diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..e25bf2858a3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6739,6 +6739,75 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("skips closing terminals on archive while a sibling thread shares the worktree", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-shared-worktree"); + const siblingThreadId = ThreadId.make("thread-archive-shared-sibling"); + const worktreePath = "/tmp/worktrees/shared"; + const effects: string[] = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + effects.push(`dispatch:${command.type}`); + return { sequence: 1 }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + worktreePath, + }), + ), + ), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [], + threads: [ + makeDefaultOrchestrationThreadShell({ + id: siblingThreadId, + updatedAt: now, + worktreePath, + }), + ], + updatedAt: now, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-shared-worktree"), + threadId, + }), + ), + ); + + // Terminals are worktree-scoped on the client: the sibling still uses + // the checkout, so its terminals (e.g. a running dev server) must + // survive the archive. + assert.deepEqual(effects, ["dispatch:thread.archive"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "bootstraps first-send worktree turns on the server before dispatching turn start", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..8732941150b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -30,6 +30,7 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, + type OrchestrationThreadShell, type OrchestrationShellStreamItem, type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, @@ -1100,6 +1101,13 @@ const makeWsRpcLayer = ( }; }); + // Mirrors the client's worktree identity rule (worktreeCleanup.ts): + // blank and null both mean "the project's local checkout". + const normalizeWorktreePathForArchive = (worktreePath: string | null): string | null => { + const trimmed = worktreePath?.trim(); + return trimmed !== undefined && trimmed.length > 0 ? trimmed : null; + }; + const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster .refreshStatus(cwd) @@ -1111,21 +1119,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - const shouldStopSessionAfterArchive = + const threadShellBeforeArchive = normalizedCommand.type === "thread.archive" ? yield* projectionSnapshotQuery .getThreadShellById(normalizedCommand.threadId) - .pipe( - Effect.map( - Option.match({ - onNone: () => false, - onSome: (thread) => - thread.session !== null && thread.session.status !== "stopped", - }), - ), - Effect.orElseSucceed(() => false), - ) - : false; + .pipe(Effect.orElseSucceed(() => Option.none())) + : Option.none(); + const shouldStopSessionAfterArchive = Option.match(threadShellBeforeArchive, { + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }); const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { if (shouldStopSessionAfterArchive) { @@ -1150,14 +1154,36 @@ const makeWsRpcLayer = ( ); } - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); + // Terminals are worktree-scoped on the client (every thread in + // a checkout shares one set of ptys via a canonical thread id), + // so archiving one thread must not kill sessions that sibling + // threads still use — e.g. a running dev server. + const worktreeStillInUse = yield* Option.match(threadShellBeforeArchive, { + onNone: () => Effect.succeed(false), + onSome: (archivedThread) => + projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.map((snapshot) => + snapshot.threads.some( + (thread) => + thread.id !== archivedThread.id && + thread.projectId === archivedThread.projectId && + normalizeWorktreePathForArchive(thread.worktreePath) === + normalizeWorktreePathForArchive(archivedThread.worktreePath), + ), + ), + Effect.orElseSucceed(() => false), + ), + }); + if (!worktreeStillInUse) { + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } } return result; }).pipe( diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c9289..fe7d48de543 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -21,6 +21,7 @@ import { rememberPreviewUrl, } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -41,9 +42,12 @@ export async function openUrlInPreview(input: { readonly url: string; readonly openPreview: OpenPreviewMutation; }): Promise> { + // Preview sessions are worktree-scoped: open through the worktree's + // canonical thread id so sibling threads share one set of tabs. + const canonicalRef = resolveWorktreeCanonicalThreadRef(input.threadRef); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + environmentId: canonicalRef.environmentId, + input: { threadId: canonicalRef.threadId, url: input.url }, }); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..f9fa3471cbd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -210,10 +210,16 @@ import { useProjects, useThread, useThreadProposedPlans, - useThreadRefs, useThreadShell, + useThreadShells, } from "../state/entities"; import { environmentShell } from "../state/shell"; +import { + threadWorktreeScopeKey, + useWorktreeCanonicalThreadRef, + useWorktreeScopeKeyForThreadRef, + worktreeCanonicalThreadRefsByScopeKey, +} from "../worktreeScope"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; @@ -1336,8 +1342,6 @@ function ChatViewContent(props: ChatViewProps) { const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => @@ -1346,14 +1350,34 @@ function ChatViewContent(props: ChatViewProps) { ), [draftThreadsByThreadKey], ); + // Terminal drawers are WORKTREE-scoped: the terminal UI store keys by + // worktree scope key, and each mounted drawer speaks to the server through + // the worktree's canonical thread ref (drafts fall back to plain thread + // keys, which parse below). + const threadShells = useThreadShells(); + const liveWorktreeScopeKeys = useMemo(() => { + const keys = new Set(); + for (const shell of threadShells) { + if (shell.archivedAt === null) { + keys.add(threadWorktreeScopeKey(shell)); + } + } + return keys; + }, [threadShells]); + const canonicalThreadRefByScopeKey = useMemo( + () => worktreeCanonicalThreadRefsByScopeKey(threadShells), + [threadShells], + ); const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const mountedTerminalThreadRefs = useMemo( () => mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + const mountedThreadRef = + canonicalThreadRefByScopeKey.get(mountedThreadKey) ?? + parseScopedThreadKey(mountedThreadKey); return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; }), - [mountedTerminalThreadKeys], + [canonicalThreadRefByScopeKey, mountedTerminalThreadKeys], ); const fallbackDraftProjectRef = draftThread @@ -1424,22 +1448,34 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + // Terminals, previews, and the right panel are WORKTREE-scoped: their wire + // calls go through the worktree's canonical thread id so every sibling + // thread in the checkout shares one set of server sessions (one terminal, + // one dev server, one set of browser tabs per worktree). + const canonicalWorktreeThreadRef = useWorktreeCanonicalThreadRef(activeThreadRef); + const terminalThreadId = canonicalWorktreeThreadRef?.threadId ?? null; + const activeWorktreeScopeKey = useWorktreeScopeKeyForThreadRef(activeThreadRef); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + threadId: terminalThreadId, }); const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + threadId: terminalThreadId, }); const activeThreadKnownSessions = useMemo(() => { - if (activeThreadId === null) { + if (terminalThreadId === null) { return []; } return activeThreadKnownSessionsRaw.filter( - (session) => session.target.threadId === activeThreadId, + (session) => session.target.threadId === terminalThreadId, ); - }, [activeThreadId, activeThreadKnownSessionsRaw]); + }, [terminalThreadId, activeThreadKnownSessionsRaw]); const activeServerOrderedTerminalIds = useMemo( () => activeThreadKnownSessions.map((session) => session.target.terminalId), [activeThreadKnownSessions], @@ -1458,11 +1494,6 @@ function ChatViewContent(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); - const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -1510,9 +1541,12 @@ function ChatViewContent(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + // Store keys are worktree scope keys for server threads and plain thread + // keys for drafts; anything not backed by a live worktree or draft is a + // leftover to skip. + const existingKeys = new Set([...liveWorktreeScopeKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingKeys.has(nextThreadKey)); + }, [draftThreadKeys, liveWorktreeScopeKeys, openTerminalThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const sourcePlanThreadRef = useMemo(() => { const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; @@ -1542,8 +1576,8 @@ function ChatViewContent(props: ChatViewProps) { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + activeThreadId: activeWorktreeScopeKey, + activeThreadTerminalOpen: Boolean(activeWorktreeScopeKey && terminalUiState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -1551,7 +1585,7 @@ function ChatViewContent(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); + }, [activeWorktreeScopeKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -2513,7 +2547,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { - if (!activeThreadId || !activeProject) { + if (!terminalThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2525,7 +2559,7 @@ function ChatViewContent(props: ChatViewProps) { void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2541,7 +2575,6 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, - activeThreadId, activeThreadRef, activeThreadWorktreePath, environmentId, @@ -2550,12 +2583,13 @@ function ChatViewContent(props: ChatViewProps) { panelTerminalIds, setTerminalOpen, storeEnsureTerminal, + terminalThreadId, terminalUiState.terminalIds.length, terminalUiState.terminalOpen, ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + if (!activeThreadRef || hasReachedSplitLimit || !terminalThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2572,7 +2606,7 @@ function ChatViewContent(props: ChatViewProps) { void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2586,7 +2620,7 @@ function ChatViewContent(props: ChatViewProps) { [ activeProject, activeKnownTerminalIds, - activeThreadId, + terminalThreadId, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2598,7 +2632,7 @@ function ChatViewContent(props: ChatViewProps) { ], ); const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { + if (!activeThreadRef || !terminalThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2611,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { void openTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -2624,7 +2658,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, activeKnownTerminalIds, - activeThreadId, + terminalThreadId, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2634,17 +2668,17 @@ function ChatViewContent(props: ChatViewProps) { ]); const closeTerminal = useCallback( (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; + if (!terminalThreadId || !activeThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, - input: { threadId: activeThreadId, terminalId, data: "exit\n" }, + input: { threadId: terminalThreadId, terminalId, data: "exit\n" }, }); void (async () => { const closeResult = await closeTerminalMutation({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, deleteHistory: true, }, @@ -2657,7 +2691,7 @@ function ChatViewContent(props: ChatViewProps) { setTerminalFocusRequestId((value) => value + 1); }, [ - activeThreadId, + terminalThreadId, activeThreadRef, closeTerminalMutation, environmentId, @@ -2676,7 +2710,7 @@ function ChatViewContent(props: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - if (!activeThreadId || !activeProject || !activeThread) return; + if (!activeThreadId || !terminalThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -2714,7 +2748,7 @@ function ChatViewContent(props: ChatViewProps) { : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), @@ -2723,7 +2757,7 @@ function ChatViewContent(props: ChatViewProps) { rows: SCRIPT_TERMINAL_ROWS, } : { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), @@ -2751,7 +2785,7 @@ function ChatViewContent(props: ChatViewProps) { const writeResult = await writeTerminal({ environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }, @@ -2768,6 +2802,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeThread, activeThreadId, + terminalThreadId, activeThreadRef, gitCwd, setTerminalOpen, @@ -3034,7 +3069,7 @@ function ChatViewContent(props: ChatViewProps) { } }, [activeThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!activeThreadRef || !terminalThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); @@ -3042,7 +3077,7 @@ function ChatViewContent(props: ChatViewProps) { void openTerminal({ environmentId: activeThreadRef.environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -3055,7 +3090,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, - activeThreadId, + terminalThreadId, activeThreadRef, activeThreadWorktreePath, gitCwd, @@ -3066,7 +3101,7 @@ function ChatViewContent(props: ChatViewProps) { (direction: "horizontal" | "vertical" = "horizontal") => { if ( !activeThreadRef || - !activeThreadId || + !terminalThreadId || !activeProject || activeRightPanelSurface?.kind !== "terminal" || activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP @@ -3082,7 +3117,7 @@ function ChatViewContent(props: ChatViewProps) { void openTerminal({ environmentId: activeThreadRef.environmentId, input: { - threadId: activeThreadId, + threadId: terminalThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), @@ -3097,7 +3132,7 @@ function ChatViewContent(props: ChatViewProps) { activeKnownTerminalIds, activeProject, activeRightPanelSurface, - activeThreadId, + terminalThreadId, activeThreadRef, activeThreadWorktreePath, gitCwd, @@ -3123,7 +3158,11 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + input: { + threadId: terminalThreadId ?? activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }, }); storeCloseTerminal(activeThreadRef, terminalId); useRightPanelStore @@ -3131,7 +3170,13 @@ function ChatViewContent(props: ChatViewProps) { .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], + [ + activeRightPanelSurface, + activeThreadRef, + closeTerminalMutation, + storeCloseTerminal, + terminalThreadId, + ], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -4209,16 +4254,16 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadId, terminalUiState.terminalOpen]); useEffect(() => { - if (!activeThreadKey) return; - const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; + if (!activeWorktreeScopeKey) return; + const previous = terminalUiOpenByThreadRef.current[activeWorktreeScopeKey] ?? false; const current = Boolean(terminalUiState.terminalOpen); if (!previous && current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[activeWorktreeScopeKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[activeWorktreeScopeKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -4227,8 +4272,8 @@ function ChatViewContent(props: ChatViewProps) { }; } - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + terminalUiOpenByThreadRef.current[activeWorktreeScopeKey] = current; + }, [activeWorktreeScopeKey, focusComposer, terminalUiState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -5535,7 +5580,7 @@ function ChatViewContent(props: ChatViewProps) { ) : activeRightPanelSurface?.kind === "terminal" ? ( = {}): EnvironmentThreadShell { + return { + id: ThreadId.make("thread-1"), + environmentId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + } as EnvironmentThreadShell; +} + +function classifyAll( + threads: EnvironmentThreadShell[], + classification: SidebarThreadClassification = "active", +) { + return threads.map((thread) => ({ thread, classification })); +} + +describe("buildSidebarWorktreeGroups", () => { + it("groups threads sharing a worktree path into one card, oldest member first", () => { + const older = makeShell({ + id: ThreadId.make("thread-older"), + worktreePath: "/wt/feature", + createdAt: "2026-03-09T10:00:00.000Z", + }); + const newer = makeShell({ + id: ThreadId.make("thread-newer"), + worktreePath: "/wt/feature", + createdAt: "2026-03-09T11:00:00.000Z", + }); + const { activeGroups } = buildSidebarWorktreeGroups(classifyAll([newer, older])); + expect(activeGroups).toHaveLength(1); + expect(activeGroups[0]!.threads.map((thread) => thread.id)).toEqual([older.id, newer.id]); + expect(activeGroups[0]!.memberKeys).toEqual([ + sidebarThreadKey(older), + sidebarThreadKey(newer), + ]); + }); + + it("groups local-checkout threads (null worktreePath) per project, not per thread", () => { + const localA = makeShell({ id: ThreadId.make("thread-a") }); + const localB = makeShell({ id: ThreadId.make("thread-b") }); + const otherProject = makeShell({ + id: ThreadId.make("thread-c"), + projectId: ProjectId.make("project-2"), + }); + const { activeGroups } = buildSidebarWorktreeGroups( + classifyAll([localA, localB, otherProject]), + ); + expect(activeGroups).toHaveLength(2); + const sizes = activeGroups.map((group) => group.threads.length).toSorted(); + expect(sizes).toEqual([1, 2]); + }); + + it("keeps distinct worktrees as distinct cards", () => { + const first = makeShell({ id: ThreadId.make("thread-a"), worktreePath: "/wt/one" }); + const second = makeShell({ id: ThreadId.make("thread-b"), worktreePath: "/wt/two" }); + const { activeGroups } = buildSidebarWorktreeGroups(classifyAll([first, second])); + expect(activeGroups).toHaveLength(2); + }); + + it("classifies the group by its most-alive member: any active member keeps the card active", () => { + const settled = makeShell({ id: ThreadId.make("thread-settled"), worktreePath: "/wt/x" }); + const active = makeShell({ id: ThreadId.make("thread-active"), worktreePath: "/wt/x" }); + const { activeGroups, settledGroups } = buildSidebarWorktreeGroups([ + { thread: settled, classification: "settled" }, + { thread: active, classification: "active" }, + ]); + expect(activeGroups).toHaveLength(1); + expect(settledGroups).toHaveLength(0); + expect(activeGroups[0]!.threads).toHaveLength(2); + }); + + it("shelves a group as snoozed when members are snoozed and none active", () => { + const snoozed = makeShell({ + id: ThreadId.make("thread-snoozed"), + worktreePath: "/wt/x", + snoozedUntil: "2026-03-10T10:00:00.000Z", + }); + const settled = makeShell({ id: ThreadId.make("thread-settled"), worktreePath: "/wt/x" }); + const { activeGroups, snoozedGroups, settledGroups } = buildSidebarWorktreeGroups([ + { thread: snoozed, classification: "snoozed" }, + { thread: settled, classification: "settled" }, + ]); + expect(activeGroups).toHaveLength(0); + expect(snoozedGroups).toHaveLength(1); + expect(settledGroups).toHaveLength(0); + }); + + it("shelves a group as settled only when every member settled", () => { + const first = makeShell({ + id: ThreadId.make("thread-a"), + worktreePath: "/wt/x", + settledAt: "2026-03-09T12:00:00.000Z", + }); + const second = makeShell({ + id: ThreadId.make("thread-b"), + worktreePath: "/wt/x", + settledAt: "2026-03-09T13:00:00.000Z", + }); + const { settledGroups } = buildSidebarWorktreeGroups(classifyAll([first, second], "settled")); + expect(settledGroups).toHaveLength(1); + expect(settledGroups[0]!.threads).toHaveLength(2); + }); + + it("orders cards statically by their newest member, newest worktree on top", () => { + const oldWorktree = makeShell({ + id: ThreadId.make("thread-old"), + worktreePath: "/wt/old", + createdAt: "2026-03-09T09:00:00.000Z", + }); + const busyWorktreeOldMember = makeShell({ + id: ThreadId.make("thread-busy-old"), + worktreePath: "/wt/busy", + createdAt: "2026-03-09T08:00:00.000Z", + }); + const busyWorktreeNewMember = makeShell({ + id: ThreadId.make("thread-busy-new"), + worktreePath: "/wt/busy", + createdAt: "2026-03-09T12:00:00.000Z", + }); + const { activeGroups } = buildSidebarWorktreeGroups( + classifyAll([oldWorktree, busyWorktreeOldMember, busyWorktreeNewMember]), + ); + expect(activeGroups.map((group) => group.threads[0]!.worktreePath)).toEqual([ + "/wt/busy", + "/wt/old", + ]); + }); +}); + +describe("pickWorktreeGroupRepresentative", () => { + it("prefers the route thread when it is a member", () => { + const first = makeShell({ id: ThreadId.make("thread-a"), worktreePath: "/wt/x" }); + const second = makeShell({ id: ThreadId.make("thread-b"), worktreePath: "/wt/x" }); + const { activeGroups } = buildSidebarWorktreeGroups(classifyAll([first, second])); + const representative = pickWorktreeGroupRepresentative( + activeGroups[0]!, + sidebarThreadKey(first), + ); + expect(representative.id).toBe(first.id); + }); + + it("uses the most recently settled member for settled groups", () => { + const earlier = makeShell({ + id: ThreadId.make("thread-a"), + worktreePath: "/wt/x", + settledAt: "2026-03-09T12:00:00.000Z", + }); + const later = makeShell({ + id: ThreadId.make("thread-b"), + worktreePath: "/wt/x", + settledAt: "2026-03-09T15:00:00.000Z", + }); + const { settledGroups } = buildSidebarWorktreeGroups(classifyAll([earlier, later], "settled")); + expect(pickWorktreeGroupRepresentative(settledGroups[0]!, null).id).toBe(later.id); + }); + + it("uses the soonest-waking snoozed member for snoozed groups", () => { + const wakesLater = makeShell({ + id: ThreadId.make("thread-a"), + worktreePath: "/wt/x", + snoozedUntil: "2026-03-11T10:00:00.000Z", + }); + const wakesSooner = makeShell({ + id: ThreadId.make("thread-b"), + worktreePath: "/wt/x", + snoozedUntil: "2026-03-10T10:00:00.000Z", + }); + const { snoozedGroups } = buildSidebarWorktreeGroups( + classifyAll([wakesLater, wakesSooner], "snoozed"), + ); + expect(pickWorktreeGroupRepresentative(snoozedGroups[0]!, null).id).toBe(wakesSooner.id); + }); +}); + +describe("resolveWorktreeGroupLiveStatus", () => { + const running = (startedAt: string) => + makeShell({ + id: ThreadId.make(`thread-run-${startedAt}`), + session: { + status: "running", + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + updatedAt: startedAt, + } as EnvironmentThreadShell["session"], + latestTurn: { + requestedAt: startedAt, + startedAt, + completedAt: null, + } as EnvironmentThreadShell["latestTurn"], + }); + + it("returns null when every member is at rest", () => { + expect(resolveWorktreeGroupLiveStatus([makeShell(), makeShell()])).toBeNull(); + }); + + it("ranks approval above working", () => { + const status = resolveWorktreeGroupLiveStatus([ + running("2026-03-09T10:00:00.000Z"), + makeShell({ id: ThreadId.make("thread-approval"), hasPendingApprovals: true }), + ]); + expect(status?.kind).toBe("approval"); + }); + + it("counts working from the earliest in-flight member", () => { + const status = resolveWorktreeGroupLiveStatus([ + running("2026-03-09T11:00:00.000Z"), + running("2026-03-09T10:00:00.000Z"), + ]); + expect(status?.kind).toBe("working"); + expect(status?.workingStartedAt).toBe("2026-03-09T10:00:00.000Z"); + }); +}); + +describe("pickWorktreeGroupTimeLabelThread", () => { + it("returns the member with the latest activity", () => { + const stale = makeShell({ + id: ThreadId.make("thread-stale"), + updatedAt: "2026-03-09T10:00:00.000Z", + }); + const fresh = makeShell({ + id: ThreadId.make("thread-fresh"), + latestUserMessageAt: "2026-03-09T12:00:00.000Z", + }); + expect(pickWorktreeGroupTimeLabelThread([stale, fresh]).id).toBe(fresh.id); + }); +}); diff --git a/apps/web/src/components/SidebarV2.logic.ts b/apps/web/src/components/SidebarV2.logic.ts new file mode 100644 index 00000000000..94024f39838 --- /dev/null +++ b/apps/web/src/components/SidebarV2.logic.ts @@ -0,0 +1,243 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; + +import { threadWorktreeScopeKey } from "../worktreeScope"; +import { + firstValidTimestamp, + firstValidTimestampMs, + parseTimestampMs, + resolveSettledTimestamp, + resolveSidebarV2Status, + resolveWorkingStartedAt, +} from "./Sidebar.logic"; + +export type SidebarWorktreeSection = "active" | "snoozed" | "settled"; + +export type SidebarThreadClassification = "active" | "snoozed" | "settled"; + +/** One sidebar row/card: every visible thread sharing a checkout (git + worktree, or the project workspace root for local-mode threads). */ +export interface SidebarWorktreeGroup { + readonly key: string; + readonly section: SidebarWorktreeSection; + /** Members in creation order (oldest first) — the in-card row order. */ + readonly threads: ReadonlyArray; + /** Member classifications aligned with `threads`. */ + readonly classifications: ReadonlyArray; + /** Scoped thread keys aligned with `threads` (stable identity for memo props). */ + readonly memberKeys: ReadonlyArray; +} + +export function sidebarThreadKey(thread: EnvironmentThreadShell): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +function groupSettledTimestampMs(group: SidebarWorktreeGroup): number { + let latest = 0; + for (const thread of group.threads) { + const timestamp = resolveSettledTimestamp(thread); + if (timestamp !== null) latest = Math.max(latest, parseTimestampMs(timestamp)); + } + return latest; +} + +function groupSoonestWakeMs(group: SidebarWorktreeGroup): number { + let soonest = Number.POSITIVE_INFINITY; + for (const [index, thread] of group.threads.entries()) { + if (group.classifications[index] !== "snoozed") continue; + soonest = Math.min(soonest, firstValidTimestampMs(thread.snoozedUntil ?? null)); + } + return soonest; +} + +function groupNewestCreatedAtMs(group: SidebarWorktreeGroup): number { + let newest = 0; + for (const thread of group.threads) { + newest = Math.max(newest, parseTimestampMs(thread.createdAt)); + } + return newest; +} + +/** + * Group per-thread classifications into worktree rows. A worktree with any + * active member is a full card (settled/snoozed members ride along inside + * it); with none active it collapses to the snoozed shelf when any member + * is snoozed, else to the settled tail. Sorting mirrors the per-thread + * rules: cards hold static creation order (newest worktree on top), + * snoozed groups order by soonest wake, settled groups by most recent + * wrap-up. + */ +export function buildSidebarWorktreeGroups( + classified: ReadonlyArray<{ + readonly thread: EnvironmentThreadShell; + readonly classification: SidebarThreadClassification; + }>, +): { + activeGroups: SidebarWorktreeGroup[]; + snoozedGroups: SidebarWorktreeGroup[]; + settledGroups: SidebarWorktreeGroup[]; +} { + const byKey = new Map< + string, + { threads: EnvironmentThreadShell[]; classifications: SidebarThreadClassification[] } + >(); + for (const { thread, classification } of classified) { + const key = threadWorktreeScopeKey(thread); + const entry = byKey.get(key) ?? { threads: [], classifications: [] }; + entry.threads.push(thread); + entry.classifications.push(classification); + byKey.set(key, entry); + } + + const activeGroups: SidebarWorktreeGroup[] = []; + const snoozedGroups: SidebarWorktreeGroup[] = []; + const settledGroups: SidebarWorktreeGroup[] = []; + for (const [key, entry] of byKey) { + const order = entry.threads + .map((_, index) => index) + .toSorted( + (left, right) => + parseTimestampMs(entry.threads[left]!.createdAt) - + parseTimestampMs(entry.threads[right]!.createdAt) || + entry.threads[left]!.id.localeCompare(entry.threads[right]!.id), + ); + const threads = order.map((index) => entry.threads[index]!); + const classifications = order.map((index) => entry.classifications[index]!); + const memberKeys = threads.map(sidebarThreadKey); + const section: SidebarWorktreeSection = classifications.includes("active") + ? "active" + : classifications.includes("snoozed") + ? "snoozed" + : "settled"; + const group: SidebarWorktreeGroup = { key, section, threads, classifications, memberKeys }; + if (section === "active") activeGroups.push(group); + else if (section === "snoozed") snoozedGroups.push(group); + else settledGroups.push(group); + } + + activeGroups.sort( + (left, right) => + groupNewestCreatedAtMs(right) - groupNewestCreatedAtMs(left) || + left.key.localeCompare(right.key), + ); + snoozedGroups.sort( + (left, right) => + groupSoonestWakeMs(left) - groupSoonestWakeMs(right) || left.key.localeCompare(right.key), + ); + settledGroups.sort( + (left, right) => + groupSettledTimestampMs(right) - groupSettledTimestampMs(left) || + left.key.localeCompare(right.key), + ); + return { activeGroups, snoozedGroups, settledGroups }; +} + +/** + * The thread a collapsed (slim) group row stands in for: the route thread + * when it's a member (so highlight and pull-into-view keep pointing at what + * the user has open), otherwise the member matching the shelf's own sort + * story — soonest wake for snoozed groups, most recent wrap-up for settled + * ones, newest member for cards. + */ +export function pickWorktreeGroupRepresentative( + group: SidebarWorktreeGroup, + routeThreadKey: string | null, +): EnvironmentThreadShell { + if (routeThreadKey !== null) { + const routeMember = group.threads.find((thread) => sidebarThreadKey(thread) === routeThreadKey); + if (routeMember !== undefined) return routeMember; + } + if (group.section === "snoozed") { + let best: EnvironmentThreadShell | null = null; + let bestWake = Number.POSITIVE_INFINITY; + for (const [index, thread] of group.threads.entries()) { + if (group.classifications[index] !== "snoozed") continue; + const wake = firstValidTimestampMs(thread.snoozedUntil ?? null); + if (wake < bestWake || best === null) { + best = thread; + bestWake = wake; + } + } + if (best !== null) return best; + } + if (group.section === "settled") { + let best: EnvironmentThreadShell | null = null; + let bestMs = Number.NEGATIVE_INFINITY; + for (const thread of group.threads) { + const timestamp = resolveSettledTimestamp(thread); + const ms = timestamp === null ? 0 : parseTimestampMs(timestamp); + if (ms > bestMs || best === null) { + best = thread; + bestMs = ms; + } + } + if (best !== null) return best; + } + return group.threads.reduce((newest, thread) => + parseTimestampMs(thread.createdAt) >= parseTimestampMs(newest.createdAt) ? thread : newest, + ); +} + +/** Card-level live status: what the worktree as a whole is doing, ranked by + how urgently it needs the user (act now > answer > in motion > broken). + Per-thread Done/Woke signals stay on the member rows — this is only the + shells-derived aggregate for the card's top-right slot. */ +export function resolveWorktreeGroupLiveStatus( + threads: ReadonlyArray, +): { kind: "approval" | "input" | "working" | "failed"; workingStartedAt: string | null } | null { + let hasApproval = false; + let hasInput = false; + let hasFailed = false; + let hasWorking = false; + let workingStartedAt: string | null = null; + let workingStartedAtMs = Number.POSITIVE_INFINITY; + for (const thread of threads) { + const status = resolveSidebarV2Status(thread); + if (status === "approval") hasApproval = true; + else if (status === "input") hasInput = true; + else if (status === "failed") hasFailed = true; + else if (status === "working") { + hasWorking = true; + const startedAt = resolveWorkingStartedAt(thread); + const startedMs = startedAt === null ? Number.NaN : Date.parse(startedAt); + // Earliest running start wins: the card counts the oldest in-flight + // work, not the most recently kicked-off member. + if (!Number.isNaN(startedMs) && startedMs < workingStartedAtMs) { + workingStartedAt = startedAt; + workingStartedAtMs = startedMs; + } + } + } + if (hasApproval) return { kind: "approval", workingStartedAt: null }; + if (hasInput) return { kind: "input", workingStartedAt: null }; + if (hasWorking) return { kind: "working", workingStartedAt }; + if (hasFailed) return { kind: "failed", workingStartedAt: null }; + return null; +} + +/** The member whose latest activity stamps the card's resting time label. */ +export function pickWorktreeGroupTimeLabelThread( + threads: ReadonlyArray, +): EnvironmentThreadShell { + return threads.reduce((latest, thread) => { + const latestMs = firstValidTimestampMs(latest.latestUserMessageAt, latest.updatedAt); + const threadMs = firstValidTimestampMs(thread.latestUserMessageAt, thread.updatedAt); + return threadMs >= latestMs ? thread : latest; + }); +} + +/** ISO timestamp used by tests/debug displays for a group's settled label. */ +export function resolveWorktreeGroupSettledTimestamp(group: SidebarWorktreeGroup): string | null { + let best: string | null = null; + let bestMs = Number.NEGATIVE_INFINITY; + for (const thread of group.threads) { + const timestamp = firstValidTimestamp(resolveSettledTimestamp(thread)); + if (timestamp === null) continue; + const ms = parseTimestampMs(timestamp); + if (ms > bestMs) { + best = timestamp; + bestMs = ms; + } + } + return best; +} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..af233dfe764 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,7 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { + canSettle, canSnooze, effectiveSettled, effectiveSnoozed, @@ -26,12 +27,14 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, + Globe2Icon, EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, ServerIcon, SquarePenIcon, + TerminalIcon, Trash2Icon, Undo2Icon, } from "lucide-react"; @@ -90,6 +93,9 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; +import { useRunningTerminalsForThreads } from "../state/terminalSessions"; +import { useDiscoveredPortsForThreads } from "../portDiscoveryState"; +import { previewEnvironment } from "../state/preview"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; @@ -109,13 +115,20 @@ import { resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, - resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebarV2, - sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + buildSidebarWorktreeGroups, + pickWorktreeGroupRepresentative, + pickWorktreeGroupTimeLabelThread, + resolveWorktreeGroupLiveStatus, + sidebarThreadKey, + type SidebarThreadClassification, + type SidebarWorktreeGroup, +} from "./SidebarV2.logic"; import { prStatusIndicator, resolveThreadPr, @@ -352,12 +365,12 @@ function SnoozePopoverButton(props: { ); } +// Slim shelf row: one row per parked WORKTREE (snoozed or settled group). +// `thread` is the group's representative member (route thread when it's a +// member); lifecycle actions act on the whole group via the parent. const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; - variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; + variantAction: "unsettle" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -375,6 +388,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { projectCwd: string | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; + // Every member thread key in the row's worktree group: the row owns the + // group's single VCS subscription, and the partition needs the PR state + // under each member's key for effectiveSettled. + changeRequestThreadKeys: ReadonlyArray; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -384,9 +401,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isRenaming: boolean; renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; - onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { @@ -397,8 +412,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onCommitRename, onContextMenu, onRenameTitleChange, - onSettle, - onSnooze, onStartRename, onThreadActivate, onThreadClick, @@ -406,7 +419,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsnooze, renamingTitle, thread, - variant, variantAction, } = props; const threadRef = useMemo( @@ -441,48 +453,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. - const topStatus = - status === "working" - ? { - label: "Working", - icon: "working" as const, - className: - "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", - } - : status === "approval" - ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", - } - : status === "input" - ? { - label: "Input", - icon: null, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "failed" - ? { - label: "Failed", - icon: null, - className: "text-red-700 dark:text-red-300", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } - : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -507,11 +477,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. + // and a merged/closed PR auto-settles a thread — data only rows have. The + // worktree's PR state applies to every member thread of the group. const prState = pr?.state ?? null; + const { changeRequestThreadKeys } = props; useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + for (const memberKey of changeRequestThreadKeys) { + onChangeRequestState(memberKey, prState); + } + }, [changeRequestThreadKeys, onChangeRequestState, prState]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -523,9 +497,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; - const detailsTooltip = ( { - event.preventDefault(); - event.stopPropagation(); - onSettle(threadRef); - }, - [onSettle, threadRef], - ); const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -620,28 +583,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsnooze, threadRef], ); - const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { - onSnooze(threadRef, preset); - }, - [onSnooze, threadRef], - ); - // While the snooze popover is open the pointer leaves the row, which - // would fade the hover actions out from under the open menu; pin them. - const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); - // Snooze is offered only where it can succeed: capability-gated and never - // on blocked-on-you work or queued turns (the server rejects both). - const showSnoozeButton = - props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); - // If the thread becomes blocked while the popover is open, the button - // unmounts without firing onOpenChange(false). Deriving the flag keeps a - // stale true from permanently hiding the status label / pinning the - // hover actions, and the effect clears the raw state so the popover - // doesn't resurrect if the button later remounts. - const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; - useEffect(() => { - if (!showSnoozeButton) setSnoozeMenuOpen(false); - }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -686,25 +627,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className={cn( "min-w-0 flex-1 text-sm", shouldRecede ? "font-normal" : "font-medium", - variant === "card" - ? cn( - "truncate", - isUnread || isWoke - ? "text-foreground" - : shouldRecede - ? "text-muted-foreground/80" - : status === "failed" - ? "text-foreground/95" - : "text-foreground/90", - ) - : cn( - "truncate group-hover/v2-row:text-foreground", - props.isActive || isWoke - ? "text-foreground" - : isUnread - ? "text-muted-foreground" - : "text-muted-foreground/70", - ), + "truncate group-hover/v2-row:text-foreground", + props.isActive || isWoke + ? "text-foreground" + : isUnread + ? "text-muted-foreground" + : "text-muted-foreground/70", )} > {thread.title} @@ -718,7 +646,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onClick={handlePrClick} className={cn( "shrink-0 font-mono text-xs hover:underline", - variant === "slim" && variantAction === "unsettle" + variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) @@ -730,9 +658,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null; - if (variant === "slim") { - return ( -
  • @@ -810,7 +737,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + ) : !props.settlementSupported ? null : ( - ) : ( - )} {props.jumpLabel ? : null} @@ -835,166 +753,729 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {detailsTooltip}
  • - ); - } + ); +}); - const diff = latestTurnDiff(thread); - return ( -
  • ; + isRenaming: boolean; + renamingTitle: string; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; +}) { + const { + isRenaming, + onCancelRename, + onCommitRename, + onContextMenu, + onRenameTitleChange, + onStartRename, + onThreadActivate, + onThreadClick, + renamingTitle, + thread, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarV2Status(thread); + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); + const rowRecedes = + (status === "ready" || status === "working" || status === "approval" || status === "input") && + !isUnread && + !isWoke && + !props.isActive && + !isSelected; + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + + // Per-thread signal, most-urgent first — the card's top row carries the + // aggregate, this glyph says which member it's about. + const statusGlyph = + status === "approval" ? ( + + ) : status === "input" ? ( + + ) : status === "working" ? ( + + ) : status === "failed" ? ( + + ) : isWoke ? ( + + ) : isUnread ? ( + + ) : null; + + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ) : ( + - - + ); + + return ( + + + } + > + {title} + {statusGlyph} + {driverKind ? ( + + - } - > -
    -
    - - {props.projectTitle ? ( - + ) : null} + {props.jumpLabel ? : null} + + + + ); +}); + +// A worktree's card: repository identity, activity indicators and lifecycle +// actions live at the card (worktree) level; each member thread renders as +// its own clickable row inside. +const SidebarV2WorktreeCard = memo(function SidebarV2WorktreeCard(props: { + groupKey: string; + threads: ReadonlyArray; + memberKeys: ReadonlyArray; + // Route thread key when it belongs to this worktree, else null. + activeThreadKey: string | null; + settlementSupported: boolean; + snoozeSupported: boolean; + snoozeNow: string; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + // Null while jump hints are hidden so the common case keeps memo identity. + jumpLabelByKey: ReadonlyMap | null; + renamingThreadKey: string | null; + renamingTitle: string; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + memberKeys, + onChangeRequestState, + onContextMenu, + onSettle, + onSnooze, + onThreadActivate, + onThreadClick, + threads, + } = props; + const newest = threads[threads.length - 1]!; + const newestRef = useMemo( + () => scopeThreadRef(newest.environmentId, newest.id), + [newest.environmentId, newest.id], + ); + const activeMember = + props.activeThreadKey === null + ? null + : (threads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + props.activeThreadKey, + ) ?? null); + const isActiveCard = activeMember !== null; + const environmentId = newest.environmentId; + const threadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); + + // Worktree-level activity: any member thread's terminal with a running + // subprocess, and any dev server discovered on a member's terminal. These + // sit inline with the repository row because terminals and dev servers + // belong to the checkout, not to an individual thread. + const runningTerminals = useRunningTerminalsForThreads({ environmentId, threadIds }); + const discoveredPorts = useDiscoveredPortsForThreads({ environmentId, threadIds }); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const handleOpenDiscoveredPort = useCallback( + (event: ReactMouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + const targetRef = + activeMember === null + ? newestRef + : scopeThreadRef(activeMember.environmentId, activeMember.id); + onThreadActivate(targetRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef: targetRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [activeMember, discoveredPorts, newestRef, onThreadActivate, openPreview], + ); + + const anySelected = useThreadSelectionStore((state) => + memberKeys.some((key) => state.selectedThreadKeys.has(key)), + ); + // One subscription for all members' visited stamps: a joined signature + // keeps the selector referentially stable while letting the card derive + // per-member unread/woke without a hook per member. + const visitedSignature = useUiStateStore((state) => + memberKeys.map((key) => state.threadLastVisitedAtById[key] ?? "").join(""), + ); + const { anyUnread, anyWoke, wokeAtByKey } = useMemo(() => { + const visited = visitedSignature.split(""); + let anyUnread = false; + let anyWoke = false; + const wokeAtByKey = new Map(); + threads.forEach((thread, index) => { + const lastVisitedAt = visited[index] === "" ? undefined : visited[index]; + if (hasUnseenCompletion({ ...thread, lastVisitedAt })) anyUnread = true; + const wokeAt = threadWokeAt(thread, { now: props.snoozeNow }); + wokeAtByKey.set(memberKeys[index]!, wokeAt); + if (wokeAt !== null) { + const lastVisitedDate = lastVisitedAt == null ? null : parseTimestampDate(lastVisitedAt); + const wokeDate = parseTimestampDate(wokeAt); + if (wokeDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeDate)) { + anyWoke = true; + } + } + }); + return { anyUnread, anyWoke, wokeAtByKey }; + }, [memberKeys, props.snoozeNow, threads, visitedSignature]); + + const liveStatus = resolveWorktreeGroupLiveStatus(threads); + const isInFlight = liveStatus !== null && liveStatus.kind !== "failed"; + const shouldRecede = + (liveStatus === null || isInFlight) && !anyUnread && !anyWoke && !isActiveCard && !anySelected; + const topStatus = + liveStatus?.kind === "working" + ? { + label: "Working", + icon: "working" as const, + className: + "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", + } + : liveStatus?.kind === "approval" + ? { label: "Approval", icon: null, className: "text-amber-700 dark:text-amber-300" } + : liveStatus?.kind === "input" + ? { label: "Input", icon: null, className: "text-indigo-600 dark:text-indigo-300" } + : liveStatus?.kind === "failed" + ? { label: "Failed", icon: null, className: "text-red-700 dark:text-red-300" } + : anyWoke + ? { + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", + } + : anyUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; + + // The worktree's VCS state is card-level: one status subscription per + // checkout, PR state fanned out to every member key for the partition. + const worktreePath = threads.find((thread) => thread.worktreePath !== null)?.worktreePath ?? null; + const gitCwd = worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + (newest.branch != null || worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.status({ + environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: worktreePath === null ? "local" : "worktree", + activeWorktreePath: worktreePath, + activeThreadBranch: newest.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const pr = resolveThreadPr({ + threadBranch: newest.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: worktreePath !== null, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prState = pr?.state ?? null; + useEffect(() => { + for (const memberKey of memberKeys) { + onChangeRequestState(memberKey, prState); + } + }, [memberKeys, onChangeRequestState, prState]); + const openPrLink = useOpenPrLink(); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const handleCardClick = useCallback( + (event: ReactMouseEvent) => { + if (isTrailingDoubleClick(event.detail)) return; + onThreadClick(event, newestRef); + }, + [newestRef, onThreadClick], + ); + const handleCardContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(newestRef, { x: event.clientX, y: event.clientY }); + }, + [newestRef, onContextMenu], + ); + const handleCardKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(newestRef); + }, + [newestRef, onThreadActivate], + ); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(newestRef); + }, + [newestRef, onSettle], + ); + const handleSnoozePreset = useCallback( + (preset: SnoozePreset) => { + onSnooze(newestRef, preset); + }, + [newestRef, onSnooze], + ); + // While the snooze popover is open the pointer leaves the card, which + // would fade the hover actions out from under the open menu; pin them. + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); + // Snooze acts on the whole worktree, so it's offered only when every + // member can take it — a half-applied snooze would leave the card in + // place and read as a failed click. + const snoozeNowIso = new Date().toISOString(); + const showSnoozeButton = + props.snoozeSupported && threads.every((thread) => canSnooze(thread, { now: snoozeNowIso })); + const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; + useEffect(() => { + if (!showSnoozeButton) setSnoozeMenuOpen(false); + }, [showSnoozeButton]); + + const isRemote = + props.currentEnvironmentId !== null && environmentId !== props.currentEnvironmentId; + const diff = latestTurnDiff(newest); + const timeLabelThread = pickWorktreeGroupTimeLabelThread(threads); + + const cardSurfaceClassName = cn( + "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + isActiveCard + ? "bg-sidebar-row-active text-sidebar-foreground" + : anySelected + ? "bg-sidebar-row-selected text-sidebar-foreground" + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + isInFlight && !isActiveCard && !anySelected && "opacity-70 transition-opacity hover:opacity-100", + ); + + return ( +
  • +
    +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : null} + {runningTerminals.length > 0 ? ( + + + } > - {props.projectTitle} - - ) : ( - - )} - - + + Terminal process running + + ) : null} + {discoveredPorts.length > 0 ? ( + + + } > - {topStatus ? ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : topStatus.icon === "woke" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + ) : null} + + + {topStatus ? ( - {showSnoozeButton ? ( - + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : topStatus.icon === "woke" ? ( + ) : null} - {props.settlementSupported ? ( - + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {liveStatus?.kind === "working" ? ( + + + ) : null} - ) : null} + ) : ( + threadTimeLabel(timeLabelThread) + )} -
    -
    {title}
    -
    - {thread.branch ? ( - {thread.branch} - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} ) : null} + +
    +
    + {threads.map((thread, index) => { + const memberKey = memberKeys[index]!; + return ( + + ); + })} +
    +
    + {newest.branch ? ( + {newest.branch} + ) : ( + + )} + {prStatus && pr ? ( + + ) : null} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + {isRemote ? ( - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - - - ) : null} + -
    + ) : null}
    - {props.jumpLabel ? : null} - - {detailsTooltip} - +
    +
  • ); }); -function latestTurnDiff( - thread: SidebarThreadSummary, -): { insertions: number; deletions: number } | null { - // Shells don't carry checkpoint summaries; diff stats render only when the - // shell projection grows them. Kept as a seam so the row layout is ready. - void thread; - return null; -} - export default function SidebarV2() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); @@ -1359,68 +1840,79 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks settled classification: an explicitly snoozed thread - // belongs to the shelf even if it would also auto-settle (the shelf's - // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); + const { activeGroups, snoozedGroups, settledGroups, snoozedThreads, settledThreads, snoozeNow } = + useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + const classified: Array<{ + thread: EnvironmentThreadShell; + classification: SidebarThreadClassification; + }> = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze outranks settled classification: an explicitly snoozed thread + // belongs to the shelf even if it would also auto-settle (the shelf's + // wake time is a stronger statement about when it matters again). + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + classified.push({ thread, classification: "snoozed" }); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + classified.push({ thread, classification: "settled" }); + } else { + classified.push({ thread, classification: "active" }); + } } - } - return { - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + // Rows are WORKTREES: threads sharing a checkout collapse into one + // card (any active member) or one shelf row (all parked). + const groups = buildSidebarWorktreeGroups(classified); + return { + ...groups, + // Soonest wake first: "what comes back next" is the shelf's question + // (and entry 0 is the wake-timer boundary below). + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: settled, + snoozeNow: preciseNow, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -1451,62 +1943,69 @@ export default function SidebarV2() { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } - const visibleSettledThreads = useMemo(() => { - if (settledThreads.length <= settledVisibleCount) return settledThreads; - const visible = settledThreads.slice(0, settledVisibleCount); + const groupContainsRouteThread = useCallback( + (group: SidebarWorktreeGroup) => + routeThreadKey !== null && group.memberKeys.includes(routeThreadKey), + [routeThreadKey], + ); + const visibleSettledGroups = useMemo(() => { + if (settledGroups.length <= settledVisibleCount) return settledGroups; + const visible = settledGroups.slice(0, settledVisibleCount); // The open thread must never hide under "Show more": navigating into a - // deep settled thread (search, deep link) pulls its row into the visible - // tail so the highlight and the un-settle affordance stay reachable. - if (routeThreadKey !== null) { - const routeThread = settledThreads - .slice(settledVisibleCount) - .find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - if (routeThread !== undefined) visible.push(routeThread); - } + // deep settled thread (search, deep link) pulls its worktree's row into + // the visible tail so the highlight and the un-settle affordance stay + // reachable. + const routeGroup = settledGroups.slice(settledVisibleCount).find(groupContainsRouteThread); + if (routeGroup !== undefined) visible.push(routeGroup); return visible; - }, [routeThreadKey, settledThreads, settledVisibleCount]); - const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; + }, [groupContainsRouteThread, settledGroups, settledVisibleCount]); + const hiddenSettledCount = settledGroups.length - visibleSettledGroups.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); - const renderedSettledThreads = useMemo(() => { - if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; - const routeThread = visibleSettledThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + const renderedSettledGroups = useMemo(() => { + if (settledShelfExpanded) return visibleSettledGroups; + const routeGroup = visibleSettledGroups.find(groupContainsRouteThread); + return routeGroup === undefined ? [] : [routeGroup]; + }, [groupContainsRouteThread, settledShelfExpanded, visibleSettledGroups]); // The snoozed shelf is collapsed by default: out of the way, never gone. - // Collapsed threads don't render (and so don't participate in jump + // Collapsed rows don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const visibleSnoozedThreads = useMemo(() => { - if (snoozedShelfExpanded) return snoozedThreads; + const visibleSnoozedGroups = useMemo(() => { + if (snoozedShelfExpanded) return snoozedGroups; // The open thread must never vanish behind the collapsed shelf: a // snoozed thread reached by route (deep link, open before snoozing - // elsewhere) keeps its row — with highlight and wake affordance — same - // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; - const routeThread = snoozedThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + // elsewhere) keeps its worktree's row — with highlight and wake + // affordance — same exception the settled tail's "Show more" makes. + const routeGroup = snoozedGroups.find(groupContainsRouteThread); + return routeGroup === undefined ? [] : [routeGroup]; + }, [groupContainsRouteThread, snoozedShelfExpanded, snoozedGroups]); + // Shelf rows stand in for their whole group; the representative prefers + // the route thread so highlight and navigation stay honest. + const snoozedRepThreads = useMemo( + () => visibleSnoozedGroups.map((group) => pickWorktreeGroupRepresentative(group, routeThreadKey)), + [routeThreadKey, visibleSnoozedGroups], + ); + const settledRepThreads = useMemo( + () => renderedSettledGroups.map((group) => pickWorktreeGroupRepresentative(group, routeThreadKey)), + [renderedSettledGroups, routeThreadKey], + ); + // The flat jump/selection spine: card members in visual order, then the + // shelf representatives. Hidden group members are not navigable rows. const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => [ + ...activeGroups.flatMap((group) => group.threads), + ...snoozedRepThreads, + ...settledRepThreads, + ], + [activeGroups, snoozedRepThreads, settledRepThreads], ); const orderedThreadKeys = useMemo( () => @@ -1536,6 +2035,20 @@ export default function SidebarV2() { // event and defeat row memoization during streaming. const threadByKeyRef = useRef(threadByKey); threadByKeyRef.current = threadByKey; + // Every member key of every group (including members hidden behind a + // collapsed shelf): lifecycle actions expand a clicked thread to its + // whole worktree through this map. + const groupByThreadKey = useMemo(() => { + const map = new Map(); + for (const group of [...activeGroups, ...snoozedGroups, ...settledGroups]) { + for (const memberKey of group.memberKeys) { + map.set(memberKey, group); + } + } + return map; + }, [activeGroups, snoozedGroups, settledGroups]); + const groupByThreadKeyRef = useRef(groupByThreadKey); + groupByThreadKeyRef.current = groupByThreadKey; // handleNewThread is inherently unstable (depends on the projects list); // a ref keeps it out of attemptSettle's dependency array. const handleNewThreadRef = useRef(newThreadContext.handleNewThread); @@ -1687,132 +2200,239 @@ export default function SidebarV2() { [navigateToThread, router], ); - const attemptSettle = useCallback( - (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + // Lifecycle actions act on the WORKTREE: a clicked thread expands to its + // whole group (falling back to just itself when it isn't in any group, + // e.g. a row that vanished mid-flight). + const resolveWorktreeThreads = useCallback( + (threadRef: ScopedThreadRef): ReadonlyArray => { + const threadKey = scopedThreadKey(threadRef); + const group = groupByThreadKeyRef.current.get(threadKey); + if (group) return group.threads; + const shell = threadByKeyRef.current.get(threadKey); + return shell ? [shell] : []; + }, + [], + ); + const attemptSettleThreads = useCallback( + ( + groupThreads: ReadonlyArray, + opts: { coParkingKeys?: ReadonlySet } = {}, + ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (settlingThreadKeysRef.current.has(threadKey)) return; - settlingThreadKeysRef.current.add(threadKey); + const now = new Date().toISOString(); + const pending = groupThreads.filter((thread) => { + const threadKey = sidebarThreadKey(thread); + return ( + !settlingThreadKeysRef.current.has(threadKey) && + !settledThreadKeysRef.current.has(threadKey) && + thread.settledOverride !== "settled" + ); + }); + if (pending.length === 0) return; + // Prefer members the server will accept; when none qualify, send + // the first anyway so the user sees the server's reason. + const settleable = pending.filter((thread) => canSettle(thread, { now })); + const targets = settleable.length > 0 ? settleable : [pending[0]!]; + const targetKeys = targets.map(sidebarThreadKey); + for (const key of targetKeys) settlingThreadKeysRef.current.add(key); try { - const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + // Forward navigation must skip everything leaving in this batch — + // the whole worktree plus any co-parking selection. + const allParkingKeys = new Set([ + ...(opts.coParkingKeys ?? []), + ...groupThreads.map(sidebarThreadKey), + ]); + const routeKey = targetKeys.find((key) => key === routeThreadKeyRef.current) ?? null; + const navigateAfterSettle = + routeKey === null ? null : planForwardNavigation(routeKey, allParkingKeys); + let routeSettled = false; + for (const target of targets) { + const result = await settleThread(scopeThreadRef(target.environmentId, target.id)); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not settle. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } else if (sidebarThreadKey(target) === routeKey) { + routeSettled = true; } - return; } // Only move forward if the user is still on the settled thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if (routeSettled && routeThreadKeyRef.current === routeKey) { navigateAfterSettle?.(); } } finally { - settlingThreadKeysRef.current.delete(threadKey); + for (const key of targetKeys) settlingThreadKeysRef.current.delete(key); } })(); }, [planForwardNavigation, settleThread], ); - const attemptUnsettle = useCallback( + const attemptSettle = useCallback( (threadRef: ScopedThreadRef) => { + attemptSettleThreads(resolveWorktreeThreads(threadRef)); + }, + [attemptSettleThreads, resolveWorktreeThreads], + ); + const attemptUnsettleThreads = useCallback( + (groupThreads: ReadonlyArray) => { void (async () => { - const result = await unsettleThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + // Un-settle every parked member; already-active members are no-ops + // skipped client-side. + const settledMembers = groupThreads.filter((thread) => + settledThreadKeysRef.current.has(sidebarThreadKey(thread)), + ); + const targets = settledMembers.length > 0 ? settledMembers : groupThreads.slice(0, 1); + for (const target of targets) { + const result = await unsettleThread(scopeThreadRef(target.environmentId, target.id)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } })(); }, [unsettleThread], ); - const attemptUnsnooze = useCallback( + const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { + attemptUnsettleThreads(resolveWorktreeThreads(threadRef)); + }, + [attemptUnsettleThreads, resolveWorktreeThreads], + ); + const attemptUnsnoozeRefs = useCallback( + (threadRefs: ReadonlyArray) => { void (async () => { - const result = await unsnoozeThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + for (const threadRef of threadRefs) { + const result = await unsnoozeThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } })(); }, [unsnoozeThread], ); + const attemptUnsnooze = useCallback( + (threadRef: ScopedThreadRef) => { + const groupThreads = resolveWorktreeThreads(threadRef); + const snoozedMembers = groupThreads.filter((thread) => + snoozedThreadKeysRef.current.has(sidebarThreadKey(thread)), + ); + const targets = (snoozedMembers.length > 0 ? snoozedMembers : groupThreads).map((thread) => + scopeThreadRef(thread.environmentId, thread.id), + ); + attemptUnsnoozeRefs(targets.length > 0 ? targets : [threadRef]); + }, + [attemptUnsnoozeRefs, resolveWorktreeThreads], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); - const attemptSnooze = useCallback( + const attemptSnoozeThreads = useCallback( ( - threadRef: ScopedThreadRef, + groupThreads: ReadonlyArray, preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, + opts: { coParkingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) return; - snoozingThreadKeysRef.current.add(threadKey); + const now = new Date().toISOString(); + const targets = groupThreads.filter((thread) => { + const threadKey = sidebarThreadKey(thread); + return ( + !snoozingThreadKeysRef.current.has(threadKey) && + !snoozedThreadKeysRef.current.has(threadKey) && + canSnooze(thread, { now }) + ); + }); + if (targets.length === 0) return; + const targetKeys = targets.map(sidebarThreadKey); + for (const key of targetKeys) snoozingThreadKeysRef.current.add(key); try { // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + // both park the worktree you're done with for now. + const allParkingKeys = new Set([ + ...(opts.coParkingKeys ?? []), + ...groupThreads.map(sidebarThreadKey), + ]); + const routeKey = targetKeys.find((key) => key === routeThreadKeyRef.current) ?? null; + const navigateAfterSnooze = + routeKey === null ? null : planForwardNavigation(routeKey, allParkingKeys); + const snoozedRefs: ScopedThreadRef[] = []; + let routeSnoozed = false; + for (const target of targets) { + const targetRef = scopeThreadRef(target.environmentId, target.id); + const result = await snoozeThread(targetRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } else { + snoozedRefs.push(targetRef); + if (sidebarThreadKey(target) === routeKey) routeSnoozed = true; } - return; } - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); + if (snoozedRefs.length > 0) { + // Snooze hides the worktree's card, so the toast is the only + // confirmation — one per worktree, not per member — and the + // Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnoozeRefs(snoozedRefs), + }, + }), + ); + } // Only move forward if the user is still on the snoozed thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if (routeSnoozed && routeThreadKeyRef.current === routeKey) { navigateAfterSnooze?.(); } } finally { - snoozingThreadKeysRef.current.delete(threadKey); + for (const key of targetKeys) snoozingThreadKeysRef.current.delete(key); } })(); }, - [attemptUnsnooze, planForwardNavigation, snoozeThread], + [attemptUnsnoozeRefs, planForwardNavigation, snoozeThread], + ); + const attemptSnooze = useCallback( + (threadRef: ScopedThreadRef, preset: SnoozePreset) => { + attemptSnoozeThreads(resolveWorktreeThreads(threadRef), preset); + }, + [attemptSnoozeThreads, resolveWorktreeThreads], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); @@ -1829,14 +2449,22 @@ export default function SidebarV2() { ); if (threadKeys.length === 0) return; const count = threadKeys.length; - // Snooze (N) is offered when every selected thread can actually take - // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + // Lifecycle actions park WORKTREES: the selection expands to every + // member of every selected thread's group before settling/snoozing. + const expandedByKey = new Map(); + for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; - }); - const canSnoozeSelection = snoozableThreads.every( + if (!thread) continue; + const groupThreads = groupByThreadKeyRef.current.get(threadKey)?.threads ?? [thread]; + for (const member of groupThreads) { + expandedByKey.set(sidebarThreadKey(member), member); + } + } + const expandedThreads = [...expandedByKey.values()]; + // Snooze (N) is offered when every affected thread can actually take + // it — a mixed batch with blocked-on-you work would half-apply. + const selectionNow = new Date().toISOString(); + const canSnoozeSelection = expandedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow }), @@ -1870,29 +2498,18 @@ export default function SidebarV2() { (candidate) => `snooze:${candidate.id}` === clicked.value, ); if (preset) { - // Post-snooze navigation must skip threads snoozing in this same - // batch — they are all leaving the card block together. - const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { - attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { - coSnoozingKeys, - }); - } + // One batch: post-snooze navigation skips everything leaving + // together, and the whole batch gets a single Undo toast. + attemptSnoozeThreads(expandedThreads, preset); clearSelection(); } return; } if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } + // One batch: post-settle navigation must skip threads settling in + // this same batch — they are all leaving the card block together. + // Already-settled members are skipped inside the handler. + attemptSettleThreads(expandedThreads); clearSelection(); return; } @@ -1945,8 +2562,8 @@ export default function SidebarV2() { removeFromSelection(threadKeys); }, [ - attemptSettle, - attemptSnooze, + attemptSettleThreads, + attemptSnoozeThreads, clearSelection, confirmThreadDelete, deleteThread, @@ -1980,6 +2597,16 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + // Lifecycle items act on the thread's whole worktree; the labels + // say so as soon as more than one thread shares it. + const groupThreads = groupByThreadKeyRef.current.get(threadKey)?.threads ?? [thread]; + const isMultiThreadWorktree = groupThreads.length > 1; + const settleLabel = isMultiThreadWorktree + ? `Settle worktree (${groupThreads.length} threads)` + : "Settle thread"; + const unsettleLabel = isMultiThreadWorktree ? "Un-settle worktree" : "Un-settle thread"; + const wakeLabel = isMultiThreadWorktree ? "Wake worktree" : "Wake thread"; + const menuNow = new Date().toISOString(); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => @@ -1996,18 +2623,22 @@ export default function SidebarV2() { ...(supportsSettlement ? [ isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, + ? { id: "unsettle", label: unsettleLabel } + : { id: "settle", label: settleLabel }, ] : []), ...(supportsSnooze ? [ isSnoozed - ? { id: "unsnooze", label: "Wake thread" } + ? { id: "unsnooze", label: wakeLabel } : { id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), + label: isMultiThreadWorktree + ? `Snooze worktree (${groupThreads.length} threads)` + : "Snooze", + disabled: !groupThreads.every((member) => + canSnooze(member, { now: menuNow }), + ), children: snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, label: `${preset.label} (${preset.whenLabel})`, @@ -2367,71 +2998,65 @@ export default function SidebarV2() { >
      {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "active" | "snoozed" | "settled", + // Shelf rows collapse a whole WORKTREE to one slim line; the + // representative thread carries title/branch/tooltip and the + // lifecycle actions expand back to the group. + const renderShelfRow = ( + group: SidebarWorktreeGroup, + section: "snoozed" | "settled", + representative: EnvironmentThreadShell, ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active"; - const rowVariant = isCard ? "card" : "slim"; + const threadKey = sidebarThreadKey(representative); return ( ); }; - const items: ReactNode[] = activeThreads.map((thread) => - renderThreadRow(thread, "active"), - ); + const items: ReactNode[] = activeGroups.map((group) => { + const newest = group.threads[group.threads.length - 1]!; + const projectLookupKey = `${newest.environmentId}:${newest.projectId}` as const; + const activeThreadKey = + routeThreadKey !== null && group.memberKeys.includes(routeThreadKey) + ? routeThreadKey + : null; + const renamingInGroup = + renamingThreadKey !== null && group.memberKeys.includes(renamingThreadKey) + ? renamingThreadKey + : null; + return ( + + ); + }); // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when // collapsed); rows only when expanded. Vanishes entirely at // count 0. - if (snoozedThreads.length > 0) { + if (snoozedGroups.length > 0) { items.push(
    • , ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } + visibleSnoozedGroups.forEach((group, index) => { + items.push(renderShelfRow(group, "snoozed", snoozedRepThreads[index]!)); + }); } - if (settledThreads.length > 0) { + if (settledGroups.length > 0) { items.push(
    - {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( + {activeGroups.length + snoozedGroups.length + settledGroups.length === 0 ? (
    {projects.length === 0 ? ( <> diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 27383b29f19..07e0ec1876b 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -29,6 +29,7 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; +import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope"; import { useRightPanelStore } from "~/rightPanelStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { @@ -301,10 +302,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - const threadRef: ScopedThreadRef = { + // Preview sessions are worktree-scoped: an automation request from any + // thread in a checkout drives the worktree's shared session via its + // canonical thread id. + const threadRef: ScopedThreadRef = resolveWorktreeCanonicalThreadRef({ environmentId, threadId: request.threadId, - }; + }); let tabId = request.tabId ?? null; try { let state = readThreadPreviewState(threadRef); @@ -312,7 +316,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (needsSessionSync) { const listTarget = { environmentId, - input: { threadId: request.threadId }, + input: { threadId: threadRef.threadId }, } as const; registry.refresh(previewEnvironment.list(listTarget)); const result = await listPreviews(listTarget); @@ -327,7 +331,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) requestId: request.requestId, operation: request.operation, environmentId, - threadId: request.threadId, + threadId: threadRef.threadId, tabId, bridgeAvailable: Boolean(previewBridge), }; @@ -365,7 +369,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const result = await open({ environmentId, input: { - threadId: request.threadId, + threadId: threadRef.threadId, ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), }, }); @@ -428,7 +432,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const result = await resize({ environmentId, input: { - threadId: request.threadId, + threadId: threadRef.threadId, tabId: ready.tabId, viewport: setting, }, @@ -444,7 +448,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { requestId: request.requestId, environmentId, - threadId: request.threadId, + threadId: threadRef.threadId, }, ); return { @@ -529,7 +533,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) new PreviewAutomationRecordingNotActiveError({ requestId: request.requestId, environmentId, - threadId: request.threadId, + threadId: threadRef.threadId, tabId, }), ); @@ -542,7 +546,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) requestId: request.requestId, operation: request.operation, environmentId, - threadId: request.threadId, + threadId: threadRef.threadId, tabId, cause, }); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 61111025fa4..aa0344e4e17 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -15,6 +15,16 @@ vi.mock("~/state/session", () => ({ readPreparedConnection: mocks.readPreparedConnection, })); +// worktreeScope pulls the full entities/server atom graph into the module +// graph; identity stubs keep this suite's narrow mocks sufficient. +vi.mock("~/worktreeScope", () => ({ + useWorktreeCanonicalThreadRef: (ref: unknown) => ref, + useWorktreeScopeKeyForThreadRef: (ref: unknown) => + ref === null ? null : JSON.stringify(ref), + resolveWorktreeCanonicalThreadRef: (ref: unknown) => ref, + resolveWorktreeScopeKeyForThreadRef: (ref: unknown) => JSON.stringify(ref), +})); + vi.mock("~/composerDraftStore", () => ({ useComposerDraftStore: ( select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown, diff --git a/apps/web/src/components/preview/closePreviewSession.ts b/apps/web/src/components/preview/closePreviewSession.ts index 5073029f6d3..b8f60052561 100644 --- a/apps/web/src/components/preview/closePreviewSession.ts +++ b/apps/web/src/components/preview/closePreviewSession.ts @@ -7,6 +7,7 @@ import type { } from "@t3tools/contracts"; import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore"; +import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope"; interface ClosePreviewSessionInput { readonly closePreview: (input: { @@ -26,9 +27,12 @@ export async function closePreviewSession( input: ClosePreviewSessionInput, ): Promise> { beginPreviewSessionClose(input.threadRef, input.tabId); + // Preview sessions are worktree-scoped: close through the worktree's + // canonical thread id, matching how the session was opened. + const canonicalRef = resolveWorktreeCanonicalThreadRef(input.threadRef); const result = await input.closePreview({ - environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, tabId: input.tabId }, + environmentId: canonicalRef.environmentId, + input: { threadId: canonicalRef.threadId, tabId: input.tabId }, }); if (result._tag === "Failure") { cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index f86ea31a187..e64a1b7bd01 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -7,6 +7,7 @@ import type { import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; +import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope"; interface OpenPreviewSessionInput { openPreview: (input: { @@ -20,10 +21,13 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, ): Promise> { + // Preview sessions are worktree-scoped: the wire call uses the worktree's + // canonical thread id so sibling threads share one set of tabs. + const canonicalRef = resolveWorktreeCanonicalThreadRef(input.threadRef); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, + environmentId: canonicalRef.environmentId, input: { - threadId: input.threadRef.threadId, + threadId: canonicalRef.threadId, ...(input.url === undefined ? {} : { url: input.url }), }, }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 312eab9eb35..fd84df994e1 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope"; import { useRightPanelStore } from "~/rightPanelStore"; const terminalLinkErrorContext = { @@ -81,9 +82,12 @@ export async function openTerminalLinkInPreview( } if (choice === "open-in-preview") { + // Preview sessions are worktree-scoped: open through the worktree's + // canonical thread id so sibling threads share one set of tabs. + const canonicalRef = resolveWorktreeCanonicalThreadRef(input.threadRef); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + environmentId: canonicalRef.environmentId, + input: { threadId: canonicalRef.threadId, url: input.url }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 9bc2cc84c37..f3f343dccc4 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -14,6 +14,7 @@ import { reconcilePreviewServerSessions, } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; +import { useWorktreeCanonicalThreadRef } from "~/worktreeScope"; class PreviewSessionThreadKeyParseError extends Schema.TaggedErrorClass()( "PreviewSessionThreadKeyParseError", @@ -116,5 +117,9 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { }); export function usePreviewSession(threadRef: ScopedThreadRef): void { - useAtomValue(previewSessionSyncAtom(scopedThreadKey(threadRef))); + // Preview sessions are worktree-scoped: every thread in a checkout syncs + // through the worktree's canonical thread id, so the same tabs stay + // attached as the user jumps between sibling threads. + const canonicalRef = useWorktreeCanonicalThreadRef(threadRef); + useAtomValue(previewSessionSyncAtom(scopedThreadKey(canonicalRef ?? threadRef))); } diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 56b5ad23fec..9de4a2683e5 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -1,9 +1,13 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +import { resolveWorktreeScopeKeyForThreadRef } from "./worktreeScope"; + +// Diff selections belong to the CHECKOUT, not the thread: every thread +// sharing a worktree sees the same diff scope and base ref. +const diffScopeKey = resolveWorktreeScopeKeyForThreadRef; export type DiffPanelSelection = | { kind: "branch"; baseRef: string | null } @@ -35,7 +39,7 @@ export const useDiffPanelStore = create()( branchBaseRefByThreadKey: {}, selectGitScope: (ref, scope) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = diffScopeKey(ref); const previous = state.byThreadKey[threadKey]; const previousBaseRef = previous?.kind === "branch" @@ -57,7 +61,7 @@ export const useDiffPanelStore = create()( }), selectBranchBaseRef: (ref, baseRef) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = diffScopeKey(ref); const normalizedBaseRef = normalizeBaseRef(baseRef); return { byThreadKey: { @@ -72,7 +76,7 @@ export const useDiffPanelStore = create()( }), selectTurn: (ref, turnId, filePath) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = diffScopeKey(ref); const previous = state.byThreadKey[threadKey]; return { byThreadKey: { @@ -88,7 +92,7 @@ export const useDiffPanelStore = create()( }), reconcileTurnSelection: (ref, availableTurnIds) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = diffScopeKey(ref); const previous = state.byThreadKey[threadKey]; const latestTurnId = availableTurnIds[0]; if ( @@ -107,7 +111,7 @@ export const useDiffPanelStore = create()( }), removeThread: (ref) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = diffScopeKey(ref); if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) { return state; } @@ -119,7 +123,16 @@ export const useDiffPanelStore = create()( }), { name: "t3code:diff-panel-state:v1", - version: 1, + // v2 re-keyed entries from thread keys to worktree scope keys; older + // thread-keyed entries can never match again, so they are dropped. + version: 2, + migrate: (persistedState, version) => + version < 2 || !persistedState || typeof persistedState !== "object" + ? { byThreadKey: {}, branchBaseRefByThreadKey: {} } + : (persistedState as { + byThreadKey: Record; + branchBaseRefByThreadKey: Record; + }), storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), @@ -138,7 +151,7 @@ export function selectThreadDiffPanelSelection( ): DiffPanelSelection { if (!ref) return DEFAULT_SELECTION; return ( - byThreadKey[scopedThreadKey(ref)] ?? + byThreadKey[diffScopeKey(ref)] ?? (hasWorkingTreeChanges ? DEFAULT_WORKING_TREE_SELECTION : DEFAULT_SELECTION) ); } diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index 014d220860d..8437ec20864 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -31,6 +31,25 @@ export function useThreadDiscoveredPorts(input: { ); } +/** Discovered dev servers owned by any of the given threads (e.g. every + thread sharing a worktree). */ +export function useDiscoveredPortsForThreads(input: { + readonly environmentId: EnvironmentId | null; + readonly threadIds: ReadonlyArray; +}): ReadonlyArray { + const ports = useDiscoveredPorts(input.environmentId); + return useMemo(() => { + if (input.threadIds.length === 0) { + return EMPTY_PORTS; + } + const threadIds = new Set(input.threadIds); + const matched = ports.filter( + (port) => port.terminal !== null && threadIds.has(port.terminal.threadId), + ); + return matched.length > 0 ? matched : EMPTY_PORTS; + }, [input.threadIds, ports]); +} + export function useTerminalDiscoveredPorts(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null; diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 34553d6f80b..ca7764b2d88 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -1,12 +1,12 @@ /** - * Per-thread preview UI state. + * Per-worktree preview UI state. * - * Each thread owns an independent atom. Most consumers read exactly one - * thread; the desktop browser host uses the aggregate session atom because it - * is the one place that must enumerate every live preview tab. + * Each worktree (checkout) owns an independent atom; callers pass a thread + * ref that resolves to its worktree scope key. Most consumers read exactly + * one worktree; the desktop browser host uses the aggregate session atom + * because it is the one place that must enumerate every live preview tab. */ import { useAtomValue } from "@effect/atom-react"; -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type DesktopPreviewColorScheme, type PreviewEvent, @@ -17,6 +17,11 @@ import { Atom } from "effect/unstable/reactivity"; import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; import { appAtomRegistry } from "./rpc/atomRegistry"; +import { resolveWorktreeScopeKeyForThreadRef } from "./worktreeScope"; + +// Preview tabs belong to the CHECKOUT, not the thread: every thread sharing +// a worktree sees the same browser tabs, active tab, and recent URLs. +const previewScopeKey = resolveWorktreeScopeKeyForThreadRef; export interface DesktopPreviewOverlay { canGoBack: boolean; @@ -97,7 +102,7 @@ function updateThreadPreviewState( ref: ScopedThreadRef, update: (current: ThreadPreviewState) => ThreadPreviewState, ): void { - const threadKey = scopedThreadKey(ref); + const threadKey = previewScopeKey(ref); const atom = previewStateAtom(threadKey); let nextState = appAtomRegistry.get(atom); const changed = appAtomRegistry.modify(atom, (current) => { @@ -148,7 +153,7 @@ const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPrevie }; export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState { - const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom; + const atom = ref ? previewStateAtom(previewScopeKey(ref)) : emptyPreviewStateAtom; return useAtomValue(atom); } @@ -157,14 +162,14 @@ export function useActivePreviewSessions(): Record { } export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState { - return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); + return appAtomRegistry.get(previewStateAtom(previewScopeKey(ref))); } export function subscribeThreadPreviewState( ref: ScopedThreadRef, listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, ): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); + const atom = previewStateAtom(previewScopeKey(ref)); let previous = appAtomRegistry.get(atom); return appAtomRegistry.subscribe(atom, (state) => { const prior = previous; @@ -403,7 +408,7 @@ export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { } export function removePreviewThread(ref: ScopedThreadRef): void { - const threadKey = scopedThreadKey(ref); + const threadKey = previewScopeKey(ref); appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); changedPreviewThreadKeys.delete(threadKey); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..5e7a2ee81c1 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -1,18 +1,24 @@ /** - * Thread-scoped right-panel surface state. + * Worktree-scoped right-panel surface state. * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/plan/files remain singleton surfaces. + * + * Callers still hand in a thread ref, but the state keys by the thread's + * WORKTREE: every thread sharing a checkout sees the same panel layout, so + * switching between sibling threads never swaps the open surfaces. */ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +import { resolveWorktreeScopeKeyForThreadRef } from "./worktreeScope"; + +const panelScopeKey = resolveWorktreeScopeKeyForThreadRef; export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -40,7 +46,7 @@ export type RightPanelSurface = | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 7; +const RIGHT_PANEL_STORAGE_VERSION = 8; export interface ThreadRightPanelState { isOpen: boolean; @@ -241,7 +247,7 @@ export const useRightPanelStore = create()( byThreadKey: {}, open: (ref, kind) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); @@ -251,7 +257,7 @@ export const useRightPanelStore = create()( })), openBrowser: (ref, tabId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const surface = browserSurface(tabId); const withoutPlaceholder = tabId ? current.surfaces.filter((entry) => entry.id !== "browser:new") @@ -261,7 +267,7 @@ export const useRightPanelStore = create()( })), openFile: (ref, relativePath, line) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); @@ -288,13 +294,13 @@ export const useRightPanelStore = create()( })), openTerminal: (ref, terminalId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => upsertSurface(current, terminalSurface(terminalId)), ), })), splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => ({ ...current, isOpen: true, activeSurfaceId: surfaceId, @@ -314,7 +320,7 @@ export const useRightPanelStore = create()( })), activateTerminal: (ref, surfaceId, terminalId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => ({ ...current, activeSurfaceId: surfaceId, surfaces: current.surfaces.map((surface) => @@ -328,7 +334,7 @@ export const useRightPanelStore = create()( })), closeTerminal: (ref, surfaceId, terminalId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const surface = current.surfaces.find( (entry) => entry.id === surfaceId && entry.kind === "terminal", ); @@ -367,7 +373,7 @@ export const useRightPanelStore = create()( })), activateSurface: (ref, surfaceId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) ? { ...current, isOpen: true, activeSurfaceId: surfaceId } : current, @@ -375,7 +381,7 @@ export const useRightPanelStore = create()( })), closeSurface: (ref, surfaceId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); @@ -393,7 +399,7 @@ export const useRightPanelStore = create()( })), closeOtherSurfaces: (ref, surfaceId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const surface = current.surfaces.find((entry) => entry.id === surfaceId); if (!surface || current.surfaces.length === 1) return current; return { @@ -406,7 +412,7 @@ export const useRightPanelStore = create()( })), closeSurfacesToRight: (ref, surfaceId) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0 || index === current.surfaces.length - 1) return current; const surfaces = current.surfaces.slice(0, index + 1); @@ -422,7 +428,7 @@ export const useRightPanelStore = create()( })), closeAllSurfaces: (ref) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => current.surfaces.length === 0 ? current : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, @@ -430,7 +436,7 @@ export const useRightPanelStore = create()( })), reconcileBrowserSurfaces: (ref, tabIds) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); const existingBrowser = current.surfaces.filter( @@ -459,7 +465,7 @@ export const useRightPanelStore = create()( })), reconcileFileSurfaces: (ref, workspaceAvailable) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { if (workspaceAvailable) return current; const surfaces = current.surfaces.filter( (surface) => surface.kind !== "files" && surface.kind !== "file", @@ -480,26 +486,26 @@ export const useRightPanelStore = create()( })), show: (ref) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => current.isOpen ? current : { ...current, isOpen: true }, ), })), close: (ref) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => current.isOpen ? { ...current, isOpen: false } : current, ), })), toggleVisibility: (ref) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => ({ ...current, isOpen: !current.isOpen, })), })), toggle: (ref, kind) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + byThreadKey: updateThread(state.byThreadKey, panelScopeKey(ref), (current) => { const active = current.surfaces.find( (surface) => surface.id === current.activeSurfaceId, ); @@ -515,7 +521,7 @@ export const useRightPanelStore = create()( })), removeThread: (ref) => set((state) => { - const threadKey = scopedThreadKey(ref); + const threadKey = panelScopeKey(ref); if (!(threadKey in state.byThreadKey)) return state; const { [threadKey]: _removed, ...rest } = state.byThreadKey; return { byThreadKey: rest }; @@ -528,7 +534,11 @@ export const useRightPanelStore = create()( resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), partialize: (state) => ({ byThreadKey: state.byThreadKey }), - migrate: migratePersistedRightPanelState, + // v8 re-keyed entries from thread keys to worktree scope keys; older + // thread-keyed entries can never match again, so they are dropped + // rather than left as unreachable garbage. + migrate: (persistedState, version) => + version < 8 ? { byThreadKey: {} } : migratePersistedRightPanelState(persistedState), }, ), ); @@ -538,7 +548,7 @@ export function selectThreadRightPanelState( ref: ScopedThreadRef | null | undefined, ): ThreadRightPanelState { if (!ref) return EMPTY_THREAD_STATE; - return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE; + return byThreadKey[panelScopeKey(ref)] ?? EMPTY_THREAD_STATE; } export function selectActiveRightPanel( diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..78147e4d92c 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,5 +1,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; @@ -89,6 +90,34 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newInWorktree") { + event.preventDefault(); + event.stopPropagation(); + // Same action as the sidebar card's "New thread on {branch}" menu + // item: the new thread joins the active thread's checkout (its git + // worktree, or its branch on the local checkout). With no active + // checkout to join, fall back to the default contextual create. + if (activeThread && (activeThread.worktreePath !== null || activeThread.branch !== null)) { + void handleNewThread( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + { + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + envMode: activeThread.worktreePath !== null ? "worktree" : "local", + startFromOrigin: false, + }, + ); + return; + } + void startNewThreadFromContext({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }); + return; + } + if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 9e480df08b2..6ab41cd50dc 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -88,3 +88,30 @@ export function useThreadRunningTerminalIds(input: { }): ReadonlyArray { return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input)); } + +/** Running-subprocess terminals across a set of threads (e.g. every thread + sharing a worktree). Terminal ids are only unique per thread, so results + are (threadId, terminalId) pairs. */ +export function useRunningTerminalsForThreads(input: { + readonly environmentId: EnvironmentId | null; + readonly threadIds: ReadonlyArray; +}): ReadonlyArray<{ readonly threadId: ThreadId; readonly terminalId: string }> { + const sessions = useKnownTerminalSessions({ + environmentId: input.environmentId, + threadId: null, + }); + return useMemo(() => { + if (input.threadIds.length === 0) { + return []; + } + const threadIds = new Set(input.threadIds); + return sessions + .filter( + (session) => threadIds.has(session.target.threadId) && session.state.hasRunningSubprocess, + ) + .map((session) => ({ + threadId: session.target.threadId, + terminalId: session.target.terminalId, + })); + }, [input.threadIds, sessions]); +} diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1f..1f2c5b512ed 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -173,7 +173,7 @@ describe("terminalUiStateStore actions", () => { ).toEqual(["env-b-terminal"]); }); - it("drops persisted entries whose thread keys are not valid scoped keys", () => { + it("drops pre-v5 persisted entries (thread-keyed state predates worktree scoping)", () => { const migrated = migratePersistedTerminalUiStateStoreState( { terminalStateByThreadKey: { @@ -185,31 +185,30 @@ describe("terminalUiStateStore actions", () => { terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], activeTerminalGroupId: "group-term-1", }, - "legacy-thread-id": { - terminalOpen: true, - terminalHeight: 320, - terminalIds: ["term-1"], - activeTerminalId: "term-1", - terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], - activeTerminalGroupId: "group-term-1", - }, }, }, 2, ); - expect(migrated).toEqual({ - terminalUiStateByThreadKey: { - [scopedThreadKey(THREAD_REF)]: { - terminalOpen: true, - terminalHeight: 320, - terminalIds: ["term-1"], - activeTerminalId: "term-1", - terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], - activeTerminalGroupId: "group-term-1", - }, - }, - }); + expect(migrated).toEqual({ terminalUiStateByThreadKey: {} }); + }); + + it("passes v5 worktree-keyed entries through unchanged", () => { + const worktreeKey = "environment-a:project-1:/tmp/worktrees/feature"; + const entry = { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["term-1"], + activeTerminalId: "term-1", + terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], + activeTerminalGroupId: "group-term-1", + }; + const migrated = migratePersistedTerminalUiStateStoreState( + { terminalUiStateByThreadKey: { [worktreeKey]: entry } }, + 5, + ); + + expect(migrated).toEqual({ terminalUiStateByThreadKey: { [worktreeKey]: entry } }); }); it("resets to default and clears persisted entry when closing the last terminal", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954..4174e459d57 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -1,11 +1,14 @@ /** - * Single Zustand store for terminal UI state keyed by scoped thread identity. + * Single Zustand store for terminal UI state keyed by WORKTREE identity. + * + * Callers pass a thread ref; the store resolves it to the thread's worktree + * scope key so every thread sharing a checkout shares one drawer layout + * (open state, height, terminal ids, splits). * * Terminal UI transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -16,6 +19,7 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; +import { resolveWorktreeScopeKeyForThreadRef } from "./worktreeScope"; interface ThreadTerminalUiState { terminalOpen: boolean; @@ -36,20 +40,18 @@ interface PersistedTerminalUiStateStoreState { export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, - _version: number, + version: number, ): PersistedTerminalUiStateStoreState { - if (!persistedState || typeof persistedState !== "object") { + // v5 re-keyed entries from thread keys to worktree scope keys; older + // thread-keyed entries can never match again, so they are dropped rather + // than left as unreachable garbage. + if (version < 5 || !persistedState || typeof persistedState !== "object") { return { terminalUiStateByThreadKey: {} }; } const candidate = persistedState as PersistedTerminalUiStateStoreState; - const persistedUiStateByThreadKey = + const terminalUiStateByThreadKey = candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; - const terminalUiStateByThreadKey = Object.fromEntries( - Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => - parseScopedThreadKey(threadKey), - ), - ); return { terminalUiStateByThreadKey }; } @@ -240,7 +242,7 @@ function isValidTerminalId(terminalId: string): boolean { } function terminalThreadKey(threadRef: ScopedThreadRef): string { - return scopedThreadKey(threadRef); + return resolveWorktreeScopeKeyForThreadRef(threadRef); } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { @@ -770,7 +772,7 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 4, + version: 5, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ diff --git a/apps/web/src/worktreeScope.ts b/apps/web/src/worktreeScope.ts new file mode 100644 index 00000000000..d287b02ce2b --- /dev/null +++ b/apps/web/src/worktreeScope.ts @@ -0,0 +1,148 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId, ScopedThreadRef } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { + readEnvironmentThreadRefs, + readThreadShell, + useThreadShell, + useThreadShells, +} from "./state/entities"; + +/** Threads without a worktree share the project's workspace-root checkout. */ +const LOCAL_CHECKOUT_SEGMENT = "local"; + +function normalizeWorktreePath(worktreePath: string | null | undefined): string | null { + const trimmed = worktreePath?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +/** Identity of the checkout a thread runs in. There is no server-side worktree + entity, so this key is composed client-side: threads with the same key share + a working directory (either a git worktree or the project workspace root). */ +export function worktreeScopeKey( + environmentId: EnvironmentId, + projectId: ProjectId, + worktreePath: string | null | undefined, +): string { + return `${environmentId}:${projectId}:${normalizeWorktreePath(worktreePath) ?? LOCAL_CHECKOUT_SEGMENT}`; +} + +export function threadWorktreeScopeKey( + shell: Pick, +): string { + return worktreeScopeKey(shell.environmentId, shell.projectId, shell.worktreePath); +} + +/** Worktree scope key for a thread ref, falling back to the plain scoped thread + key when the shell is unknown (drafts, shells not yet bootstrapped) so state + degrades to thread-scoped instead of colliding. */ +export function resolveWorktreeScopeKeyForThreadRef(ref: ScopedThreadRef): string { + const shell = readThreadShell(ref); + return shell === null ? scopedThreadKey(ref) : threadWorktreeScopeKey(shell); +} + +function compareCanonicalCandidates( + left: EnvironmentThreadShell, + right: EnvironmentThreadShell, +): number { + const leftCreatedAt = Date.parse(left.createdAt); + const rightCreatedAt = Date.parse(right.createdAt); + if (Number.isFinite(leftCreatedAt) && Number.isFinite(rightCreatedAt)) { + if (leftCreatedAt !== rightCreatedAt) { + return leftCreatedAt - rightCreatedAt; + } + } else if (Number.isFinite(leftCreatedAt)) { + return -1; + } else if (Number.isFinite(rightCreatedAt)) { + return 1; + } + return left.id.localeCompare(right.id); +} + +function selectCanonicalShell( + ref: ScopedThreadRef, + shell: EnvironmentThreadShell | null, + candidates: Iterable, +): ScopedThreadRef { + if (shell === null) { + return ref; + } + const scopeKey = threadWorktreeScopeKey(shell); + // Archived threads stay eligible on purpose: the canonical ref must not + // shift (orphaning the worktree's terminal/preview sessions) just because + // the oldest thread was archived while siblings remain. + let canonical: EnvironmentThreadShell | null = null; + for (const candidate of candidates) { + if (candidate === null || candidate.environmentId !== ref.environmentId) { + continue; + } + if (threadWorktreeScopeKey(candidate) !== scopeKey) { + continue; + } + if (canonical === null || compareCanonicalCandidates(candidate, canonical) < 0) { + canonical = candidate; + } + } + return canonical === null ? ref : scopeThreadRef(canonical.environmentId, canonical.id); +} + +/** Stable thread ref used to scope wire calls (terminal/preview RPCs) for a + worktree: the oldest thread that ever ran in the checkout. Server sessions + stay keyed by threadId, so every thread in a worktree must agree on the + thread id it uses for those calls. */ +export function resolveWorktreeCanonicalThreadRef(ref: ScopedThreadRef): ScopedThreadRef { + const shell = readThreadShell(ref); + if (shell === null) { + return ref; + } + return selectCanonicalShell( + ref, + shell, + readEnvironmentThreadRefs(ref.environmentId).map((candidateRef) => + readThreadShell(candidateRef), + ), + ); +} + +/** Canonical wire-call thread ref for every worktree scope key present in + `shells`. Bulk twin of resolveWorktreeCanonicalThreadRef for callers that + need to resolve many scope keys reactively (e.g. mounted terminal + drawers). */ +export function worktreeCanonicalThreadRefsByScopeKey( + shells: ReadonlyArray, +): Map { + const bestByKey = new Map(); + for (const shell of shells) { + const key = threadWorktreeScopeKey(shell); + const existing = bestByKey.get(key); + if (existing === undefined || compareCanonicalCandidates(shell, existing) < 0) { + bestByKey.set(key, shell); + } + } + return new Map( + [...bestByKey].map(([key, shell]) => [key, scopeThreadRef(shell.environmentId, shell.id)]), + ); +} + +/** Reactive twin of resolveWorktreeScopeKeyForThreadRef. */ +export function useWorktreeScopeKeyForThreadRef(ref: ScopedThreadRef | null): string | null { + const shell = useThreadShell(ref); + if (ref === null) { + return null; + } + return shell === null ? scopedThreadKey(ref) : threadWorktreeScopeKey(shell); +} + +/** Reactive twin of resolveWorktreeCanonicalThreadRef. */ +export function useWorktreeCanonicalThreadRef(ref: ScopedThreadRef | null): ScopedThreadRef | null { + const shell = useThreadShell(ref); + const shells = useThreadShells(); + return useMemo(() => { + if (ref === null) { + return null; + } + return selectCanonicalShell(ref, shell, shells); + }, [ref, shell, shells]); +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..93c27f1aa8e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "chat.new", "chat.newLocal", + "chat.newInWorktree", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..ba55a3bd37d 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -38,6 +38,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + // "New thread on {branch}": joins the active thread's worktree/branch + // instead of creating a fresh checkout. + { key: "mod+t", command: "chat.newInWorktree", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" },