diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index fb0ff3337..4a9d29283 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -61,6 +61,7 @@ import { ServerConnection, ServerProvider, serverName, useServer } from "@/conte import { SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, tabHref, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" +import { resolveLandingDirectory } from "@/pages/new-session-landing" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import LegacyLayout from "@/pages/layout" @@ -765,15 +766,20 @@ function NewSessionLanding() { return } - // Otherwise create a new draft — find a server + project to use + // Otherwise create a new draft — find a server + directory to use const connections = global.servers.list() const conn = connections[0] if (!conn) return // no server connected yet — will re-render when one connects - const project = global.ensureServerCtx(conn).projects.list()[0] - if (!project) return // no project yet + // projects.list() is the client-side store of OPENED projects, which is empty + // on a fresh profile and for servers running outside any registered project + // (the amicode chat server spawns in an internal scaffold dir). Falling back + // to a server-known worktree keeps this route from rendering nothing at all. + const ctx = global.ensureServerCtx(conn) + const directory = resolveLandingDirectory(ctx.projects.list(), ctx.sync.data.project[0]?.worktree) + if (!directory) return // nothing to land on yet — re-renders when sync arrives - tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") + tabs.newDraft({ server: ServerConnection.key(conn), directory }, "") } return ( diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 6d2bc8db5..79b87939f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { createMediaQuery } from "@solid-primitives/media" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { ProfilePopoverTrigger } from "@/components/profile-popover" import { useGlobal } from "@/context/global" +import { resolveLandingDirectory } from "@/pages/new-session-landing" import { ServerConnection, useServer } from "@/context/server" import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" @@ -315,13 +316,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return } + // Same fallback as NewSessionLanding: a server running outside any + // registered project must not leave "+" as a silent no-op. const fallback = global.servers.list().flatMap((conn) => { - const project = global.ensureServerCtx(conn).projects.list()[0] - return project ? [{ server: ServerConnection.key(conn), project }] : [] + const ctx = global.ensureServerCtx(conn) + const directory = resolveLandingDirectory(ctx.projects.list(), ctx.sync.data.project[0]?.worktree) + return directory ? [{ server: ServerConnection.key(conn), directory }] : [] })[0] if (!fallback) return - tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "") + tabs.newDraft({ server: fallback.server, directory: fallback.directory }, "") } const dialog = useDialog() const [settingsOpen, setSettingsOpen] = createSignal(false) diff --git a/packages/app/src/pages/new-session-landing.test.ts b/packages/app/src/pages/new-session-landing.test.ts new file mode 100644 index 000000000..c9c58694b --- /dev/null +++ b/packages/app/src/pages/new-session-landing.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { resolveLandingDirectory } from "./new-session-landing" + +describe("resolveLandingDirectory", () => { + test("uses the first registered project's worktree", () => { + expect(resolveLandingDirectory([{ worktree: "/repo/a" }, { worktree: "/repo/b" }], "/cwd")).toBe("/repo/a") + }) + + // The amicode chat server runs with cwd set to an internal scaffold dir that is + // never registered as a project. Before this fallback, NewSessionLanding and the + // titlebar "+" both gave up silently, leaving a permanently blank app. + test("falls back to the server directory when no project is registered", () => { + expect(resolveLandingDirectory([], "/scaffold/opencode-project")).toBe("/scaffold/opencode-project") + }) + + test("returns undefined when there is neither a project nor a server directory", () => { + expect(resolveLandingDirectory([], undefined)).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/new-session-landing.ts b/packages/app/src/pages/new-session-landing.ts new file mode 100644 index 000000000..b61a707e6 --- /dev/null +++ b/packages/app/src/pages/new-session-landing.ts @@ -0,0 +1,13 @@ +/** Pick the directory a new draft should be created in. + * + * Prefers the first registered project. Falls back to the server's own working + * directory, because a server can legitimately run somewhere that was never + * registered as a project — the amicode chat server does exactly that, spawning + * in an internal scaffold dir. Without the fallback both the "/" landing route + * and the titlebar "+" return silently and the app renders nothing at all. */ +export function resolveLandingDirectory( + projects: readonly { worktree: string }[], + serverDirectory: string | undefined, +): string | undefined { + return projects[0]?.worktree ?? serverDirectory +}