From 84e616d8724d273ba29db6f2e875d3ca176f72a6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 00:33:39 -0400 Subject: [PATCH] feat(app): default draft directory to workspace folder when available (amicode#872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two code paths create drafts — draftDirectory() in tabs.tsx and the navigate bridge in app.tsx. Both now check workspaceProjects() first (the extension's real VS Code folder list) before falling back to server.projects.list() (which returns the engine scaffold dir). This makes session.directory a real workspace folder from birth, so the sidebar's orphan guard can match it and the #841 expand/scroll feature works end-to-end. 7 new tests covering the preference, fallback, and override paths. --- packages/app/src/app.tsx | 10 ++- packages/app/src/context/tabs.tsx | 8 +- .../utils/amicode-workspace-projects.test.ts | 73 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index fa39019ce8..dbf3b62447 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,7 +1,7 @@ import "@/index.css" import * as Sentry from "@sentry/solid" import { requestComputeConnect } from "@/components/amicode-defaults-capsule" -import { adoptWorkspaceProjects } from "@/utils/amicode-workspace-projects" +import { adoptWorkspaceProjects, workspaceProjects } from "@/utils/amicode-workspace-projects" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -482,7 +482,13 @@ function AmicodeNavigateBridge() { if (url.pathname === "/new-session") { const prompt = url.searchParams.get("prompt") || undefined const autoSend = url.searchParams.get("autoSend") === "1" - await tabs.newDraft({ server: server.key, directory: server.projects.list()[0]?.worktree ?? "" }, prompt) + // amicode#872: prefer workspace-projects (real VS Code folders) over + // the engine's project list (which returns the scaffold dir). + const wsProjects = workspaceProjects() + const directory = wsProjects.length > 0 + ? wsProjects[0].worktree + : server.projects.list()[0]?.worktree ?? "" + await tabs.newDraft({ server: server.key, directory }, prompt) if (autoSend) setPendingAutoSend(true) } else { // Navigate to an existing session by path (e.g. /session/:id) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 5640fc8c29..1a304e82e1 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -8,6 +8,7 @@ import { ServerConnection, useServer } from "./server" import { createEffect, getOwner, onCleanup, startTransition } from "solid-js" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" +import { workspaceProjects } from "@/utils/amicode-workspace-projects" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" @@ -60,9 +61,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const platform = usePlatform() /** amicode(workbench): TabsProvider sits ABOVE the SDK/Sync providers, so * a draft's project directory comes from the route or the server - * context — never from useSDK (there is no provider above us). */ + * context — never from useSDK (there is no provider above us). + * amicode#872: prefer the extension's workspace-projects signal over + * the engine's project list — the engine's first project is the scaffold + * dir, which the sidebar's orphan guard correctly rejects. */ const draftDirectory = () => { if (params.dir) return base64Decode(params.dir) ?? "" + const wsProjects = workspaceProjects() + if (wsProjects.length > 0) return wsProjects[0].worktree return server.projects.list()[0]?.worktree ?? "" } const fallback = server.key diff --git a/packages/app/src/utils/amicode-workspace-projects.test.ts b/packages/app/src/utils/amicode-workspace-projects.test.ts index 8f0614d96e..980e59fdbf 100644 --- a/packages/app/src/utils/amicode-workspace-projects.test.ts +++ b/packages/app/src/utils/amicode-workspace-projects.test.ts @@ -73,6 +73,79 @@ describe("prompt-project-selector — no false default (#673)", () => { }) }) +// ── #872: draft directory defaults to workspace folder ────────────────── +// Two code paths create drafts — tabs.tsx draftDirectory() and app.tsx +// AmicodeNavigateBridge. Both must prefer workspaceProjects() over +// server.projects.list() so the sidebar's orphan guard can match the path. + +describe("draft directory — tabs.tsx draftDirectory (#872)", () => { + const tabsSrc = readFileSync( + resolve(__dirname, "..", "context", "tabs.tsx"), + "utf8", + ) + + test("draftDirectory checks workspaceProjects before server.projects", () => { + // The draftDirectory function must reference workspaceProjects + const draftFn = tabsSrc.slice(tabsSrc.indexOf("const draftDirectory")) + const fnEnd = draftFn.indexOf("\n const fallback") + const fnBody = draftFn.slice(0, fnEnd) + expect(fnBody).toContain("workspaceProjects") + }) + + test("tabs.tsx imports workspaceProjects from the amicode module", () => { + expect(tabsSrc).toMatch(/import\s.*workspaceProjects.*from\s+["']@\/utils\/amicode-workspace-projects["']/) + }) + + test("draftDirectory falls back to server.projects when workspaceProjects is empty", () => { + // The fallback path must still exist + const draftFn = tabsSrc.slice(tabsSrc.indexOf("const draftDirectory")) + const fnEnd = draftFn.indexOf("\n const fallback") + const fnBody = draftFn.slice(0, fnEnd) + expect(fnBody).toContain("server.projects.list()") + }) +}) + +describe("draft directory — app.tsx navigate bridge (#872)", () => { + const appSrc = readFileSync( + resolve(__dirname, "..", "app.tsx"), + "utf8", + ) + + test("navigate bridge checks workspaceProjects before server.projects for /new-session", () => { + // Find the new-session branch in the navigate bridge + const newSessionIdx = appSrc.indexOf('url.pathname === "/new-session"') + const bridgeSlice = appSrc.slice(newSessionIdx, newSessionIdx + 600) + expect(bridgeSlice).toContain("workspaceProjects") + }) + + test("navigate bridge falls back to server.projects when workspaceProjects is empty", () => { + const newSessionIdx = appSrc.indexOf('url.pathname === "/new-session"') + const bridgeSlice = appSrc.slice(newSessionIdx, newSessionIdx + 600) + expect(bridgeSlice).toContain("server.projects.list()") + }) +}) + +describe("explicit project selection overrides the default (#872 AC4)", () => { + const ctrlSrc = readFileSync( + resolve(__dirname, "..", "pages", "session", "composer", "session-composer-controls.ts"), + "utf8", + ) + + test("selectProject writes the selected worktree directly to the draft (updateDraft)", () => { + // The explicit-selection path must call updateDraft with { directory: worktree } + // so it overrides the draftDirectory() default entirely. + const selectFn = ctrlSrc.slice(ctrlSrc.indexOf("const selectProject")) + expect(selectFn).toContain("tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })") + }) + + test("selectProject does not re-invoke draftDirectory", () => { + // The explicit-selection path must not go through draftDirectory — it writes + // the selected worktree directly. + const selectFn = ctrlSrc.slice(ctrlSrc.indexOf("const selectProject")) + expect(selectFn).not.toContain("draftDirectory") + }) +}) + describe("session-composer-controls — toggle deselect (#673)", () => { const ctrlSrc = readFileSync( resolve(__dirname, "..", "pages", "session", "composer", "session-composer-controls.ts"),