From eebafbb2a0f0d252a9ad37d03aa685542498f908 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 25 Jul 2026 09:51:48 -0400 Subject: [PATCH] feat(web): group sidebar v2 by worktree and scope panes to the checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar v2 cards now represent a worktree rather than a thread. Threads sharing a checkout collapse into one card, where each thread's summary is its own clickable row carrying that thread's provider logo; the branch, PR badge and repository row stay at the worktree level. Settle and snooze (and un-settle/wake) act on every thread in the worktree, and the v1 activity indicators — pulsing terminal and dev-server globe — return on the card's top row, where they belong now that terminals and dev servers are worktree-scoped. The right-panel surfaces (terminal, browser, diff, files) follow the same rule: switching between sibling threads keeps the same terminal session, dev server, browser tabs and diff state, and only changes when the checkout does. There is no worktree entity in the data model, so identity is composed client-side in worktreeScope.ts (environment + project + worktreePath, with null paths meaning the project's local checkout). Server terminal and preview sessions remain keyed by threadId on the wire, so every thread in a worktree routes those calls through a stable canonical thread id. Also adds chat.newInWorktree (default mod+t): the keyboard equivalent of a card's "New thread on {branch}" action. Persisted panel state is re-keyed, so right-panel (v8), terminal UI (v5) and diff-panel (v2) drop their old thread-keyed entries on migrate. Server-side, archiving a thread no longer closes terminals that sibling threads in the same checkout still use — that would kill a shared dev server. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.test.ts | 69 + apps/server/src/ws.ts | 66 +- apps/web/src/browser/openFileInPreview.ts | 8 +- apps/web/src/components/ChatView.tsx | 163 +- .../src/components/SidebarV2.logic.test.ts | 264 +++ apps/web/src/components/SidebarV2.logic.ts | 243 +++ apps/web/src/components/SidebarV2.tsx | 1708 ++++++++++++----- .../preview/PreviewAutomationHosts.tsx | 22 +- .../components/preview/PreviewView.test.tsx | 10 + .../components/preview/closePreviewSession.ts | 8 +- .../components/preview/openPreviewSession.ts | 8 +- .../preview/openTerminalLinkInPreview.ts | 8 +- .../components/preview/usePreviewSession.ts | 7 +- apps/web/src/diffPanelStore.ts | 29 +- apps/web/src/portDiscoveryState.ts | 19 + apps/web/src/previewStateStore.ts | 25 +- apps/web/src/rightPanelStore.ts | 58 +- apps/web/src/routes/_chat.tsx | 29 + apps/web/src/state/terminalSessions.ts | 27 + apps/web/src/terminalUiStateStore.test.ts | 41 +- apps/web/src/terminalUiStateStore.ts | 26 +- apps/web/src/worktreeScope.ts | 148 ++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 3 + 24 files changed, 2299 insertions(+), 691 deletions(-) create mode 100644 apps/web/src/components/SidebarV2.logic.test.ts create mode 100644 apps/web/src/components/SidebarV2.logic.ts create mode 100644 apps/web/src/worktreeScope.ts 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" },