From a02099284dd5365937310fd47658a945b4435794 Mon Sep 17 00:00:00 2001 From: cynfria Date: Wed, 12 Aug 2026 16:56:56 -0700 Subject: [PATCH 1/2] feat: improve multi-workspace chat setup --- scripts/design-system-audit.mjs | 1 - .../crates/berdctl/api-surface-feedback.json | 18 +- src-tauri/crates/berdctl/api-surface.json | 18 +- .../crates/berdctl/cli-surface-feedback.json | 2 +- src-tauri/crates/berdctl/cli-surface.json | 2 +- src/app/AppShell.tsx | 45 +- src/app/SessionWindowApp.tsx | 5 +- .../__tests__/commands/commands.test.ts | 23 +- .../berdctl/commands/impl/getProject.ts | 7 +- .../commands/impl/setProjectStartupMode.ts | 63 +-- .../useChatSessionController.test.ts | 64 +++ .../hooks/__tests__/useMessageQueue.test.ts | 21 + .../chat/hooks/useChatSessionController.ts | 155 +++++- src/features/chat/hooks/useMessageQueue.ts | 12 + .../chat/lib/firstWorkspaceSend.test.ts | 308 +++++++++++- src/features/chat/lib/firstWorkspaceSend.ts | 353 +++++++++++-- .../chat/lib/sessionTargetCoordinator.test.ts | 67 +++ .../chat/lib/sessionTargetCoordinator.ts | 19 + src/features/chat/ui/ChatInput.tsx | 279 +++++++---- src/features/chat/ui/ChatView.tsx | 19 +- .../chat/ui/WorkspaceSetupChoice.test.tsx | 60 ++- src/features/chat/ui/WorkspaceSetupChoice.tsx | 312 ++++++------ .../chat/ui/__tests__/ChatInput.test.tsx | 63 ++- .../chat/ui/widgets/WorkspaceIdentity.tsx | 50 +- .../generated/componentManifest.ts | 1 + .../design-system/ui/DesignSystemView.tsx | 1 + src/features/projects/api/projects.test.ts | 52 +- src/features/projects/api/projects.ts | 30 +- .../lib/projectChatWorkspaces.test.ts | 28 +- .../projects/lib/projectChatWorkspaces.ts | 83 +++- .../projects/ui/CreateProjectDialog.tsx | 214 ++++---- .../ui/__tests__/CreateProjectDialog.test.tsx | 468 ++++++------------ src/shared/i18n/locales/en/projects.json | 6 + src/shared/i18n/locales/es/projects.json | 6 + src/shared/ui/button.tsx | 4 + 35 files changed, 1990 insertions(+), 869 deletions(-) diff --git a/scripts/design-system-audit.mjs b/scripts/design-system-audit.mjs index 2543e39c8..9b712d4fa 100644 --- a/scripts/design-system-audit.mjs +++ b/scripts/design-system-audit.mjs @@ -122,7 +122,6 @@ const buttonStylingBaseline = new Set([ "src/features/extensions/ui/ExtensionModal.tsx", "src/features/home/widgets/ChecklistWidget.tsx", "src/features/home/widgets/StickyNoteWidget.tsx", - "src/features/projects/ui/CreateProjectDialog.tsx", "src/features/projects/ui/ProjectsView.tsx", "src/features/sessions/ui/session-list/SidebarFlatChatsSection.tsx", "src/features/sessions/ui/session-list/SidebarProjectList.tsx", diff --git a/src-tauri/crates/berdctl/api-surface-feedback.json b/src-tauri/crates/berdctl/api-surface-feedback.json index 898183ea1..e3e404ac5 100644 --- a/src-tauri/crates/berdctl/api-surface-feedback.json +++ b/src-tauri/crates/berdctl/api-surface-feedback.json @@ -728,7 +728,7 @@ } }, "set_startup_mode": { - "description": "Set the startup behavior for new chats in a project. `worktree` creates an isolated Git worktree per chat, `branch` creates a branch, and `none` uses the configured folders as-is. Existing chats and project folder paths are not changed.", + "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", "fields": [ { "name": "project_id", @@ -741,7 +741,13 @@ "required": true, "kind": "string", "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree.", - "values": ["none", "branch", "worktree"] + "values": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ] } ], "schema": { @@ -754,7 +760,13 @@ }, "mode": { "type": "string", - "enum": ["none", "branch", "worktree"], + "enum": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ], "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree." } }, diff --git a/src-tauri/crates/berdctl/api-surface.json b/src-tauri/crates/berdctl/api-surface.json index ef25e2ed4..18c5163b0 100644 --- a/src-tauri/crates/berdctl/api-surface.json +++ b/src-tauri/crates/berdctl/api-surface.json @@ -728,7 +728,7 @@ } }, "set_startup_mode": { - "description": "Set the startup behavior for new chats in a project. `worktree` creates an isolated Git worktree per chat, `branch` creates a branch, and `none` uses the configured folders as-is. Existing chats and project folder paths are not changed.", + "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", "fields": [ { "name": "project_id", @@ -741,7 +741,13 @@ "required": true, "kind": "string", "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree.", - "values": ["none", "branch", "worktree"] + "values": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ] } ], "schema": { @@ -754,7 +760,13 @@ }, "mode": { "type": "string", - "enum": ["none", "branch", "worktree"], + "enum": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ], "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree." } }, diff --git a/src-tauri/crates/berdctl/cli-surface-feedback.json b/src-tauri/crates/berdctl/cli-surface-feedback.json index 2f5cdb2c2..494968c97 100644 --- a/src-tauri/crates/berdctl/cli-surface-feedback.json +++ b/src-tauri/crates/berdctl/cli-surface-feedback.json @@ -115,7 +115,7 @@ "set-startup-mode": { "action": "set_startup_mode", "about": "Set how a project's new chats start from its Git workspaces", - "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Branch and worktree modes\nprompt for a startup name when the next chat is created.\n\nExamples:\n berdctl project set-startup-mode --project-id --mode worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ]}" + "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Legacy \"worktree\" becomes\n\"auto-worktree\" and legacy \"branch\" becomes \"ask-worktree\".\n\nExamples:\n berdctl project set-startup-mode --project-id --mode auto-worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"auto-worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"auto-worktree\"}\n ]}" }, "archive": { "action": "archive", diff --git a/src-tauri/crates/berdctl/cli-surface.json b/src-tauri/crates/berdctl/cli-surface.json index 1183f9219..a8584db5d 100644 --- a/src-tauri/crates/berdctl/cli-surface.json +++ b/src-tauri/crates/berdctl/cli-surface.json @@ -115,7 +115,7 @@ "set-startup-mode": { "action": "set_startup_mode", "about": "Set how a project's new chats start from its Git workspaces", - "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Branch and worktree modes\nprompt for a startup name when the next chat is created.\n\nExamples:\n berdctl project set-startup-mode --project-id --mode worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ]}" + "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Legacy \"worktree\" becomes\n\"auto-worktree\" and legacy \"branch\" becomes \"ask-worktree\".\n\nExamples:\n berdctl project set-startup-mode --project-id --mode auto-worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"auto-worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"auto-worktree\"}\n ]}" }, "archive": { "action": "archive", diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ef28b6dc4..ceb780bcf 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -15,8 +15,12 @@ import { eventMatchesShortcutCommand } from "@/features/shortcuts/lib/shortcutRe import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { prefetchProjectArtifactRenderer } from "@/features/projects/artifact/prefetchProjectArtifactRenderer"; import { getPlatform, type Platform } from "@/shared/lib/platform"; -import { archiveProject } from "@/features/projects/api/projects"; -import type { ProjectInfo } from "@/features/projects/api/projects"; +import { + archiveProject, + isWorktreeStartupMode, + requiresWorkspaceStartup, + type ProjectInfo, +} from "@/features/projects/api/projects"; import { DEFAULT_SETTINGS_SECTION, resolveEnabledSettingsSection, @@ -1054,6 +1058,9 @@ export function AppShell({ const retryFailedSessionsForProjectRef = useRef< (project: ProjectInfo) => void >(() => {}); + const startChatForCreatedProjectRef = useRef<(project: ProjectInfo) => void>( + () => {}, + ); const refreshProjectsAfterDialogSave = useCallback( (savedProject: ProjectInfo) => { useProjectStore @@ -1084,6 +1091,8 @@ export function AppShell({ openEditProjectDialog, } = useProjectDialog({ onProjectSaved: refreshProjectsAfterDialogSave, + onProjectCreated: (project) => + startChatForCreatedProjectRef.current(project), }); const startup = useAppStartup(); const [startupLoadingMinElapsed, setStartupLoadingMinElapsed] = useState( @@ -1941,7 +1950,10 @@ export function AppShell({ transferSessionTargetOwnership(session.id, sessionId); promoteDraftSession(session.id, sessionId, { executionTarget: promotedTarget, - workingDir, + workingDir: latestSessionAfterReady.workingDir ?? workingDir, + workspaceAttachments: + latestSessionAfterReady.workspaceAttachments, + activeWorkspaceId: latestSessionAfterReady.activeWorkspaceId, ...latestSessionPatch, ...(resolvedConfigOptionsSnapshot?.reasoningEffort ? { @@ -2344,8 +2356,8 @@ export function AppShell({ const chatState = useChatStore.getState(); const needsStartup = workspaceRepository.mode === "multi" && - project.projectWorkspaces.some( - (workspace) => workspace.startupMode !== "none", + project.projectWorkspaces.some((workspace) => + requiresWorkspaceStartup(workspace.startupMode), ); const existingDraft = findExistingDraft({ sessions: sessionState.sessions, @@ -2383,7 +2395,9 @@ export function AppShell({ workspaceAttachments: needsStartup ? asIs?.workspaceAttachments.filter( (_, index) => - project.projectWorkspaces[index]?.startupMode === "none", + !requiresWorkspaceStartup( + project.projectWorkspaces[index]?.startupMode ?? "none", + ), ) : asIs?.workspaceAttachments, }); @@ -2484,6 +2498,12 @@ export function AppShell({ ], ); + startChatForCreatedProjectRef.current = (project) => { + void createNewProjectDraft(DEFAULT_CHAT_TITLE, project).catch((error) => { + logProjectChatStartError("Failed to start chat for new project:", error); + }); + }; + const activateDeferredChatSession = useCallback( (sessionId: string) => { const liveSessionId = resolveLiveSessionId(sessionId); @@ -4979,17 +4999,20 @@ export function AppShell({ onCreatePersona={agentBuilder.create} onStartAgentBuilderSession={agentBuilder.start} onArchiveChat={handleArchiveChat} - onCreateProject={() => { + onCreateProject={(options) => { if (starterTasksVisible) { setStarterTasksAwaitingCompletion((awaiting) => new Set(awaiting).add("create-project"), ); openCreateProjectDialog({ - onCreated: handleStarterProjectCreated, + onCreated: (projectId) => { + handleStarterProjectCreated(projectId); + options?.onCreated?.(projectId); + }, }); return; } - openCreateProjectDialog(); + openCreateProjectDialog(options); }} onOpenProjectSettings={handleEditProject} onActivateHomeSession={activateHomeSession} @@ -5133,8 +5156,8 @@ export function AppShell({ requestIdentity={pendingWorkspaceName ?? undefined} workspaces={pendingWorkspaceName?.workspaces ?? []} requiresWorktreeSafeName={Boolean( - pendingWorkspaceName?.workspaces.some( - (workspace) => workspace.startupMode === "worktree", + pendingWorkspaceName?.workspaces.some((workspace) => + isWorktreeStartupMode(workspace.startupMode), ), )} onCancel={closeWorkspaceName} diff --git a/src/app/SessionWindowApp.tsx b/src/app/SessionWindowApp.tsx index 3dfc8ad1f..c20ec6d89 100644 --- a/src/app/SessionWindowApp.tsx +++ b/src/app/SessionWindowApp.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { isWorktreeStartupMode } from "@/features/projects/api/projects"; import { runChatRuntimeStartup } from "@/app/lib/chatRuntimeStartup"; import { SessionWindowTopBar } from "@/app/ui/SessionWindowTopBar"; @@ -447,8 +448,8 @@ export function SessionWindowApp({ requestIdentity={workspaceName ?? undefined} workspaces={workspaceName?.workspaces ?? []} requiresWorktreeSafeName={Boolean( - workspaceName?.workspaces.some( - (workspace) => workspace.startupMode === "worktree", + workspaceName?.workspaces.some((workspace) => + isWorktreeStartupMode(workspace.startupMode), ), )} onCancel={cancelWorkspaceNameRequest} diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index 537f7df1f..0ae4fa33d 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -3533,7 +3533,7 @@ describe("projects", () => { expect.objectContaining({ id: "ws-main", path: "/projects/repo", - startupMode: "worktree", + startupMode: "auto-worktree", }), expect.objectContaining({ id: "ws-docs", @@ -3545,9 +3545,9 @@ describe("projects", () => { ); expect(result).toEqual({ ok: true, - mode: "worktree", + mode: "auto-worktree", workspaces: [ - { path: "/projects/repo", startup_mode: "worktree" }, + { path: "/projects/repo", startup_mode: "auto-worktree" }, { path: "/projects/docs", startup_mode: "none" }, ], }); @@ -3582,7 +3582,7 @@ describe("projects", () => { ); }); - it("rejects branch mode across different checkouts of the same repository", async () => { + it("migrates legacy branch mode to manual worktree management", async () => { const linkedWorkspace = { ...mainWorkspace, id: "ws-linked", @@ -3619,15 +3619,14 @@ describe("projects", () => { localBranches: ["main", "feature"], })); - await expectCommandError( - dispatchCommand( - "projects", - { action: "set_startup_mode", project_id: "p-1", mode: "branch" }, - ctx, - ), - "invalid_args", + const result = await dispatchCommand( + "projects", + { action: "set_startup_mode", project_id: "p-1", mode: "branch" }, + ctx, ); - expect(mocks.updateProject).not.toHaveBeenCalled(); + + expect(result).toMatchObject({ mode: "ask-worktree" }); + expect(mocks.updateProject).toHaveBeenCalled(); }); it("rejects branch/worktree mode when the project has no Git workspaces", async () => { diff --git a/src/features/berdctl/commands/impl/getProject.ts b/src/features/berdctl/commands/impl/getProject.ts index 138a40109..04407e471 100644 --- a/src/features/berdctl/commands/impl/getProject.ts +++ b/src/features/berdctl/commands/impl/getProject.ts @@ -16,7 +16,12 @@ interface GetProjectResult { working_dirs: string[]; workspaces: Array<{ path: string; - startup_mode: "none" | "branch" | "worktree"; + startup_mode: + | "none" + | "branch" + | "worktree" + | "ask-worktree" + | "auto-worktree"; }>; archived: boolean; session_count: number; diff --git a/src/features/berdctl/commands/impl/setProjectStartupMode.ts b/src/features/berdctl/commands/impl/setProjectStartupMode.ts index cb265b612..ed426cde2 100644 --- a/src/features/berdctl/commands/impl/setProjectStartupMode.ts +++ b/src/features/berdctl/commands/impl/setProjectStartupMode.ts @@ -11,7 +11,7 @@ const setProjectStartupModeSchema = z .object({ project_id: z.string().describe("Id of the project to update."), mode: z - .enum(["none", "branch", "worktree"]) + .enum(["none", "branch", "worktree", "ask-worktree", "auto-worktree"]) .describe( "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree.", ), @@ -27,6 +27,14 @@ interface SetProjectStartupModeResult { }>; } +function normalizeRequestedMode( + mode: z.infer["mode"], +): ProjectWorkspaceStartupMode { + if (mode === "branch") return "ask-worktree"; + if (mode === "worktree") return "auto-worktree"; + return mode; +} + function workspacePathKey(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, ""); } @@ -43,24 +51,25 @@ export const setProjectStartupModeCommand = defineCommand({ destructive: false, summary: "Set how a project's new chats start from its Git workspaces", description: - "Set the startup behavior for new chats in a project. `worktree` creates " + - "an isolated Git worktree per chat, `branch` creates a branch, and " + - "`none` uses the configured folders as-is. Existing chats and project " + - "folder paths are not changed.", + "Set the startup behavior for new chats in a project. `auto-worktree` " + + "prompts before creating isolated worktrees, `ask-worktree` leaves " + + "worktree creation to the user, and `none` uses configured folders as-is. " + + "Legacy `worktree` and `branch` values migrate to those current modes.", helpFooter: `The mode applies to every Git workspace configured on the project. Non-Git -folders remain in the project but use mode "none". Branch and worktree modes -prompt for a startup name when the next chat is created. +folders remain in the project but use mode "none". Legacy "worktree" becomes +"auto-worktree" and legacy "branch" becomes "ask-worktree". Examples: - berdctl project set-startup-mode --project-id --mode worktree + berdctl project set-startup-mode --project-id --mode auto-worktree berdctl project set-startup-mode --project-id --mode none Result: - {"ok": true, "mode": "worktree", "workspaces": [ - {"path": "...", "startup_mode": "worktree"} + {"ok": true, "mode": "auto-worktree", "workspaces": [ + {"path": "...", "startup_mode": "auto-worktree"} ]}`, schema: setProjectStartupModeSchema, execute: async (args, ctx): Promise => { + const mode = normalizeRequestedMode(args.mode); const [ { refusePastDeadline }, { normalizeProjectWorkspaces }, @@ -94,7 +103,7 @@ Result: string, Awaited> >(); - if (args.mode !== "none") { + if (mode !== "none") { try { await Promise.all( initialWorkspaces.map(async (workspace) => { @@ -141,9 +150,8 @@ Result: } let gitWorkspaceCount = 0; - const branchCheckoutByRepository = new Map(); const projectWorkspaces = liveWorkspaces.map((workspace) => { - if (args.mode === "none") { + if (mode === "none") { return { ...workspace, startupMode: "none" as const }; } const gitState = gitStateByPath.get(workspacePathKey(workspace.path)); @@ -155,30 +163,9 @@ Result: workspace, gitState, ); - if (args.mode === "branch") { - // Match the startup planner's invariant: multiple included paths in - // one repository can share a branch startup only when they share the - // checkout where that branch will be created. - const repositoryKey = workspacePathKey( - enriched.repositoryPath ?? - gitState.mainWorktreePath ?? - workspace.path, - ); - const checkoutKey = workspacePathKey( - enriched.worktreePath ?? workspace.path, - ); - const existingCheckout = branchCheckoutByRepository.get(repositoryKey); - if (existingCheckout && existingCheckout !== checkoutKey) { - throw new CommandError( - "invalid_args", - `Project "${args.project_id}" includes multiple checkouts from the same repository; branch mode requires those workspaces to share one checkout. Use --mode worktree or edit the project folders.`, - ); - } - branchCheckoutByRepository.set(repositoryKey, checkoutKey); - } - return { ...workspace, ...enriched, startupMode: args.mode }; + return { ...workspace, ...enriched, startupMode: mode }; }); - if (args.mode !== "none" && gitWorkspaceCount === 0) { + if (mode !== "none" && gitWorkspaceCount === 0) { throw new CommandError( "invalid_args", `Project "${args.project_id}" has no Git workspaces; mode "${args.mode}" requires at least one Git workspace.`, @@ -193,13 +180,13 @@ Result: project.icon, project.color, projectWorkspaces.map((workspace) => workspace.path), - args.mode === "worktree", + mode === "auto-worktree", projectWorkspaces, ); return { ok: true, - mode: args.mode, + mode, workspaces: updated.projectWorkspaces.map((workspace) => ({ path: workspace.path, startup_mode: workspace.startupMode, diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 673f33b35..95152412a 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -577,6 +577,70 @@ describe("useChatSessionController", () => { }); }); + it("offers worktree setup before the first message is sent", () => { + useProjectStore.setState({ + projects: [ + { + id: "project-1", + path: "/tmp/project.md", + name: "Project", + description: "", + prompt: "", + icon: "", + color: "#22c55e", + projectWorkspaces: [ + { + id: "workspace-1", + path: "/repo/project", + kind: "git-main-worktree", + source: "selected", + branch: "main", + usedByAgent: false, + repositoryPath: "/repo/project", + startupMode: "worktree", + }, + ], + workingDirs: ["/repo/project"], + useWorktrees: true, + order: 0, + archivedAt: null, + artifact: null, + }, + ], + loading: false, + activeProjectId: "project-1", + }); + useChatSessionStore.getState().patchSession("session-1", { + projectId: "project-1", + workingDir: "/repo/project", + workspaceAttachments: [], + }); + useChatStore.setState({ + messagesBySession: { + "session-1": [ + { + id: "startup-system-message", + role: "system", + created: 0, + content: [{ type: "text", text: "Session initialized" }], + }, + ], + }, + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + expect(result.current.defaultWorkspaceSetup).toMatchObject({ + status: "choice", + desired: [{ id: "workspace-1", startupMode: "worktree" }], + }); + expect( + useChatStore.getState().queuedMessageBySession["session-1"], + ).toBeUndefined(); + }); + it("debounces draft store writes while composer text changes", () => { vi.useFakeTimers(); try { diff --git a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts index 506e5863e..85796a86a 100644 --- a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts +++ b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts @@ -902,6 +902,27 @@ describe("useMessageQueue", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + it("does not send the next queued message when the head is dismissed", () => { + const sendMessage = vi.fn(); + useChatStore.getState().enqueueTransportReadyMessage("s1", { + persona: { kind: "inherit" }, + text: "first", + }); + useChatStore.getState().enqueueTransportReadyMessage("s1", { + persona: { kind: "inherit" }, + text: "second", + }); + + const { result } = renderHook(() => + useMessageQueue("s1", "idle", sendMessage, false, true), + ); + + act(() => result.current.dismiss(result.current.queuedRecord?.recordId)); + + expect(result.current.queuedMessage?.text).toBe("second"); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("queued messages are scoped to session", () => { const sendMessage = vi.fn(); useChatStore.getState().enqueueTransportReadyMessage("s2", { diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 35758eb1b..d6674d861 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -24,6 +24,7 @@ import { useAgentStore } from "@/features/agents/stores/agentStore"; import { selectPersonas } from "@/features/agents/stores/agentSelectors"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { isAskWorktreeStartupMode } from "@/features/projects/api/projects"; import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; @@ -64,7 +65,9 @@ import { chooseDeferredWorkspaceSetup, cancelDeferredWorkspaceNaming, createDeferredWorkspaces, + provisionPreSendProjectWorkspaces, releaseDeferredWorkspaceSend, + workspaceAttachmentsEqualConfiguration, UNRESOLVED_DEFERRED_SEND_ERROR, type DeferredWorkspaceSend, type WorkspaceNameRequest, @@ -361,6 +364,13 @@ export function useChatSessionController({ useState(); const [pendingModelSelection, setPendingModelSelection] = useState(); + const preSendWorkspaceOperationRef = useRef(0); + const [preSendWorkspaceSetup, setPreSendWorkspaceSetup] = useState<{ + sessionId: string; + status: "choice" | "naming" | "creating" | "selected"; + startupName?: string | null; + error?: string; + } | null>(null); const pendingDefaultReasoningEffortBySessionRef = useRef< Record >({}); @@ -371,7 +381,11 @@ export function useChatSessionController({ {}, ); const sessionLocalMessageCount = useChatStore((s) => - sessionId ? (s.messagesBySession[sessionId]?.length ?? 0) : 0, + sessionId + ? (s.messagesBySession[sessionId]?.filter( + (message) => message.role !== "system", + ).length ?? 0) + : 0, ); const sessionHasStarted = session ? hasSessionStarted(session, sessionLocalMessageCount) @@ -1963,6 +1977,9 @@ export function useChatSessionController({ (s.messagesBySession[sessionId]?.length ?? 0) === 0 : false, ); + const hasQueuedMessages = useChatStore( + (state) => (state.queuedMessageBySession[stateSessionId]?.length ?? 0) > 0, + ); const deferredWorkspaceRecord = useChatStore((state) => { const record = state.queuedMessageBySession[stateSessionId]?.[0]; return record?.kind === "deferred" && @@ -1973,6 +1990,39 @@ export function useChatSessionController({ const unresolvedDeferredSend = deferredWorkspaceRecord?.state.error === UNRESOLVED_DEFERRED_SEND_ERROR && !session?.executionTarget; + const currentPreSendWorkspaceSetup = + preSendWorkspaceSetup?.sessionId === stateSessionId + ? preSendWorkspaceSetup + : null; + const canOfferPreSendWorkspaceSetup = Boolean( + !readOnly && + sessionId && + session && + !sessionHasStarted && + !deferredWorkspaceRecord && + !hasQueuedMessages && + workspaceRepository.mode === "multi" && + project?.projectWorkspaces.some((workspace) => + isAskWorktreeStartupMode(workspace.startupMode), + ) && + !workspaceAttachmentsEqualConfiguration( + project.projectWorkspaces, + session.workspaceAttachments, + ), + ); + const defaultWorkspaceSetup = + (canOfferPreSendWorkspaceSetup || currentPreSendWorkspaceSetup) && + currentPreSendWorkspaceSetup?.status !== "selected" + ? { + status: currentPreSendWorkspaceSetup?.status ?? ("choice" as const), + desired: project?.projectWorkspaces ?? [], + error: currentPreSendWorkspaceSetup?.error, + } + : null; + const preselectedWorkspaceStartupName = + currentPreSendWorkspaceSetup?.status === "selected" + ? currentPreSendWorkspaceSetup.startupName + : undefined; useEffect(() => { if ( !deferredWorkspaceRecord || @@ -2236,6 +2286,9 @@ export function useChatSessionController({ attachments?: ChatAttachmentDraft[], sendOptions?: ChatSendOptions, ) => { + if (currentPreSendWorkspaceSetup?.status === "creating") { + return false; + } const personaName = personaId ? selectedPersona?.id === personaId ? selectedPersona.displayName @@ -2329,6 +2382,7 @@ export function useChatSessionController({ { cancelBuilderDraftPath: builderSession.targetAgentPath ?? undefined, + startupName: preselectedWorkspaceStartupName, onNeedsName: onBuilderWorkspaceNameRequest, }, ); @@ -2370,7 +2424,10 @@ export function useChatSessionController({ attachments, sendOptions, }), - { onNeedsName: onWorkspaceNameRequest }, + { + startupName: preselectedWorkspaceStartupName, + onNeedsName: onWorkspaceNameRequest, + }, ); if (firstSend.accepted) { recordDraftPreservingSubmission(sessionId, text); @@ -2402,7 +2459,10 @@ export function useChatSessionController({ attachments, sendOptions: preparedSendOptions, }), - { onNeedsName: onWorkspaceNameRequest }, + { + startupName: preselectedWorkspaceStartupName, + onNeedsName: onWorkspaceNameRequest, + }, ); if (firstSend.accepted) { recordDraftPreservingSubmission(sessionId, text); @@ -2420,11 +2480,13 @@ export function useChatSessionController({ }, [ captureSessionSelection, + currentPreSendWorkspaceSetup?.status, enqueueCapturedMessage, ensureCurrentSessionIsAgentBuilder, handlePersonaChange, onMessageAccepted, onWorkspaceNameRequest, + preselectedWorkspaceStartupName, queue, readOnly, recordDraftPreservingSubmission, @@ -2908,22 +2970,89 @@ export function useChatSessionController({ dismiss: dismissQueuedMessage, }, deferredWorkspaceRecord, + defaultWorkspaceSetup, deferredWorkspaceError: deferredWorkspaceRecord?.state.error, unresolvedDeferredSend, - cancelDeferredWorkspaceName: () => - readOnly ? false : cancelDeferredWorkspaceNaming(stateSessionId), - createDeferredWorkspace: () => - readOnly ? false : chooseDeferredWorkspaceSetup(stateSessionId, true), - submitDeferredWorkspaceName: (name: string) => - !readOnly && deferredWorkspaceRecord?.state.status === "naming" + cancelDeferredWorkspaceName: () => { + if (defaultWorkspaceSetup?.status === "naming") { + setPreSendWorkspaceSetup({ + sessionId: stateSessionId, + status: "choice", + }); + return true; + } + return readOnly ? false : cancelDeferredWorkspaceNaming(stateSessionId); + }, + createDeferredWorkspace: () => { + if (defaultWorkspaceSetup?.status === "choice") { + setPreSendWorkspaceSetup({ + sessionId: stateSessionId, + status: "naming", + }); + return true; + } + return readOnly + ? false + : chooseDeferredWorkspaceSetup(stateSessionId, true); + }, + submitDeferredWorkspaceName: (name: string) => { + if (defaultWorkspaceSetup?.status === "naming" && project) { + const operationId = preSendWorkspaceOperationRef.current + 1; + preSendWorkspaceOperationRef.current = operationId; + setPreSendWorkspaceSetup({ + sessionId: stateSessionId, + status: "creating", + }); + void provisionPreSendProjectWorkspaces( + stateSessionId, + project, + name, + ).then( + () => + setPreSendWorkspaceSetup((current) => + preSendWorkspaceOperationRef.current === operationId && + current?.sessionId === stateSessionId + ? null + : current, + ), + (error) => { + console.error("Failed to configure project worktree:", error); + setPreSendWorkspaceSetup((current) => + preSendWorkspaceOperationRef.current === operationId && + current?.sessionId === stateSessionId + ? { + sessionId: stateSessionId, + status: "naming", + error: + error instanceof Error ? error.message : String(error), + } + : current, + ); + }, + ); + return; + } + return !readOnly && deferredWorkspaceRecord?.state.status === "naming" ? void createDeferredWorkspaces( stateSessionId, deferredWorkspaceRecord.recordId, name, ) - : undefined, - skipDeferredWorkspace: () => - readOnly ? false : chooseDeferredWorkspaceSetup(stateSessionId, false), + : undefined; + }, + skipDeferredWorkspace: () => { + if (defaultWorkspaceSetup) { + setPreSendWorkspaceSetup({ + sessionId: stateSessionId, + status: "selected", + startupName: null, + }); + return true; + } + return readOnly + ? false + : chooseDeferredWorkspaceSetup(stateSessionId, false); + }, sendDeferredAnyway: () => !readOnly && deferredWorkspaceRecord && @@ -2935,6 +3064,8 @@ export function useChatSessionController({ ) : false, handleSend, + workspaceSetupInProgress: + currentPreSendWorkspaceSetup?.status === "creating", steerDraftMessage, canSteerMessage: Boolean( sessionId && diff --git a/src/features/chat/hooks/useMessageQueue.ts b/src/features/chat/hooks/useMessageQueue.ts index c1eff9af2..6b5b2f389 100644 --- a/src/features/chat/hooks/useMessageQueue.ts +++ b/src/features/chat/hooks/useMessageQueue.ts @@ -106,6 +106,7 @@ export function useMessageQueue( QueuedMessageRecord["payload"] | null >(null); const suppressNextRenderIdleCycleRef = useRef(false); + const dismissedRecordIdRef = useRef(null); const queuedMessageKey = useMemo( () => getQueuedMessageKey(queuedRecord), [queuedRecord], @@ -365,6 +366,12 @@ export function useMessageQueue( const advancedToNextRecord = queuedMessage?.recordId !== previousQueuedMessage?.recordId && previousQueuedMessage !== undefined; + const advancedAfterDismiss = + advancedToNextRecord && + previousQueuedMessage?.recordId === dismissedRecordIdRef.current; + if (advancedAfterDismiss) { + dismissedRecordIdRef.current = null; + } if (editedCurrentRecord || advancedToNextRecord) { automaticallyRetriedPayloadRef.current = null; @@ -395,6 +402,10 @@ export function useMessageQueue( return; } + if (advancedAfterDismiss) { + return; + } + if (currentChatState !== "idle" || isLiveSendBlocked) { return; } @@ -503,6 +514,7 @@ export function useMessageQueue( (recordId?: string) => { const targetId = recordId ?? queuedRecord?.recordId; if (targetId) { + dismissedRecordIdRef.current = targetId; useChatStore.getState().dismissQueuedMessage(sessionId, targetId); } }, diff --git a/src/features/chat/lib/firstWorkspaceSend.test.ts b/src/features/chat/lib/firstWorkspaceSend.test.ts index a57f31d0b..048db838b 100644 --- a/src/features/chat/lib/firstWorkspaceSend.test.ts +++ b/src/features/chat/lib/firstWorkspaceSend.test.ts @@ -14,6 +14,7 @@ import { chooseDeferredWorkspaceSetup, createDeferredWorkspaces, prepareExistingFirstSend, + provisionPreSendProjectWorkspaces, releaseDeferredWorkspaceSend, releaseWorkspaceSendAfterUserEdit, workspaceAttachmentsEqualConfiguration, @@ -109,6 +110,80 @@ describe("workspace attachment equality", () => { }); describe("first workspace send", () => { + it("provisions a named worktree before any message is queued", async () => { + const created = { + ...selected, + id: "created", + path: "/repo/worktrees/feature/app", + source: "created" as const, + worktreePath: "/repo/worktrees/feature", + }; + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce({ + workingDir: created.path, + workspaceAttachments: [created], + }); + vi.mocked(transitionSessionTarget).mockImplementationOnce(async () => { + expect(useChatSessionStore.getState().getSession("s1")).toMatchObject({ + workingDir: "/repo/app", + workspaceAttachments: [], + }); + return { + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }; + }); + + await provisionPreSendProjectWorkspaces("s1", project, "feature"); + + expect(planProjectChatWorkspaces).toHaveBeenCalledWith(project, "feature"); + expect(useChatStore.getState().queuedMessageBySession.s1).toBeUndefined(); + expect(useChatSessionStore.getState().getSession("s1")).toMatchObject({ + workingDir: created.path, + workspaceAttachments: [created], + activeWorkspaceId: created.id, + }); + expect(transitionSessionTarget).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s1", workingDir: created.path }), + ); + }); + + it("restores the backend target before rolling back a stale completed setup", async () => { + const created = { + ...selected, + id: "created", + path: "/repo/worktrees/feature/app", + source: "created" as const, + worktreePath: "/repo/worktrees/feature", + }; + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce({ + workingDir: created.path, + workspaceAttachments: [created], + }); + let transitionCount = 0; + vi.mocked(transitionSessionTarget).mockImplementation(async () => { + transitionCount += 1; + if (transitionCount === 1) { + useProjectStore.setState({ projects: [] }); + } + return { + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }; + }); + + await expect( + provisionPreSendProjectWorkspaces("s1", project, "feature"), + ).rejects.toThrow("The project workspace changed during setup. Try again."); + + expect(transitionSessionTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sessionId: "s1", workingDir: "/repo/app" }), + ); + expect(rollbackProjectChatWorkspacePlan).toHaveBeenCalledOnce(); + }); + it("converts an existing transport-ready first send into the choice flow", () => { const onNeedsName = vi.fn(); const onChoice = vi.fn(); @@ -221,6 +296,65 @@ describe("first workspace send", () => { }); }); + it("shows the piggyback for auto-create worktrees", () => { + const onNeedsName = vi.fn(); + useProjectStore.setState({ + projects: [ + { + ...project, + projectWorkspaces: [{ ...workspace, startupMode: "auto-worktree" }], + }, + ], + }); + + expect( + acceptFirstSend( + "s1", + { persona: { kind: "inherit" }, text: "hello" }, + { onNeedsName }, + ), + ).toEqual({ + accepted: true, + deferred: true, + needsName: false, + }); + expect( + useChatStore.getState().queuedMessageBySession.s1?.[0], + ).toMatchObject({ + kind: "deferred", + state: { status: "choice" }, + }); + expect(onNeedsName).not.toHaveBeenCalled(); + expect(planProjectChatWorkspaces).not.toHaveBeenCalled(); + }); + + it("sends normally when worktrees are manually managed", () => { + useProjectStore.setState({ + projects: [ + { + ...project, + projectWorkspaces: [{ ...workspace, startupMode: "ask-worktree" }], + }, + ], + }); + + expect( + acceptFirstSend( + "s1", + { persona: { kind: "inherit" }, text: "hello" }, + { queueReady: true }, + ), + ).toEqual({ + accepted: true, + deferred: false, + needsName: false, + }); + expect( + useChatStore.getState().queuedMessageBySession.s1?.[0], + ).toMatchObject({ kind: "transport-ready", payload: { text: "hello" } }); + expect(planProjectChatWorkspaces).not.toHaveBeenCalled(); + }); + it("keeps the prior naming flow for non-worktree startup modes", () => { const onNeedsName = vi.fn(); useProjectStore.setState({ @@ -380,6 +514,129 @@ describe("first workspace send", () => { }); }); + it("ignores workspace ordering and derived metadata changes during setup", async () => { + useProjectStore.setState({ + projects: [ + { + ...project, + projectWorkspaces: [ + { ...workspace, branch: "enriched", usedByAgent: true }, + ], + }, + ], + }); + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce({ + workingDir: "/repo/app", + workspaceAttachments: [selected], + }); + vi.mocked(transitionSessionTarget).mockResolvedValueOnce({ + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }); + acceptFirstSend( + "s1", + { persona: { kind: "inherit" }, text: "hello" }, + { onNeedsName: vi.fn() }, + ); + const record = useChatStore.getState().queuedMessageBySession.s1?.[0]; + if (record?.kind !== "deferred") throw new Error("missing deferred record"); + + await createDeferredWorkspaces("s1", record.recordId, "feature"); + + expect( + useChatStore.getState().queuedMessageBySession.s1?.[0], + ).toMatchObject({ kind: "transport-ready", recordId: record.recordId }); + }); + + it("restores ACP and rolls back when project policy changes after prepare", async () => { + acceptFirstSend( + "s1", + { persona: { kind: "inherit" }, text: "hello" }, + { onNeedsName: vi.fn() }, + ); + const record = useChatStore.getState().queuedMessageBySession.s1?.[0]; + if (record?.kind !== "deferred") throw new Error("missing deferred record"); + const plan = { + workingDir: "/created", + workspaceAttachments: [selected], + }; + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce(plan); + vi.mocked(transitionSessionTarget).mockImplementationOnce(async () => { + useProjectStore.setState({ projects: [] }); + return { + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }; + }); + vi.mocked(transitionSessionTarget).mockResolvedValueOnce({ + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }); + + await createDeferredWorkspaces("s1", record.recordId, "feature"); + + expect(transitionSessionTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ workingDir: "/repo/app" }), + ); + expect(rollbackProjectChatWorkspacePlan).toHaveBeenCalledWith(plan); + expect( + ( + useChatStore.getState().queuedMessageBySession.s1?.[0] as { + state: { status: string }; + } + ).state.status, + ).toBe("held"); + }); + + it("preserves a concurrent workspace edit while rolling back prepared setup", async () => { + acceptFirstSend( + "s1", + { persona: { kind: "inherit" }, text: "hello" }, + { onNeedsName: vi.fn() }, + ); + const record = useChatStore.getState().queuedMessageBySession.s1?.[0]; + if (record?.kind !== "deferred") throw new Error("missing deferred record"); + const plan = { + workingDir: "/created", + workspaceAttachments: [selected], + }; + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce(plan); + vi.mocked(transitionSessionTarget).mockImplementationOnce(async () => { + useChatSessionStore.getState().patchSession("s1", { + workingDir: "/user-choice", + workspaceAttachments: [ + { ...selected, id: "user-choice", path: "/user-choice" }, + ], + }); + return { + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }; + }); + vi.mocked(transitionSessionTarget).mockResolvedValueOnce({ + status: "committed", + applied: true, + target: { harnessId: "goose" }, + }); + + await createDeferredWorkspaces("s1", record.recordId, "feature"); + + expect(transitionSessionTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ workingDir: "/user-choice" }), + ); + expect(useChatSessionStore.getState().getSession("s1")).toMatchObject({ + workingDir: "/user-choice", + workspaceAttachments: [expect.objectContaining({ id: "user-choice" })], + }); + expect(rollbackProjectChatWorkspacePlan).toHaveBeenCalledWith(plan); + }); + it("stops stale planning before config apply without calling it", async () => { acceptFirstSend( "s1", @@ -410,6 +667,41 @@ describe("first workspace send", () => { ).toBe("held"); }); + it("times out stalled draft promotion and rolls back workspace setup", async () => { + vi.useFakeTimers(); + try { + const plan = { + workingDir: "/repo/worktrees/feature/app", + workspaceAttachments: [selected], + }; + vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce(plan); + useChatSessionStore.setState({ + sessions: [ + { + ...session(), + creationState: "pending", + clientSessionId: "s1", + }, + ], + }); + + const provisioning = provisionPreSendProjectWorkspaces( + "s1", + project, + "feature", + ); + const rejection = expect(provisioning).rejects.toThrow( + "Chat creation failed during workspace setup.", + ); + await vi.advanceTimersByTimeAsync(30_000); + + await rejection; + expect(rollbackProjectChatWorkspacePlan).toHaveBeenCalledWith(plan); + } finally { + vi.useRealTimers(); + } + }); + it("waits for draft promotion before applying ACP configuration", async () => { vi.mocked(planProjectChatWorkspaces).mockResolvedValueOnce({ workingDir: "/repo/app", @@ -445,9 +737,12 @@ describe("first workspace send", () => { const creating = createDeferredWorkspaces("s1", record.recordId, "feature"); await vi.waitFor(() => { expect( - useChatSessionStore.getState().getSession("s1")?.workspaceAttachments, - ).toEqual([selected]); + useChatSessionStore.getState().getSession("s1")?.creationState, + ).toBe("pending"); }); + expect( + useChatSessionStore.getState().getSession("s1")?.workspaceAttachments, + ).toEqual([expect.objectContaining({ source: "inferred" })]); expect(transitionSessionTarget).not.toHaveBeenCalled(); useChatStore.getState().promoteSessionId("s1", "backend-s1"); @@ -492,13 +787,8 @@ describe("first workspace send", () => { const creating = createDeferredWorkspaces("s1", record.recordId, "feature"); await vi.waitFor(() => { expect( - useChatSessionStore - .getState() - .getSession("s1") - ?.workspaceAttachments?.some( - (attachment) => attachment.id === selected.id, - ), - ).toBe(true); + useChatSessionStore.getState().getSession("s1")?.creationState, + ).toBe("pending"); }); useChatSessionStore.getState().patchSession("s1", { creationState: "failed", diff --git a/src/features/chat/lib/firstWorkspaceSend.ts b/src/features/chat/lib/firstWorkspaceSend.ts index b189b7773..5358e6e73 100644 --- a/src/features/chat/lib/firstWorkspaceSend.ts +++ b/src/features/chat/lib/firstWorkspaceSend.ts @@ -1,6 +1,8 @@ -import type { - ProjectInfo, - ProjectWorkspace, +import { + isAskWorktreeStartupMode, + isWorktreeStartupMode, + type ProjectInfo, + type ProjectWorkspace, } from "@/features/projects/api/projects"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { getWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; @@ -17,6 +19,7 @@ import { isSameWorkspacePath, } from "./workspaceAttachments"; import { transitionSessionTarget } from "./sessionTargetCoordinator"; +import type { SessionExecutionTarget } from "./sessionExecutionTarget"; import { isAdmittedQueuedMessagePayload } from "./admittedSend"; import { useChatStore, @@ -27,6 +30,30 @@ import { useChatSessionStore } from "../stores/chatSessionStore"; export const UNRESOLVED_DEFERRED_SEND_ERROR = "Select a model before sending to this unresolved session."; +const WORKSPACE_SESSION_PROMOTION_TIMEOUT_MS = 30_000; + +function projectWorkspaceConfigurationRevision( + workspaces: readonly ProjectWorkspace[], +): string { + return workspaces + .map((workspace) => ({ + path: workspace.path.replace(/\\/g, "/").replace(/\/+$/, ""), + startupMode: workspace.startupMode, + })) + .sort((left, right) => left.path.localeCompare(right.path)) + .map(({ path, startupMode }) => `${path}\0${startupMode}`) + .join("\n"); +} + +function projectWorkspaceConfigurationsEqual( + left: readonly ProjectWorkspace[], + right: readonly ProjectWorkspace[], +): boolean { + return ( + projectWorkspaceConfigurationRevision(left) === + projectWorkspaceConfigurationRevision(right) + ); +} export interface DeferredWorkspaceSend { type: "workspace-first-send"; @@ -34,6 +61,7 @@ export interface DeferredWorkspaceSend { projectId: string; desired: ProjectWorkspace[]; cancelBuilderDraftPath?: string; + configurationRevision?: string; error?: string; } @@ -57,12 +85,11 @@ function attachmentMatches( isSameWorkspacePath(attachment.path, workspace.path) ); } - const requiredCleanup = - workspace.startupMode === "worktree" - ? "worktree" - : workspace.startupMode === "branch" - ? "branch" - : null; + const requiredCleanup = isWorktreeStartupMode(workspace.startupMode) + ? "worktree" + : workspace.startupMode === "branch" + ? "branch" + : null; if (requiredCleanup && attachment.lifecycle?.cleanup !== requiredCleanup) { return false; } @@ -122,6 +149,48 @@ function resolveDeferredSessionId( return queuedEntry?.[0] ?? null; } +async function waitForPromotedSessionId( + originalSessionId: string, +): Promise { + const resolve = (): string | null | undefined => { + const session = useChatSessionStore + .getState() + .sessions.find( + (candidate) => + candidate.id === originalSessionId || + candidate.clientSessionId === originalSessionId, + ); + if (!session || session.archivedAt || session.creationState === "failed") { + return null; + } + return session.creationState === "pending" || + session.id === originalSessionId + ? undefined + : session.id; + }; + + const initial = resolve(); + if (initial !== undefined) return initial; + return new Promise((finish) => { + let settled = false; + const complete = (result: string | null) => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + unsubscribe(); + finish(result); + }; + const unsubscribe = useChatSessionStore.subscribe(() => { + const result = resolve(); + if (result !== undefined) complete(result); + }); + const timeoutId = window.setTimeout( + () => complete(null), + WORKSPACE_SESSION_PROMOTION_TIMEOUT_MS, + ); + }); +} + async function waitForBackendSessionId( originalSessionId: string, recordId: string, @@ -226,6 +295,150 @@ export function hasDeferredWorkspaceSend(sessionId: string): boolean { return deferredRecord(sessionId) !== null; } +export async function provisionPreSendProjectWorkspaces( + sessionId: string, + project: ProjectInfo, + startupName: string, +): Promise { + let resolvedSessionId = resolveDeferredSessionId(sessionId); + const session = resolvedSessionId + ? useChatSessionStore.getState().getSession(resolvedSessionId) + : null; + if (!resolvedSessionId || !session || session.archivedAt) { + throw new Error("The chat is no longer available for workspace setup."); + } + + let originalWorkspaceState = { + workingDir: session.workingDir, + workspaceAttachments: session.workspaceAttachments, + activeWorkspaceId: session.activeWorkspaceId, + }; + let originalWorkspaceSnapshot = sessionSnapshot(sessionId); + let switchedTarget: SessionExecutionTarget | undefined; + const plan = await planProjectChatWorkspaces(project, startupName); + if (!plan) { + throw new Error("The project has no folders to configure."); + } + + try { + const liveProject = useProjectStore + .getState() + .projects.find((candidate) => candidate.id === project.id); + if ( + !liveProject || + !projectWorkspaceConfigurationsEqual( + liveProject.projectWorkspaces, + project.projectWorkspaces, + ) + ) { + throw new Error("The project folders changed during workspace setup."); + } + + if (session.creationState === "pending") { + const promotedSessionId = await waitForPromotedSessionId(sessionId); + if (!promotedSessionId) { + throw new Error("Chat creation failed during workspace setup."); + } + resolvedSessionId = promotedSessionId; + const promotedSession = useChatSessionStore + .getState() + .getSession(resolvedSessionId); + if (!promotedSession) { + throw new Error("Chat creation failed during workspace setup."); + } + originalWorkspaceState = { + workingDir: promotedSession.workingDir, + workspaceAttachments: promotedSession.workspaceAttachments, + activeWorkspaceId: promotedSession.activeWorkspaceId, + }; + originalWorkspaceSnapshot = sessionSnapshot(resolvedSessionId); + } + + const currentProject = useProjectStore + .getState() + .projects.find((candidate) => candidate.id === project.id); + const currentSessionSnapshot = sessionSnapshot(resolvedSessionId); + if ( + !currentProject || + !projectWorkspaceConfigurationsEqual( + currentProject.projectWorkspaces, + project.projectWorkspaces, + ) || + currentSessionSnapshot !== originalWorkspaceSnapshot + ) { + throw new Error("The project workspace changed during setup. Try again."); + } + + const preparedSession = useChatSessionStore + .getState() + .getSession(resolvedSessionId); + if (!preparedSession?.executionTarget) { + throw new Error("Select a model before configuring this workspace."); + } + const prepared = await transitionSessionTarget({ + sessionId: resolvedSessionId, + target: preparedSession.executionTarget, + workingDir: plan.workingDir, + }); + if (!prepared.applied) { + throw new Error("The chat could not switch to the new worktree."); + } + switchedTarget = preparedSession.executionTarget; + const finalProject = useProjectStore + .getState() + .projects.find((candidate) => candidate.id === project.id); + if ( + !finalProject || + !projectWorkspaceConfigurationsEqual( + finalProject.projectWorkspaces, + project.projectWorkspaces, + ) || + sessionSnapshot(resolvedSessionId) !== originalWorkspaceSnapshot + ) { + throw new Error("The project workspace changed during setup. Try again."); + } + useChatSessionStore.getState().patchSession(resolvedSessionId, { + workingDir: plan.workingDir, + workspaceAttachments: plan.workspaceAttachments, + activeWorkspaceId: plan.workspaceAttachments[0]?.id, + ...(prepared.configOptionsSnapshot?.reasoningEffort + ? { reasoningEffort: prepared.configOptionsSnapshot.reasoningEffort } + : {}), + }); + return resolvedSessionId; + } catch (error) { + let rollbackError: unknown; + if (switchedTarget && !originalWorkspaceState.workingDir) { + rollbackError = new Error( + "Berd couldn’t safely return the chat to its original folder.", + ); + } else if (switchedTarget && originalWorkspaceState.workingDir) { + const restored = await transitionSessionTarget({ + sessionId: resolvedSessionId, + target: switchedTarget, + workingDir: originalWorkspaceState.workingDir, + }); + if (!restored.applied) { + rollbackError = new Error( + "Berd couldn’t safely return the chat to its original folder.", + ); + } + } + if (!rollbackError) { + await rollbackProjectChatWorkspacePlan(plan); + const rollbackSession = useChatSessionStore + .getState() + .getSession(resolvedSessionId); + if (rollbackSession) { + useChatSessionStore + .getState() + .patchSession(resolvedSessionId, originalWorkspaceState); + } + } + throw rollbackError ?? error; + } +} + /** Call only after an explicit successful user workspace/configuration edit. */ export function releaseWorkspaceSendAfterUserEdit(sessionId: string): boolean { const record = deferredRecord(sessionId); @@ -277,10 +490,13 @@ export async function createDeferredWorkspaces( }); return; } + const expectedConfigurationRevision = + record.state.configurationRevision ?? + projectWorkspaceConfigurationRevision(record.state.desired); if ( !project || - JSON.stringify(project.projectWorkspaces) !== - JSON.stringify(record.state.desired) + projectWorkspaceConfigurationRevision(project.projectWorkspaces) !== + expectedConfigurationRevision ) { useChatStore.getState().updateDeferredMessage(resolvedSessionId, recordId, { ...record.state, @@ -314,8 +530,8 @@ export async function createDeferredWorkspaces( .projects.find((candidate) => candidate.id === desiredProjectId); if ( !liveProject || - JSON.stringify(liveProject.projectWorkspaces) !== - JSON.stringify(record.state.desired) + projectWorkspaceConfigurationRevision(liveProject.projectWorkspaces) !== + expectedConfigurationRevision ) { await rollbackProjectChatWorkspacePlan(plan); useChatStore @@ -352,10 +568,6 @@ export async function createDeferredWorkspaces( } return; } - useChatSessionStore.getState().patchSession(resolvedSessionId, { - workingDir, - workspaceAttachments: plan?.workspaceAttachments, - }); if (current.creationState === "pending") { const backendSessionId = await waitForBackendSessionId( sessionId, @@ -392,10 +604,6 @@ export async function createDeferredWorkspaces( return; } resolvedSessionId = backendSessionId; - useChatSessionStore.getState().patchSession(resolvedSessionId, { - workingDir, - workspaceAttachments: plan?.workspaceAttachments, - }); } const appliedSnapshot = sessionSnapshot(sessionId, recordId); const preparedSession = useChatSessionStore @@ -426,6 +634,30 @@ export async function createDeferredWorkspaces( !prepared.applied || sessionSnapshot(sessionId, recordId) !== appliedSnapshot ) { + if (prepared.applied) { + const concurrentWorkspaceState = useChatSessionStore + .getState() + .getSession(resolvedSessionId); + const restored = concurrentWorkspaceState?.workingDir + ? await transitionSessionTarget({ + sessionId: resolvedSessionId, + target: preparedSession.executionTarget, + workingDir: concurrentWorkspaceState.workingDir, + }) + : null; + if (!restored?.applied) { + useChatStore + .getState() + .updateDeferredMessage(resolvedSessionId, recordId, { + ...record.state, + status: "failed", + error: + "Berd couldn’t safely return the chat to its original folder.", + }); + return; + } + } + await rollbackProjectChatWorkspacePlan(plan); useChatStore .getState() .updateDeferredMessage(resolvedSessionId, recordId, { @@ -440,9 +672,33 @@ export async function createDeferredWorkspaces( .projects.find((candidate) => candidate.id === finalProjectId); if ( !finalProject || - JSON.stringify(finalProject.projectWorkspaces) !== - JSON.stringify(record.state.desired) + projectWorkspaceConfigurationRevision(finalProject.projectWorkspaces) !== + expectedConfigurationRevision ) { + const restored = originalWorkspaceState?.workingDir + ? await transitionSessionTarget({ + sessionId: resolvedSessionId, + target: preparedSession.executionTarget, + workingDir: originalWorkspaceState.workingDir, + }) + : null; + if (!restored?.applied) { + useChatStore + .getState() + .updateDeferredMessage(resolvedSessionId, recordId, { + ...record.state, + status: "failed", + error: + "Berd couldn’t safely return the chat to its original folder.", + }); + return; + } + await rollbackProjectChatWorkspacePlan(plan); + if (originalWorkspaceState) { + useChatSessionStore + .getState() + .patchSession(resolvedSessionId, originalWorkspaceState); + } useChatStore .getState() .updateDeferredMessage(resolvedSessionId, recordId, { @@ -464,6 +720,9 @@ export async function createDeferredWorkspaces( return; } useChatSessionStore.getState().patchSession(resolvedSessionId, { + workingDir, + workspaceAttachments: plan?.workspaceAttachments, + activeWorkspaceId: plan?.workspaceAttachments[0]?.id, ...(prepared.configOptionsSnapshot?.reasoningEffort ? { reasoningEffort: prepared.configOptionsSnapshot.reasoningEffort } : {}), @@ -567,10 +826,16 @@ export function prepareExistingFirstSend( } const needsName = projectRequiresStartupWorkspaceName(project); - const usesWorktreeChoice = project.projectWorkspaces.some( - (workspace) => workspace.startupMode === "worktree", + const usesWorktreeChoice = project.projectWorkspaces.some((workspace) => + isAskWorktreeStartupMode(workspace.startupMode), ); - if (needsName && !options.onNeedsName) return false; + const manuallyManaged = project.projectWorkspaces.every( + (workspace) => + workspace.startupMode === "none" || + workspace.startupMode === "ask-worktree", + ); + if (manuallyManaged) return true; + if (needsName && !usesWorktreeChoice && !options.onNeedsName) return false; if ( !chat.deferTransportReadyMessage( sessionId, @@ -584,6 +849,9 @@ export function prepareExistingFirstSend( : "creating", projectId: project.id, desired: project.projectWorkspaces, + configurationRevision: projectWorkspaceConfigurationRevision( + project.projectWorkspaces, + ), }, payloadForDeferredWorkspaceSetup(record.payload), ) @@ -670,10 +938,26 @@ export function acceptFirstSend( } const needsName = projectRequiresStartupWorkspaceName(project); - const usesWorktreeChoice = project.projectWorkspaces.some( - (workspace) => workspace.startupMode === "worktree", + const usesWorktreeChoice = project.projectWorkspaces.some((workspace) => + isAskWorktreeStartupMode(workspace.startupMode), ); - if (needsName && options.startupName === undefined && !options.onNeedsName) { + const manuallyManaged = project.projectWorkspaces.every( + (workspace) => + workspace.startupMode === "none" || + workspace.startupMode === "ask-worktree", + ); + if (manuallyManaged) { + return { + accepted: + options.queueReady && isAdmittedQueuedMessagePayload(payload) + ? chat.enqueueTransportReadyMessage(sessionId, payload) + : false, + deferred: false, + needsName: false, + }; + } + const startupName = options.startupName; + if (needsName && startupName === undefined && !options.onNeedsName) { return { accepted: false, deferred: false, @@ -686,13 +970,16 @@ export function acceptFirstSend( { type: "workspace-first-send", status: - needsName && options.startupName === undefined + needsName && startupName === undefined ? usesWorktreeChoice ? "choice" : "naming" : "creating", projectId: project.id, desired: project.projectWorkspaces, + configurationRevision: projectWorkspaceConfigurationRevision( + project.projectWorkspaces, + ), cancelBuilderDraftPath: options.cancelBuilderDraftPath, }, ); @@ -705,7 +992,7 @@ export function acceptFirstSend( }; } - if (needsName && options.startupName === undefined && !usesWorktreeChoice) { + if (needsName && startupName === undefined && !usesWorktreeChoice) { options.onNeedsName?.({ workspaces: project.projectWorkspaces, submit: (name) => @@ -722,11 +1009,11 @@ export function acceptFirstSend( } }, }); - } else if (!(needsName && options.startupName === undefined)) { + } else if (!(needsName && startupName === undefined)) { void createDeferredWorkspaces( sessionId, record.recordId, - options.startupName ?? null, + startupName ?? null, ); } return { accepted: true, deferred: true, needsName: false }; diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index 302ab9760..93854799b 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -798,6 +798,73 @@ describe("session target coordinator", () => { expect(getSessionTargetSelection("selection")).toBeUndefined(); }); + it("settles pending draft work when ownership transfers to an existing backend actor", async () => { + const { transferSessionTargetOwnership, hydrateSessionTarget } = + await import("./sessionTargetCoordinator"); + const wire = deferred(); + mockPrepare.mockReturnValueOnce(wire.promise); + useChatSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0], + id: "draft", + title: "draft", + }, + { + ...state.sessions[0], + id: "backend", + title: "backend", + }, + ], + })); + const pending = transitionSessionTarget({ + sessionId: "draft", + target: target("b"), + workingDir: "/w", + }); + await vi.waitFor(() => expect(mockPrepare).toHaveBeenCalledOnce()); + hydrateSessionTarget("backend", target("a")); + + transferSessionTargetOwnership("draft", "backend"); + + await expect(pending).resolves.toMatchObject({ + status: "superseded", + applied: false, + }); + expect(getSessionTargetState("backend")).toMatchObject({ + status: "settled", + committed: target("a"), + }); + wire.resolve(); + }); + + it("preserves an existing backend actor while transferring draft selection", async () => { + const { + recordSessionTargetSelection, + getSessionTargetSelection, + transferSessionTargetOwnership, + } = await import("./sessionTargetCoordinator"); + recordSessionTargetSelection({ + sessionId: "backend", + operationId: "op-backend", + target: target("a"), + }); + recordSessionTargetSelection({ + sessionId: "draft", + operationId: "op-draft", + target: target("b"), + }); + + transferSessionTargetOwnership("draft", "backend"); + + expect(getSessionTargetSelection("draft")).toBeUndefined(); + expect(getSessionTargetSelection("backend")).toMatchObject({ + operationId: "op-backend", + target: target("a"), + }); + }); + it("transfers pending selection ownership from a draft id", async () => { const { recordSessionTargetSelection, diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 1bd1a160d..4720276b2 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -1207,6 +1207,25 @@ export function transferSessionTargetOwnership( const source = actors.get(fromSessionId); if (!source) return; actors.delete(fromSessionId); + const destination = actors.get(toSessionId); + if (destination) { + destination.selection ??= source.selection; + destination.deferredSelection ??= source.deferredSelection; + destination.deferredTargetMutation ??= source.deferredTargetMutation; + source.cancelled = true; + const pending = new Set( + [source.current, source.latest].filter( + (operation): operation is PendingOperation => operation !== undefined, + ), + ); + for (const operation of pending) { + settleOperation(operation, { status: "superseded", applied: false }); + } + source.current = undefined; + source.latest = undefined; + source.dispatch?.release(); + return; + } source.tracksLiveSession = true; actors.set(toSessionId, source); } diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index f14b3622f..94f152e98 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -11,6 +11,7 @@ import { import { Pencil, X } from "lucide-react"; import { IconCheck, IconCornerDownLeft } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { attachmentSnapshotsMatch, skillDraftSnapshotsMatch, @@ -478,16 +479,35 @@ export function ChatInput({ }; }, [scheduleResizeTextarea, surface]); - const visibleQueuedMessages = ( + const shouldReduceMotion = useReducedMotion(); + const queuedMessageContentRef = useRef(null); + const [queuedMessageContentHeight, setQueuedMessageContentHeight] = + useState(); + const allQueuedMessages = queuedMessages ?? - (queuedMessage ? [{ recordId: "legacy", payload: queuedMessage }] : []) - ).filter(({ payload }) => payload.showInComposer !== false); + (queuedMessage ? [{ recordId: "legacy", payload: queuedMessage }] : []); + const visibleQueuedMessages = allQueuedMessages.filter( + ({ payload }) => payload.showInComposer !== false, + ); // A record being edited lives in the composer, so its pill is hidden to // avoid showing the same message both queued and in the composer. Queue // positions (head-only actions) still come from the unfiltered list. const queuedMessagePills = visibleQueuedMessages .map((entry, index) => ({ ...entry, index })) .filter(({ recordId }) => recordId !== editingQueuedRecordId); + useLayoutEffect(() => { + const content = queuedMessageContentRef.current; + if (!content) { + setQueuedMessageContentHeight(undefined); + return; + } + const updateHeight = () => + setQueuedMessageContentHeight(content.scrollHeight); + updateHeight(); + const observer = new ResizeObserver(updateHeight); + observer.observe(content); + return () => observer.disconnect(); + }); const hasDraftContext = (scopedControls.attachments && attachments.length > 0) || visibleSelectedSkills.length > 0; @@ -504,6 +524,30 @@ export function ChatInput({ canSteerMessage && visibleQueuedMessages.length === 0 && Boolean(onSteerMessage); + // Steering acts on the true queue head, so it is only offered when that + // head is also the message the user can see. In practice hidden records + // (reliable startup handoffs) cannot coexist with an active run today; + // this is a tripwire so a future longer-lived hidden record makes + // steering go inert instead of steering something off-screen. + const queuedHeadIsVisible = + allQueuedMessages.length > 0 && + allQueuedMessages[0].payload.showInComposer !== false; + // With an empty composer, the send shortcut steers the first queued + // message instead of no-oping — the double-enter flow (enter queues, + // enter again steers). Draft content keeps the shortcut on the draft so + // it can never discard or bypass what the user is composing, and an + // in-progress queue edit keeps the shortcut inert because the edited + // message lives in the composer, not the queue. + const canSteerQueuedMessageWithShortcut = + !hasDraftContent && + !attachmentWorkPending && + !disabled && + !sendDisabled && + isStreaming && + canSteerQueuedMessage && + editingQueuedRecordId === null && + queuedHeadIsVisible && + Boolean(onSteerQueuedMessage); const effectivePersonaId = editingQueuedPersona ? editingQueuedPersona.kind === "persona" @@ -1148,6 +1192,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); return; @@ -1183,6 +1231,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); @@ -1553,11 +1605,20 @@ export function ChatInput({ )} > + {queuedMessageAccessory ? ( +
+ {queuedMessageAccessory} +
+ ) : null} + {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments */}
{queuedMessagePills.length > 0 && ( -
- {queuedMessagePills.map(({ recordId, payload, index }) => ( +
+ 1 + ? "rounded-xs" + : "rounded-full", + )} + >
- - {payload.text} - - {index === 0 && isStreaming && canSteerQueuedMessage ? ( - - ) : null} - {index === 0 && onSendQueue ? ( - - ) : null} - {( - recordId === "legacy" - ? Boolean(onDismissQueue) - : Boolean(onUpdateQueue) - ) ? ( - - ) : null} - {onDismissQueue ? ( - - ) : null} + + {queuedMessagePills.map( + ({ recordId, payload, index }) => ( + + + {payload.text} + + {index === 0 && + isStreaming && + canSteerQueuedMessage && + queuedHeadIsVisible ? ( + + ) : null} + {index === 0 && onSendQueue ? ( + + ) : null} + {( + recordId === "legacy" + ? Boolean(onDismissQueue) + : Boolean(onUpdateQueue) + ) ? ( + + ) : null} + {onDismissQueue ? ( + + ) : null} + + ), + )} +
- ))} -
- )} - - {visibleQueuedMessages.length > 0 && queuedMessageAccessory ? ( -
- {queuedMessageAccessory} +
- ) : ( - queuedMessageAccessory )}
) : !isReadOnly && deferredWorkspaceStartup.worktreeCount > 0 && - (controller.deferredWorkspaceRecord?.state.status === "choice" || - controller.deferredWorkspaceRecord?.state.status === "naming" || - controller.deferredWorkspaceRecord?.state.status === - "creating") ? ( + (workspaceSetup?.status === "choice" || + workspaceSetup?.status === "naming" || + workspaceSetup?.status === "creating") ? ( false; +} +if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = () => {}; +} + describe("WorkspaceSetupChoice", () => { it("matches Cynthia's collapsed prompt interaction", async () => { const user = userEvent.setup(); @@ -77,17 +84,6 @@ describe("WorkspaceSetupChoice", () => { render( { ); const input = screen.getByRole("textbox", { name: "Worktree name" }); - expect( - screen.getByRole("combobox", { name: "Project folder" }), - ).toHaveValue("/Berd-internal"); - expect( - screen.getByRole("option", { name: "Berd-internal" }), - ).toBeInTheDocument(); - expect(input.parentElement?.parentElement).toHaveClass( - "grid-cols-1", - "sm:grid-cols-[minmax(8rem,0.34fr)_minmax(0,1fr)]", - ); expect(input).toHaveAttribute("placeholder", "Enter worktree name"); + expect(input).toHaveClass( + "text-base", + "md:text-sm", + "border-transparent", + "shadow-none", + "placeholder:text-muted-foreground/60", + "dark:bg-muted/55", + "dark:placeholder:text-muted-foreground/50", + "dark:focus-visible:border-ring/50", + ); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onCancelName).toHaveBeenCalledOnce(); await user.type(input, "feature/name"); @@ -117,6 +113,28 @@ describe("WorkspaceSetupChoice", () => { expect(onSubmitName).toHaveBeenCalledWith("feature-name"); }); + it("aligns an error to the left of the naming actions", () => { + render( + , + ); + + const error = screen.getByRole("alert"); + expect(error).toHaveClass("flex-1", "truncate"); + expect(error).toHaveAttribute("title", "Choose a Git repository."); + const actionRow = error.parentElement; + expect(actionRow).toHaveClass("items-center", "justify-between"); + expect(actionRow?.lastElementChild).toContainElement( + screen.getByRole("button", { name: "Save" }), + ); + }); + it("reuses the piggyback surface while preparing", () => { render( void; onCreate: () => void; onSubmitName: (name: string) => void; @@ -26,35 +24,29 @@ export function WorkspaceSetupChoice({ worktreeCount = 1, branchCount = 0, exactCounts = true, - workspaces = [], + error, onCancelName, onCreate, onSubmitName, onSkip, }: WorkspaceSetupChoiceProps) { const { t } = useTranslation("chat"); + const shouldReduceMotion = useReducedMotion(); const [name, setName] = useState(""); + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(); + useLayoutEffect(() => { + const content = contentRef.current; + if (!content) return; + const updateHeight = () => setContentHeight(content.scrollHeight); + updateHeight(); + const observer = new ResizeObserver(updateHeight); + observer.observe(content); + return () => observer.disconnect(); + }, []); const trimmedName = name.trim(); const invalidName = trimmedName === "." || trimmedName === ".." || /[/\\]/.test(trimmedName); - const projectFolders = useMemo( - () => - Array.from( - new Map( - workspaces - .filter((workspace) => workspace.startupMode === "worktree") - .map((workspace) => [ - workspace.repositoryPath ?? - workspace.worktreePath ?? - workspace.path, - getWorkspaceTitle(workspace), - ]), - ).entries(), - ), - [workspaces], - ); - const selectedProjectFolder = projectFolders[0]?.[0] || ""; - const choiceLabel = !exactCounts ? t("queue.configureWorkspaces") : worktreeCount === 1 && branchCount === 0 @@ -71,142 +63,150 @@ export function WorkspaceSetupChoice({ return ( - - {state === "choice" ? ( - -
- - {choiceLabel} - -
- - + + {state === "choice" ? ( + +
+ + {choiceLabel} + +
+ + + +
+
+
+ ) : state === "naming" ? ( + { + event.preventDefault(); + if (trimmedName && !invalidName) onSubmitName(trimmedName); + }} + > +
+
-
- - ) : state === "naming" ? ( - { - event.preventDefault(); - if (trimmedName && !invalidName) onSubmitName(trimmedName); - }} - > -
- - -
-
- - -
-
- ) : ( - -
- - {t("queue.preparingWorkspaceTitle")} - {" "} - - {t("queue.preparingWorkspaceBody")} - -
-
- )} - + {error} +

+ ) : ( +
+ + ) : ( + +
+ + {t("queue.preparingWorkspaceTitle")} + {" "} + + {t("queue.preparingWorkspaceBody")} + +
+
+ )} +
+
); } diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx index a347497b0..e7003a4a0 100644 --- a/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -318,7 +318,7 @@ describe("ChatInput", () => { ).not.toBeInTheDocument(); }); - it("keeps the queue pill outside and above the composer piggyback", () => { + it("keeps the composer piggyback at the top with queued messages below it", () => { render( { const queue = screen.getByText("queued follow up"); const accessory = screen.getByText("Configure a new worktree?"); - expect(queue.parentElement).toHaveClass( + expect( + queue.parentElement?.parentElement?.parentElement?.parentElement, + ).toHaveClass("-mx-1"); + expect(queue.parentElement).toHaveClass("flex", "items-center", "gap-2"); + expect(queue.parentElement?.parentElement).toHaveClass( "flex", - "items-center", - "gap-2", + "flex-col", + "gap-1.5", + "p-1.5", + ); + expect(queue.parentElement?.parentElement?.parentElement).toHaveClass( "rounded-full", "bg-surface-chat-responding-pill-bg", - "px-3", - "py-1.5", "text-surface-chat-responding-pill-fg", "shadow-[var(--shadow-chat)]", ); + expect(queue).toHaveClass("pl-1.5", "text-sm"); expect(accessory.parentElement).toHaveClass( + "relative", + "z-0", + "-mb-2", "rounded-t-sm", "bg-surface-composer-action", - "-mx-4", + "pb-2", ); expect(accessory.parentElement).toHaveAttribute( "data-slot", @@ -356,17 +365,18 @@ describe("ChatInput", () => { .getByTestId("chat-composer") .closest(".chat-composer-shell"); expect(composerShell).toHaveClass( + "z-10", + "rounded-sm", "bg-surface-chat-composer", "[backdrop-filter:var(--backdrop-composer-glass)]", ); expect(composerShell?.parentElement).not.toHaveClass( "bg-surface-chat-composer", ); - expect(queue.parentElement?.parentElement).not.toBe( - accessory.parentElement?.parentElement, - ); + expect(accessory.parentElement?.nextElementSibling).toBe(composerShell); + expect(composerShell?.contains(queue.parentElement)).toBe(true); expect( - queue.compareDocumentPosition(accessory) & + accessory.compareDocumentPosition(queue) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); @@ -2494,12 +2504,14 @@ describe("ChatInput", () => { // Editing the head hides its pill; the tail pill must not inherit // head-only actions like steering. - const pillTexts = Array.from( - document.querySelectorAll('[data-slot="queued-message"]'), - ).map((pill) => pill.textContent); - expect(pillTexts).toHaveLength(1); - expect(pillTexts[0]).toContain("second"); - expect(pillTexts[0]).not.toContain("first"); + await waitFor(() => { + const pillTexts = Array.from( + document.querySelectorAll('[data-slot="queued-message"]'), + ).map((pill) => pill.textContent); + expect(pillTexts).toHaveLength(1); + expect(pillTexts[0]).toContain("second"); + expect(pillTexts[0]).not.toContain("first"); + }); expect(screen.queryByTitle("Steer queued message")).not.toBeInTheDocument(); }); @@ -2528,8 +2540,21 @@ describe("ChatInput", () => { />, ); - expect(screen.getByText("first")).toBeInTheDocument(); - expect(screen.getByText("second")).toBeInTheDocument(); + const firstQueuedMessage = screen.getByText("first"); + const secondQueuedMessage = screen.getByText("second"); + expect(firstQueuedMessage).toBeInTheDocument(); + expect(secondQueuedMessage).toBeInTheDocument(); + const queuedMessageGroup = + firstQueuedMessage.parentElement?.parentElement?.parentElement; + expect(queuedMessageGroup).toBe( + secondQueuedMessage.parentElement?.parentElement?.parentElement, + ); + expect(queuedMessageGroup).toHaveAttribute( + "data-slot", + "queued-message-group", + ); + expect(queuedMessageGroup).toHaveClass("rounded-xs"); + expect(queuedMessageGroup).not.toHaveClass("rounded-full"); expect(screen.queryByText("1. first")).not.toBeInTheDocument(); expect(screen.queryByText("2. second")).not.toBeInTheDocument(); diff --git a/src/features/chat/ui/widgets/WorkspaceIdentity.tsx b/src/features/chat/ui/widgets/WorkspaceIdentity.tsx index 51db99b44..47b769410 100644 --- a/src/features/chat/ui/widgets/WorkspaceIdentity.tsx +++ b/src/features/chat/ui/widgets/WorkspaceIdentity.tsx @@ -9,6 +9,7 @@ import { import type { WorkspaceAttachment } from "@/shared/types/chat"; import type { GitState } from "@/shared/types/git"; import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { isPathWithin, isSamePath, @@ -228,11 +229,13 @@ interface WorkspaceIdentityProps { gitState: GitState | undefined; gitContext?: WorkspaceGitContext; showMetadata?: boolean; + iconKind?: WorkspaceIdentityIconKind; className?: string; iconClassName?: string; titleClassName?: string; metadataClassName?: string; showHoverChevron?: boolean; + iconTooltip?: string; } export function WorkspaceIdentity({ @@ -240,15 +243,17 @@ export function WorkspaceIdentity({ gitState, gitContext, showMetadata = true, + iconKind: iconKindOverride, className, iconClassName, titleClassName, metadataClassName, showHoverChevron = true, + iconTooltip, }: WorkspaceIdentityProps) { const { t } = useTranslation("chat"); const context = gitContext ?? getWorkspaceGitContext(workspace, gitState); - const iconKind = getWorkspaceIdentityIconKind(context); + const iconKind = iconKindOverride ?? getWorkspaceIdentityIconKind(context); const metadataItems = getWorkspaceIdentityMetadataItems(context, { mainCheckout: t("contextPanel.includedWorkspaces.mainCheckout"), worktree: t("contextPanel.includedWorkspaces.worktree"), @@ -256,25 +261,30 @@ export function WorkspaceIdentity({ return (
- - - {showHoverChevron ? ( - + + + + + {showHoverChevron ? ( + + + {iconTooltip ? {iconTooltip} : null} +
{ expect(createRequest.properties.projectWorkspaces).toEqual([ expect.objectContaining({ path: "/tmp/launch/packages/app", - startupMode: "worktree", + startupMode: "auto-worktree", }), ]); expect(project.workingDirs).toEqual(["/tmp/launch/packages/app"]); expect(project.projectWorkspaces).toEqual([ expect.objectContaining({ path: "/tmp/launch/packages/app", - startupMode: "worktree", + startupMode: "auto-worktree", }), ]); }); + it("migrates legacy branch startup to manually managed worktrees", async () => { + const { normalizeProjectWorkspaces } = await import("./projects"); + + expect( + normalizeProjectWorkspaces([ + { + id: "legacy-branch", + path: "/tmp/legacy", + kind: "repository", + source: "selected", + branch: "main", + usedByAgent: false, + startupMode: "branch", + }, + ]), + ).toEqual([expect.objectContaining({ startupMode: "ask-worktree" })]); + }); + + it("round-trips the new worktree startup policies", async () => { + const { normalizeProjectWorkspaces } = await import("./projects"); + + expect( + normalizeProjectWorkspaces([ + { + id: "auto", + path: "/tmp/auto", + kind: "repository", + source: "selected", + branch: "main", + usedByAgent: false, + startupMode: "auto-worktree", + }, + { + id: "ask", + path: "/tmp/ask", + kind: "repository", + source: "selected", + branch: "main", + usedByAgent: false, + startupMode: "ask-worktree", + }, + ]), + ).toEqual([ + expect.objectContaining({ startupMode: "auto-worktree" }), + expect.objectContaining({ startupMode: "ask-worktree" }), + ]); + }); + it("recomputes existing artifact identity when renaming a project", async () => { const existingArtifact = artifactMetadata(); mocks.sourcesUpdate.mockImplementation(async (request) => ({ diff --git a/src/features/projects/api/projects.ts b/src/features/projects/api/projects.ts index 801c1170c..34c87ecf3 100644 --- a/src/features/projects/api/projects.ts +++ b/src/features/projects/api/projects.ts @@ -16,7 +16,30 @@ import { import { toIdentityKey } from "@/shared/lib/pathIdentity"; import type { ProjectArtifactMetadata } from "../artifact/types"; -export type ProjectWorkspaceStartupMode = "none" | "branch" | "worktree"; +export type ProjectWorkspaceStartupMode = + | "none" + | "branch" + | "worktree" + | "ask-worktree" + | "auto-worktree"; + +export function isWorktreeStartupMode( + mode: ProjectWorkspaceStartupMode, +): boolean { + return mode === "worktree" || mode === "auto-worktree"; +} + +export function isAskWorktreeStartupMode( + mode: ProjectWorkspaceStartupMode, +): boolean { + return mode === "worktree" || mode === "auto-worktree"; +} + +export function requiresWorkspaceStartup( + mode: ProjectWorkspaceStartupMode, +): boolean { + return mode === "branch" || isWorktreeStartupMode(mode); +} export interface ProjectWorkspace extends WorkspaceAttachment { startupMode: ProjectWorkspaceStartupMode; @@ -64,7 +87,10 @@ function createArtifactMetadata( } function validStartupMode(value: unknown): ProjectWorkspaceStartupMode { - return value === "branch" || value === "worktree" ? value : "none"; + if (value === "worktree") return "auto-worktree"; + if (value === "branch") return "ask-worktree"; + if (value === "ask-worktree" || value === "auto-worktree") return value; + return "none"; } function validWorkspaceKind(value: unknown): WorkspaceAttachmentKind { diff --git a/src/features/projects/lib/projectChatWorkspaces.test.ts b/src/features/projects/lib/projectChatWorkspaces.test.ts index cb796c29d..8ebf8d052 100644 --- a/src/features/projects/lib/projectChatWorkspaces.test.ts +++ b/src/features/projects/lib/projectChatWorkspaces.test.ts @@ -635,6 +635,26 @@ describe("project chat workspaces", () => { expect(gitMocks.createBranch).not.toHaveBeenCalled(); }); + it("turns raw git worktree failures into plain English", async () => { + gitMocks.createWorktree.mockRejectedValueOnce( + new Error( + "git worktree add -b test test /Users/test/repo-worktrees/test failed: fatal: not a valid branch name", + ), + ); + + await expect( + planProjectChatWorkspaces( + project({ + projectWorkspaces: [workspace("/repo/builderbot", "worktree")], + workingDirs: ["/repo/builderbot"], + }), + "test test", + ), + ).rejects.toThrow( + "That name can’t be used for a worktree. Use letters, numbers, hyphens, or underscores.", + ); + }); + it("rejects configured startup for non-git workspaces before mutating git", async () => { gitMocks.getGitState.mockResolvedValue({ isGitRepo: false, @@ -780,7 +800,9 @@ describe("project chat workspaces", () => { }), "chat-123", ), - ).rejects.toThrow("git lock"); + ).rejects.toThrow( + "Berd couldn’t prepare the project workspace. Try again.", + ); expect(gitMocks.deleteBranch).toHaveBeenCalledWith( "/repo", @@ -831,7 +853,9 @@ describe("project chat workspaces", () => { }), "chat-123", ), - ).rejects.toThrow("worktree locked"); + ).rejects.toThrow( + "Berd couldn’t prepare the project workspace. Try again.", + ); expect(gitMocks.removeWorktree).toHaveBeenCalledWith( "/repo", diff --git a/src/features/projects/lib/projectChatWorkspaces.ts b/src/features/projects/lib/projectChatWorkspaces.ts index 1dc092256..80ac9d500 100644 --- a/src/features/projects/lib/projectChatWorkspaces.ts +++ b/src/features/projects/lib/projectChatWorkspaces.ts @@ -16,10 +16,12 @@ import { normalizeComparableWorkspacePath, workspaceAttachmentIdForPath, } from "@/features/chat/lib/workspaceAttachments"; -import type { - ProjectInfo, - ProjectWorkspace, - ProjectWorkspaceStartupMode, +import { + isWorktreeStartupMode, + requiresWorkspaceStartup, + type ProjectInfo, + type ProjectWorkspace, + type ProjectWorkspaceStartupMode, } from "@/features/projects/api/projects"; export interface ProjectWorkspaceStartupSummary { @@ -37,15 +39,14 @@ export function summarizeProjectWorkspaceStartup( let exact = true; for (const workspace of workspaces) { - if (workspace.startupMode === "none") continue; + if (!requiresWorkspaceStartup(workspace.startupMode)) continue; exact &&= Boolean(workspace.repositoryPath || workspace.worktreePath); const repositoryKey = normalizedKey( workspace.repositoryPath ?? workspace.worktreePath ?? workspace.path, ); - const repositories = - workspace.startupMode === "worktree" - ? worktreeRepositories - : branchRepositories; + const repositories = isWorktreeStartupMode(workspace.startupMode) + ? worktreeRepositories + : branchRepositories; repositories.add(repositoryKey); } @@ -115,6 +116,47 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function friendlyWorkspaceSetupError(error: unknown): Error { + const message = errorMessage(error); + if ( + /choose a different name/i.test(message) || + /requires a Git repository/i.test(message) || + /name is required/i.test(message) + ) { + return new Error(message); + } + if (/already exists/i.test(message) && /branch/i.test(message)) { + return new Error( + "That worktree name is already in use. Choose another name.", + ); + } + if ( + /not a valid branch name|invalid branch name|cannot lock ref/i.test(message) + ) { + return new Error( + "That name can’t be used for a worktree. Use letters, numbers, hyphens, or underscores.", + ); + } + if (/not inside one|not a git repository/i.test(message)) { + return new Error("Choose a project folder that contains a Git repository."); + } + if (/timed out/i.test(message)) { + return new Error("Creating the worktree took too long. Try again."); + } + if (/permission denied|operation not permitted/i.test(message)) { + return new Error( + "Berd doesn’t have permission to create a worktree there.", + ); + } + if ( + /git\s+worktree\s+add/i.test(message) || + /failed to create worktree/i.test(message) + ) { + return new Error("Berd couldn’t create that worktree. Try another name."); + } + return new Error("Berd couldn’t prepare the project workspace. Try again."); +} + async function rollbackStartupMutations( actions: StartupRollbackAction[], ): Promise { @@ -242,7 +284,8 @@ function gitOperationPathCandidates( ]; const worktreeCandidates = [classifiedWorktreePath, workspace.worktreePath]; const candidates = - workspace.startupMode === "branch" || workspace.startupMode === "worktree" + workspace.startupMode === "branch" || + isWorktreeStartupMode(workspace.startupMode) ? [...worktreeCandidates, workspace.path, ...repositoryCandidates] : [...repositoryCandidates, ...worktreeCandidates, workspace.path]; return candidates.filter((path): path is string => Boolean(path)); @@ -384,8 +427,8 @@ function attachmentForCreatedBranch( export function projectRequiresStartupWorkspaceName( project: Pick, ): boolean { - return (project.projectWorkspaces ?? []).some( - (workspace) => workspace.startupMode !== "none", + return (project.projectWorkspaces ?? []).some((workspace) => + requiresWorkspaceStartup(workspace.startupMode), ); } @@ -417,7 +460,9 @@ function validateStartupName( throw new Error("A branch or worktree name is required."); } - if (workspaces.some((workspace) => workspace.startupMode === "worktree")) { + if ( + workspaces.some((workspace) => isWorktreeStartupMode(workspace.startupMode)) + ) { if (startupName === "." || startupName === "..") { throw new Error( "Worktree startup names must be real folder names. Choose a different name.", @@ -513,8 +558,8 @@ export async function planProjectChatWorkspaces( const rollbackActions: StartupRollbackAction[] = []; const attachments: WorkspaceAttachment[] = []; const trimmedStartupName = startupName?.trim() ?? ""; - const startupWorkspaces = workspaces.filter( - (workspace) => workspace.startupMode !== "none", + const startupWorkspaces = workspaces.filter((workspace) => + requiresWorkspaceStartup(workspace.startupMode), ); const gitStateForPath = async (path: string): Promise => { const pathKey = normalizedKey(path); @@ -582,7 +627,7 @@ export async function planProjectChatWorkspaces( } startupModeByRepo.set(repoKey, workspace.startupMode); - if (workspace.startupMode === "worktree") { + if (isWorktreeStartupMode(workspace.startupMode)) { const targetWorktreePath = deriveStartupWorktreePath( gitContext, trimmedStartupName, @@ -612,7 +657,7 @@ export async function planProjectChatWorkspaces( try { for (const workspace of workspaces) { - if (workspace.startupMode === "none") { + if (!requiresWorkspaceStartup(workspace.startupMode)) { attachments.push(projectWorkspaceToAttachment(workspace)); continue; } @@ -629,7 +674,7 @@ export async function planProjectChatWorkspaces( const repoKey = repositoryKeyForGitContext(gitContext); const repositoryPath = repositoryPathForGitContext(gitContext); const baseBranch = defaultBaseBranch(gitState); - if (workspace.startupMode === "worktree") { + if (isWorktreeStartupMode(workspace.startupMode)) { let createdWorktree = createdWorktreeByRepo.get(repoKey); if (!createdWorktree) { const newWorktree = await createWorktree( @@ -701,7 +746,7 @@ export async function planProjectChatWorkspaces( `${errorMessage(error)} Rollback also failed: ${rollbackErrors.join("; ")}`, ); } - throw error; + throw friendlyWorkspaceSetupError(error); } return { diff --git a/src/features/projects/ui/CreateProjectDialog.tsx b/src/features/projects/ui/CreateProjectDialog.tsx index a26b8991c..ac1c0c374 100644 --- a/src/features/projects/ui/CreateProjectDialog.tsx +++ b/src/features/projects/ui/CreateProjectDialog.tsx @@ -38,6 +38,7 @@ import { type ProjectInfo, type ProjectWorkspace, type ProjectWorkspaceStartupMode, + isWorktreeStartupMode, } from "../api/projects"; import { classifyWorkspaceAttachment, @@ -80,46 +81,36 @@ function getDefaultProjectName(path: string | null | undefined): string { return parts[parts.length - 1] ?? ""; } +function configuredWorktreeStartupMode( + mode: ProjectWorkspaceStartupMode, +): "ask-worktree" | "auto-worktree" | null { + if (mode === "worktree" || mode === "auto-worktree") { + return "auto-worktree"; + } + return mode === "ask-worktree" ? mode : null; +} + function projectWorkspaceStartupModeLabel( mode: ProjectWorkspaceStartupMode, t: ReturnType>["t"], ) { switch (mode) { case "worktree": - return t("dialog.workspacePolicy.createWorktree"); + case "ask-worktree": + return t("dialog.workspacePolicy.askWorktree"); + case "auto-worktree": + return t("dialog.workspacePolicy.autoWorktree"); case "branch": return t("dialog.workspacePolicy.createBranch"); case "none": - return t("dialog.workspacePolicy.neither"); + return t("dialog.workspacePolicy.noWorktree"); } } function defaultStartupModeForCandidate( - candidate: WorkspaceAddCandidate, - projectWorkspaces: ProjectWorkspace[] = [], + _candidate: WorkspaceAddCandidate, + _projectWorkspaces: ProjectWorkspace[] = [], ): ProjectWorkspaceStartupMode { - const matchingProjectMode = startupModeFromProjectWorkspaceContext( - candidate, - projectWorkspaces, - ); - if (matchingProjectMode) { - return matchingProjectMode; - } - - const { kind, repositoryPath, worktreePath } = candidate.classification; - if ( - !repositoryPath || - (worktreePath && !isSameWorkspacePath(repositoryPath, worktreePath)) - ) { - return "none"; - } - if ( - kind === "repository" || - kind === "git-main-worktree" || - kind === "subdirectory" - ) { - return "worktree"; - } return "none"; } @@ -225,19 +216,6 @@ function workspaceStartupModeAppliesToCandidate( ); } -function startupModeFromProjectWorkspaceContext( - candidate: WorkspaceAddCandidate, - projectWorkspaces: ProjectWorkspace[], -): ProjectWorkspaceStartupMode | null { - for (const workspace of projectWorkspaces) { - if (workspaceStartupModeAppliesToCandidate(candidate, workspace)) { - return workspace.startupMode; - } - } - - return null; -} - function nonNoneStartupModeFromProjectWorkspaceContext( candidate: WorkspaceAddCandidate, projectWorkspaces: ProjectWorkspace[], @@ -292,14 +270,6 @@ function hasGitWorkspaceKind(workspace: Pick) { ); } -function canConfigureGitStartup(workspace: ProjectWorkspace): boolean { - return Boolean( - workspace.repositoryPath || - workspace.worktreePath || - hasGitWorkspaceKind(workspace), - ); -} - function workspaceUsesLinkedWorktree(workspace: ProjectWorkspace): boolean { if ( workspace.kind === "git-linked-worktree" || @@ -315,28 +285,19 @@ function workspaceUsesLinkedWorktree(workspace: ProjectWorkspace): boolean { ); } -function startupModeOptionsForWorkspace( - workspace: ProjectWorkspace, -): ProjectWorkspaceStartupMode[] { - if (!canConfigureGitStartup(workspace)) { - return ["none"]; - } - - return ["none", "worktree", "branch"]; +function startupModeOptionsForWorkspace(): ProjectWorkspaceStartupMode[] { + return ["ask-worktree", "auto-worktree"]; } function startupModeAllowedForWorkspace( workspace: ProjectWorkspace, startupMode: ProjectWorkspaceStartupMode, ): boolean { - return startupModeOptionsForWorkspace(workspace).includes(startupMode); -} - -function sanitizeStartupMode(workspace: ProjectWorkspace): ProjectWorkspace { - if (startupModeAllowedForWorkspace(workspace, workspace.startupMode)) { - return workspace; - } - return { ...workspace, startupMode: "none" }; + return ( + startupMode === "none" || + hasGitWorkspaceKind(workspace) || + Boolean(workspace.repositoryPath) + ); } interface CreateProjectDialogProps { @@ -378,6 +339,8 @@ export function CreateProjectDialog({ const [projectWorkspaceGitStates, setProjectWorkspaceGitStates] = useState< Record >({}); + const [confirmedNonGitWorkspaceIds, setConfirmedNonGitWorkspaceIds] = + useState>(() => new Set()); const { icon, iconCandidates, @@ -446,9 +409,16 @@ export function CreateProjectDialog({ const results = await Promise.all( missingWorkspaces.map(async (workspace) => { try { + const resolved = await resolvePath({ parts: [workspace.path] }); + const gitState = await getGitState(resolved.path); return { key: normalizeComparableWorkspacePath(workspace.path), - gitState: await getGitState(workspace.path), + workspaceId: workspace.id, + classification: classifyWorkspaceAttachment( + resolved.path, + gitState, + ), + gitState, }; } catch { return null; @@ -457,9 +427,13 @@ export function CreateProjectDialog({ ); const nextGitStates: Record = {}; + const nextConfirmedNonGitIds = new Set(); for (const result of results) { if (result) { nextGitStates[result.key] = result.gitState; + if (!result.gitState.isGitRepo) { + nextConfirmedNonGitIds.add(result.workspaceId); + } } } if (Object.keys(nextGitStates).length === 0) { @@ -470,6 +444,25 @@ export function CreateProjectDialog({ ...current, ...nextGitStates, })); + setConfirmedNonGitWorkspaceIds((current) => { + const next = new Set(current); + for (const workspaceId of nextConfirmedNonGitIds) next.add(workspaceId); + return next; + }); + setProjectWorkspaces((current) => + current.map((workspace) => { + const result = results.find( + (candidate) => candidate?.workspaceId === workspace.id, + ); + if (!result) return workspace; + return { + ...workspace, + ...result.classification, + path: workspace.path, + startupMode: workspace.startupMode, + }; + }), + ); }, [projectWorkspaceGitStates, projectWorkspaces]); useEffect(() => { @@ -564,6 +557,7 @@ export function CreateProjectDialog({ setProjectWorkspaces([]); setWorkingDir(""); setProjectWorkspaceGitStates({}); + setConfirmedNonGitWorkspaceIds(new Set()); setPendingWorktreePolicyWorkspaceId(null); resetIcon(DEFAULT_PROJECT_ICON); setColor(DEFAULT_PROJECT_COLOR); @@ -614,8 +608,12 @@ export function CreateProjectDialog({ } } } - const sanitizedProjectWorkspaces = - savedProjectWorkspaces.map(sanitizeStartupMode); + const sanitizedProjectWorkspaces = savedProjectWorkspaces.map( + (workspace) => + confirmedNonGitWorkspaceIds.has(workspace.id) + ? { ...workspace, startupMode: "none" as const } + : workspace, + ); const savedWorkingDirs = sanitizedProjectWorkspaces.map( (workspace) => workspace.path, ); @@ -724,11 +722,7 @@ export function CreateProjectDialog({ return { ...workspace, startupMode }; } - if ( - !targetSyncKey || - startupMode === "none" || - workspace.startupMode === "none" - ) { + if (!targetSyncKey || startupMode === "none") { return workspace; } @@ -844,8 +838,8 @@ export function CreateProjectDialog({ } if ( - startupMode === "worktree" && - workspace.startupMode !== "worktree" && + isWorktreeStartupMode(startupMode) && + !isWorktreeStartupMode(workspace.startupMode) && workspaceUsesLinkedWorktree(workspace) ) { setPendingWorktreePolicyWorkspaceId(workspace.id); @@ -1116,14 +1110,17 @@ export function CreateProjectDialog({ {enrichedProjectWorkspaces.length > 0 ? (
{enrichedProjectWorkspaces.map((workspace, index) => { - const gitConfigurable = - canConfigureGitStartup(workspace); const startupModeOptions = - startupModeOptionsForWorkspace(workspace); + startupModeOptionsForWorkspace(); const workspaceGitState = projectWorkspaceGitStates[ normalizeComparableWorkspacePath(workspace.path) ]; + const canConfigureWorktrees = + hasGitWorkspaceKind(workspace) || + Boolean(workspace.repositoryPath) || + (!confirmedNonGitWorkspaceIds.has(workspace.id) && + workspace.startupMode !== "none"); return (
-
+
- {gitConfigurable ? ( -
- - {t("dialog.workspacePolicy.sectionLabel")} - + {canConfigureWorktrees ? ( +