diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..05bcabe7677 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+x"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..85ebc6b99b3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,18 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + canSettleLegacySidebarRouteThread, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, + resolveNextActiveThreadIdAfterSettle, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, + getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, isContextMenuPointerDown, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + pruneSidebarChangeRequestStates, + resolveSidebarThreadGitCwd, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, @@ -24,6 +30,9 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, + shouldDismissThreadSettleConfirmation, + shouldQuerySidebarThreadGitStatus, + shouldPublishSidebarChangeRequestState, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -41,11 +50,51 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("shouldQuerySidebarThreadGitStatus", () => { + it("queries worktree-only threads so their pull request state can be reported", () => { + expect( + shouldQuerySidebarThreadGitStatus({ + branch: null, + worktreePath: "/repo/.t3/worktrees/feature", + gitCwd: "/repo/.t3/worktrees/feature", + }), + ).toBe(true); + }); + + it("requires a cwd and either a branch or worktree", () => { + expect( + shouldQuerySidebarThreadGitStatus({ + branch: "feature", + worktreePath: null, + gitCwd: null, + }), + ).toBe(false); + expect( + shouldQuerySidebarThreadGitStatus({ + branch: null, + worktreePath: null, + gitCwd: "/repo", + }), + ).toBe(false); + }); +}); + +describe("shouldPublishSidebarChangeRequestState", () => { + it("preserves the last known state while git status is pending", () => { + expect(shouldPublishSidebarChangeRequestState(true)).toBe(false); + }); + + it("publishes the resolved state after git status settles", () => { + expect(shouldPublishSidebarChangeRequestState(false)).toBe(true); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; @@ -514,6 +563,382 @@ describe("resolveAdjacentThreadId", () => { }); }); +describe("resolveNextActiveThreadIdAfterSettle", () => { + it("wraps to the next active thread while skipping settled threads", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2", "thread-3"], + settledThreadId: "thread-1", + isActive: (threadId) => threadId === "thread-3", + }), + ).toBe("thread-3"); + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2", "thread-3"], + settledThreadId: "thread-3", + isActive: (threadId) => threadId === "thread-1", + }), + ).toBe("thread-1"); + }); + + it("returns null when settling leaves no active thread", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2"], + settledThreadId: "thread-1", + isActive: () => false, + }), + ).toBeNull(); + }); + + it("falls back to an active thread hidden outside the visible keyboard order", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-settled"], + fallbackThreadIds: ["visible-current", "preview-hidden-active", "collapsed-project-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("preview-hidden-active"); + }); + + it("prefers the next visible active thread before hidden fallbacks", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-next"], + fallbackThreadIds: ["visible-current", "hidden-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId !== "visible-current", + }), + ).toBe("visible-next"); + }); + + it("rotates the full logical order when the settled thread is hidden", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-earlier", "visible-later"], + fallbackThreadIds: [ + "visible-earlier", + "hidden-current", + "logical-next-active", + "earlier-active", + ], + settledThreadId: "hidden-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("logical-next-active"); + }); + + it("finds an active full-list target when V2's scoped order excludes the current route", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["scoped-settled"], + fallbackThreadIds: [ + "full-list-before", + "hidden-current", + "hidden-settled", + "hidden-active", + ], + settledThreadId: "hidden-current", + isActive: (threadId) => threadId === "hidden-active", + }), + ).toBe("hidden-active"); + }); + + it("rotates hidden fallbacks after exhausting visible active threads", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-settled"], + fallbackThreadIds: ["earlier-active", "visible-current", "hidden-next-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("hidden-next-active"); + }); +}); + +describe("isSidebarThreadEffectivelySettled", () => { + const now = "2026-03-15T12:00:00.000Z"; + + it("classifies inactivity and closed change requests as settled", () => { + const thread = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + isSidebarThreadEffectivelySettled({ + thread: makeThreadShell(), + settlementSupported: true, + now, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("keeps auto-settlement disabled on servers without lifecycle support", () => { + expect( + isSidebarThreadEffectivelySettled({ + thread: makeThreadShell(), + settlementSupported: false, + now, + autoSettleAfterDays: null, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("keeps auto-settled threads out of post-settle navigation targets", () => { + const threads = new Map([ + ["current", makeThreadShell({ id: ThreadId.make("current") })], + [ + "inactive", + makeThreadShell({ + id: ThreadId.make("inactive"), + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + }), + ], + ["active", makeThreadShell({ id: ThreadId.make("active") })], + ]); + + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["current", "inactive", "active"], + settledThreadId: "current", + isActive: (threadId) => { + const thread = threads.get(threadId); + return ( + thread !== undefined && + !isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }) + ); + }, + }), + ).toBe("active"); + }); +}); + +describe("canSettleLegacySidebarRouteThread", () => { + const now = "2026-03-15T12:00:00.000Z"; + + it("keeps inactive and pull-request-idle threads eligible for explicit settlement", () => { + const inactiveThread = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const pullRequestIdleThread = makeThreadShell({ + latestUserMessageAt: "2026-03-15T10:00:00.000Z", + updatedAt: "2026-03-15T10:00:00.000Z", + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread: inactiveThread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + isSidebarThreadEffectivelySettled({ + thread: pullRequestIdleThread, + settlementSupported: true, + now, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + + for (const thread of [inactiveThread, pullRequestIdleThread]) { + expect( + canSettleLegacySidebarRouteThread({ + thread, + settlementSupported: true, + now, + }), + ).toBe(true); + } + }); + + it("does not reopen settlement for an explicitly settled thread", () => { + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell({ settledOverride: "settled" }), + settlementSupported: true, + now, + }), + ).toBe(false); + }); + + it("uses the settle operation's capability and live-work guards", () => { + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell(), + settlementSupported: false, + now, + }), + ).toBe(false); + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell({ + session: { + threadId: ThreadId.make("thread-running"), + status: "running", + providerName: "Codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-running" as never, + lastError: null, + updatedAt: now, + }, + }), + settlementSupported: true, + now, + }), + ).toBe(false); + }); +}); + +describe("shouldDismissThreadSettleConfirmation", () => { + it("dismisses a confirmation when the route changes away from its target", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-2", + targetExists: true, + targetExplicitlySettled: false, + }), + ).toBe(true); + }); + + it("keeps a confirmation open while its unsettled target remains the route", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: false, + }), + ).toBe(false); + }); + + it("keeps a confirmation open when inactivity only makes the target effectively settled", () => { + const target = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + settledOverride: null, + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread: target, + settlementSupported: true, + now: "2026-03-15T12:00:00.000Z", + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: target.settledOverride === "settled", + }), + ).toBe(false); + }); + + it("dismisses a confirmation after the server explicitly settles its target", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: true, + }), + ).toBe(true); + }); +}); + +describe("getSidebarThreadKeysNeedingChangeRequestReporter", () => { + it("keeps live PR-state reporters for collapsed and preview-hidden rows", () => { + expect( + getSidebarThreadKeysNeedingChangeRequestReporter( + ["visible", "preview-hidden", "collapsed"], + new Set(["visible"]), + true, + ), + ).toEqual(["preview-hidden", "collapsed"]); + }); + + it("reports every thread when settings replaces the sidebar thread rows", () => { + expect( + getSidebarThreadKeysNeedingChangeRequestReporter( + ["would-be-visible", "preview-hidden", "collapsed"], + new Set(["would-be-visible"]), + false, + ), + ).toEqual(["would-be-visible", "preview-hidden", "collapsed"]); + }); +}); + +describe("resolveSidebarThreadGitCwd", () => { + it("uses the same fallback order for visible and hidden thread reporters", () => { + expect( + resolveSidebarThreadGitCwd({ + worktreePath: "/worktree", + threadProjectCwd: "/thread-project", + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/worktree"); + expect( + resolveSidebarThreadGitCwd({ + worktreePath: null, + threadProjectCwd: "/thread-project", + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/thread-project"); + expect( + resolveSidebarThreadGitCwd({ + worktreePath: null, + threadProjectCwd: null, + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/sidebar-project"); + }); +}); + +describe("pruneSidebarChangeRequestStates", () => { + it("drops state for removed and archived threads", () => { + expect( + pruneSidebarChangeRequestStates( + new Map([ + ["active", "open"], + ["removed", "closed"], + ["archived", "merged"], + ]), + new Set(["active"]), + ), + ).toEqual(new Map([["active", "open"]])); + }); + + it("preserves map identity when every state still belongs to a live thread", () => { + const current = new Map([["active", "open"]]); + expect(pruneSidebarChangeRequestStates(current, new Set(["active"]))).toBe(current); + }); +}); + describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( @@ -1092,6 +1517,17 @@ function makeThread(overrides: Partial = {}): Thread { }; } +function makeThreadShell(overrides: Partial = {}): SidebarThreadSummary { + return { + ...makeThread(), + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..3bbad8c92bb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,10 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; +import { + canSettle, + effectiveSettled, + type ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -45,6 +50,95 @@ type LogicalSidebarProject = SidebarProject & { export type ThreadTraversalDirection = "previous" | "next"; +export function shouldDismissThreadSettleConfirmation(input: { + readonly confirmationThreadKey: T | null; + readonly routeThreadKey: T | null; + readonly targetExists: boolean; + readonly targetExplicitlySettled: boolean; +}): boolean { + return ( + input.confirmationThreadKey !== null && + (input.confirmationThreadKey !== input.routeThreadKey || + !input.targetExists || + input.targetExplicitlySettled) + ); +} + +export function getSidebarThreadKeysNeedingChangeRequestReporter( + allThreadKeys: readonly T[], + visibleThreadKeys: ReadonlySet, + sidebarThreadRowsMounted: boolean, +): T[] { + return allThreadKeys.filter( + (threadKey) => !sidebarThreadRowsMounted || !visibleThreadKeys.has(threadKey), + ); +} + +export function resolveSidebarThreadGitCwd(input: { + readonly worktreePath: string | null; + readonly threadProjectCwd: string | null; + readonly sidebarProjectCwd: string | null; +}): string | null { + return input.worktreePath ?? input.threadProjectCwd ?? input.sidebarProjectCwd; +} + +export function shouldQuerySidebarThreadGitStatus(input: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly gitCwd: string | null; +}): boolean { + return (input.branch !== null || input.worktreePath !== null) && input.gitCwd !== null; +} + +export function shouldPublishSidebarChangeRequestState(isGitStatusPending: boolean): boolean { + return !isGitStatusPending; +} + +export function pruneSidebarChangeRequestStates( + current: ReadonlyMap, + liveThreadKeys: ReadonlySet, +): ReadonlyMap { + let next: Map | null = null; + for (const threadKey of current.keys()) { + if (liveThreadKeys.has(threadKey)) continue; + next ??= new Map(current); + next.delete(threadKey); + } + return next ?? current; +} + +export function isSidebarThreadEffectivelySettled(input: { + readonly thread: SidebarThreadSummary; + readonly settlementSupported: boolean; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; +}): boolean { + return ( + input.settlementSupported && + effectiveSettled(input.thread, { + now: input.now, + autoSettleAfterDays: input.autoSettleAfterDays, + ...(input.changeRequestState !== undefined + ? { changeRequestState: input.changeRequestState } + : {}), + }) + ); +} + +export function canSettleLegacySidebarRouteThread(input: { + readonly thread: SidebarThreadSummary | null; + readonly settlementSupported: boolean; + readonly now: string; +}): boolean { + return ( + input.thread !== null && + input.settlementSupported && + input.thread.settledOverride !== "settled" && + canSettle(input.thread, { now: input.now }) + ); +} + export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, TResult extends { readonly _tag: "Success" | "Failure" }, @@ -351,6 +445,33 @@ export function shouldNavigateAfterProjectRemoval(input: { ); } +export function resolveNextActiveThreadIdAfterSettle(input: { + threadIds: readonly T[]; + fallbackThreadIds?: readonly T[]; + settledThreadId: T; + isActive: (threadId: T) => boolean; +}): T | null { + const rotateAfterSettledThread = (threadIds: readonly T[]): readonly T[] => { + const currentIndex = threadIds.indexOf(input.settledThreadId); + return currentIndex === -1 + ? threadIds + : [...threadIds.slice(currentIndex + 1), ...threadIds.slice(0, currentIndex)]; + }; + const preferredThreadIds = input.threadIds.includes(input.settledThreadId) + ? rotateAfterSettledThread(input.threadIds) + : []; + const fallbackThreadIds = rotateAfterSettledThread(input.fallbackThreadIds ?? []); + const seen = new Set([input.settledThreadId]); + + for (const threadId of [...preferredThreadIds, ...fallbackThreadIds]) { + if (seen.has(threadId)) continue; + seen.add(threadId); + if (input.isActive(threadId)) return threadId; + } + + return null; +} + export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..91ce14af871 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,6 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { type ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -80,6 +81,7 @@ import { readThreadShell, useProject, useProjects, + useServerConfigs, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; @@ -96,11 +98,10 @@ import { } from "../uiStateStore"; import { resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; @@ -134,6 +135,15 @@ import { shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Dialog, @@ -168,19 +178,26 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + canSettleLegacySidebarRouteThread, + getSidebarThreadKeysNeedingChangeRequestReporter, getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, isContextMenuPointerDown, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, + resolveNextActiveThreadIdAfterSettle, resolveProjectStatusIndicator, + resolveSidebarThreadGitCwd, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + shouldPublishSidebarChangeRequestState, + shouldQuerySidebarThreadGitStatus, shouldClearThreadSelectionOnMouseDown, + shouldDismissThreadSettleConfirmation, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, @@ -301,6 +318,52 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +function SidebarHiddenThreadChangeRequestStateReporter(props: { + thread: SidebarThreadSummary; + projectCwd: string | null; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; +}) { + const { thread, projectCwd, onChangeRequestState } = props; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const threadProject = useProject( + useMemo( + () => scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd: threadProject?.workspaceRoot ?? null, + sidebarProjectCwd: projectCwd, + }); + const gitStatus = useEnvironmentQuery( + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const prState = + resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + })?.state ?? null; + + useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; + onChangeRequestState(threadKey, prState); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); + + return null; +} + interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -337,6 +400,7 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -363,6 +427,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + onChangeRequestState, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -406,9 +471,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ), ); const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd, + sidebarProjectCwd: props.projectCwd, + }); const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -455,6 +529,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prState = pr?.state ?? null; + useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; + onChangeRequestState(threadKey, prState); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -921,6 +1000,7 @@ interface SidebarProjectThreadListProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -961,6 +1041,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + onChangeRequestState, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -1012,6 +1093,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onChangeRequestState={onChangeRequestState} /> ); })} @@ -1062,6 +1144,7 @@ interface SidebarProjectItemProps { archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -1082,6 +1165,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec archiveThread, deleteThread, threadJumpLabelByKey, + onChangeRequestState, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2355,6 +2439,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onChangeRequestState={onChangeRequestState} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> @@ -2753,6 +2838,7 @@ interface SidebarProjectsContentProps { newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -2793,6 +2879,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, + onChangeRequestState, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2930,6 +3017,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + onChangeRequestState={onChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -2962,6 +3050,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + onChangeRequestState={onChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -2998,9 +3087,11 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const sidebarAutoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, deleteThread, settleThread } = useThreadActions(); + const serverConfigs = useServerConfigs(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3014,6 +3105,56 @@ export default function Sidebar() { [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( + null, + ); + const settleConfirmationButtonRef = useRef(null); + const [settlementNowMinute, setSettlementNowMinute] = useState(() => + new Date().toISOString().slice(0, 16), + ); + useEffect(() => { + const id = window.setInterval( + () => setSettlementNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + const settlementNow = `${settlementNowMinute}:00.000Z`; + const [changeRequestStateByThreadKey, setChangeRequestStateByThreadKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: ChangeRequestStateLike | null) => { + setChangeRequestStateByThreadKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const isThreadEffectivelySettled = useCallback( + (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true, + now: settlementNow, + autoSettleAfterDays: sidebarAutoSettleAfterDays, + changeRequestState: changeRequestStateByThreadKey.get(threadKey) ?? null, + }); + }, + [changeRequestStateByThreadKey, serverConfigs, settlementNow, sidebarAutoSettleAfterDays], + ); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3151,6 +3292,21 @@ export default function Sidebar() { } return next; }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const sidebarProjectCwdByThreadKey = useMemo( + () => + new Map( + sidebarThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const scopedProject = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + const physicalKey = projectPhysicalKeyByScopedRef.get(scopedProject) ?? scopedProject; + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + return [threadKey, sidebarProjectByKey.get(logicalKey)?.workspaceRoot ?? null] as const; + }), + ), + [physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarThreads], + ); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3384,6 +3540,38 @@ export default function Sidebar() { ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const allUnarchivedSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => + sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + [sidebarThreadSortOrder, sortedProjects, threadsByProjectKey], + ); + const visibleSidebarThreadKeySet = useMemo( + () => new Set(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const changeRequestReporterThreadKeys = useMemo( + () => + getSidebarThreadKeysNeedingChangeRequestReporter( + allUnarchivedSidebarThreadKeys, + visibleSidebarThreadKeySet, + !isOnSettings, + ), + [allUnarchivedSidebarThreadKeys, isOnSettings, visibleSidebarThreadKeySet], + ); + useEffect(() => { + const liveThreadKeys = new Set(allUnarchivedSidebarThreadKeys); + setChangeRequestStateByThreadKey((current) => { + if ([...current.keys()].every((threadKey) => liveThreadKeys.has(threadKey))) return current; + return new Map([...current].filter(([threadKey]) => liveThreadKeys.has(threadKey))); + }); + }, [allUnarchivedSidebarThreadKeys]); const prewarmedSidebarThreadKeys = useMemo( () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), [visibleSidebarThreadKeys], @@ -3396,6 +3584,64 @@ export default function Sidebar() { }), [prewarmedSidebarThreadKeys], ); + const settlingThreadKeysRef = useRef(new Set()); + + const attemptShortcutSettle = useCallback( + (threadKey: string) => { + const thread = sidebarThreadByKey.get(threadKey); + if (!thread || settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); + + const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ + threadIds: orderedSidebarThreadKeys, + fallbackThreadIds: allUnarchivedSidebarThreadKeys, + settledThreadId: threadKey, + isActive: (candidateKey) => { + const candidate = sidebarThreadByKey.get(candidateKey); + return candidate != null && !isThreadEffectivelySettled(candidate); + }, + }); + const nextThread = nextThreadKey ? sidebarThreadByKey.get(nextThreadKey) : null; + + void (async () => { + try { + const result = await settleThread(scopeThreadRef(thread.environmentId, thread.id)); + if (result._tag === "Failure") { + 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.", + }), + ); + } + return; + } + if (routeThreadKeyRef.current === threadKey) { + if (nextThread) { + navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)); + } else { + void handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); + } + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [ + allUnarchivedSidebarThreadKeys, + handleNewThread, + isThreadEffectivelySettled, + navigateToThread, + orderedSidebarThreadKeys, + settleThread, + sidebarThreadByKey, + ], + ); useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); @@ -3408,49 +3654,43 @@ export default function Sidebar() { if (event.defaultPrevented || event.repeat) { return; } + if (isCommandPaletteOpen() || isModelPickerOpen()) return; const command = resolveShortcutCommand(event, keybindings, { platform, context: shortcutContext, }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - + const routeThread = routeThreadKey ? sidebarThreadByKey.get(routeThreadKey) : null; + const action = resolveThreadSidebarShortcutAction({ + command, + orderedThreadKeys: orderedSidebarThreadKeys, + jumpThreadKeys: threadJumpThreadKeys, + routeThreadKey, + settleConfirmationThreadKey, + canSettleRouteThread: canSettleLegacySidebarRouteThread({ + thread: routeThread ?? null, + settlementSupported: + routeThread != null && + serverConfigs.get(routeThread.environmentId)?.environment.capabilities + .threadSettlement === true, + now: new Date().toISOString(), + }), + isRouteThreadSettling: + routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), + }); + if (action.type === "none") return; + if (action.type === "navigate") { + const targetThread = sidebarThreadByKey.get(action.threadKey); + if (!targetThread) return; event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - event.preventDefault(); event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + if (action.type === "consume") return; + setSettleConfirmationThreadKey(action.threadKey); }; window.addEventListener("keydown", onWindowKeyDown); @@ -3460,15 +3700,42 @@ export default function Sidebar() { }; }, [ getCurrentSidebarShortcutContext, + isThreadEffectivelySettled, keybindings, navigateToThread, orderedSidebarThreadKeys, platform, routeThreadKey, + serverConfigs, + settleConfirmationThreadKey, sidebarThreadByKey, threadJumpThreadKeys, ]); + const settleConfirmationThread = settleConfirmationThreadKey + ? (sidebarThreadByKey.get(settleConfirmationThreadKey) ?? null) + : null; + const confirmShortcutSettle = useCallback(() => { + if (!settleConfirmationThreadKey || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + setSettleConfirmationThreadKey(null); + return; + } + attemptShortcutSettle(settleConfirmationThreadKey); + }, [attemptShortcutSettle, settleConfirmationThreadKey]); + + useEffect(() => { + if ( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: settleConfirmationThreadKey, + routeThreadKey, + targetExists: settleConfirmationThread !== null, + targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", + }) + ) { + setSettleConfirmationThreadKey(null); + } + }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); + useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (!useThreadSelectionStore.getState().hasSelection()) return; @@ -3588,6 +3855,17 @@ export default function Sidebar() { return ( <> + {changeRequestReporterThreadKeys.map((threadKey) => { + const thread = sidebarThreadByKey.get(threadKey); + return thread ? ( + + ) : null; + })} {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} @@ -3624,6 +3902,7 @@ export default function Sidebar() { newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} + onChangeRequestState={handleChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3638,6 +3917,30 @@ export default function Sidebar() { )} + { + if (!open) setSettleConfirmationThreadKey(null); + }} + > + + + Settle this thread? + + {settleConfirmationThread + ? `“${settleConfirmationThread.title}” will move out of active work. You can un-settle it later.` + : "This thread will move out of active work. You can un-settle it later."} + + + + }>Cancel + + + + ); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c2995359034..9c24a6124d5 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -47,11 +47,10 @@ import { import { isElectron } from "../env"; import { resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -74,11 +73,20 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -93,17 +101,24 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, + getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, - resolveAdjacentThreadId, resolveSettledTimestamp, + pruneSidebarChangeRequestStates, + resolveNextActiveThreadIdAfterSettle, + resolveSidebarThreadGitCwd, resolveSidebarV2Status, resolveWorkingStartedAt, + shouldPublishSidebarChangeRequestState, + shouldQuerySidebarThreadGitStatus, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + shouldDismissThreadSettleConfirmation, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; @@ -136,6 +151,46 @@ import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; + +function SidebarV2HiddenThreadChangeRequestStateReporter(props: { + thread: SidebarThreadSummary; + threadProjectCwd: string | null; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { thread, threadProjectCwd, onChangeRequestState } = props; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd, + sidebarProjectCwd: null, + }); + const gitStatus = useEnvironmentQuery( + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const prState = + resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + })?.state ?? null; + + useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; + onChangeRequestState(threadKey, prState); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); + + return null; +} const SETTLED_TAIL_PAGE_COUNT = 25; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -380,9 +435,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } : null; - const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd: props.projectCwd, + sidebarProjectCwd: null, + }); const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -405,8 +469,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1165,8 +1230,24 @@ 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 isThreadEffectivelySettled = useCallback( + (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true, + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: changeRequestStateByKey.get(threadKey) ?? null, + }); + }, + [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs], + ); + const isThreadEffectivelySettledRef = useRef(isThreadEffectivelySettled); + isThreadEffectivelySettledRef.current = isThreadEffectivelySettled; const { activeThreads, settledThreads } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; const visible = threads.filter( (thread) => thread.archivedAt === null && @@ -1180,14 +1261,7 @@ export default function SidebarV2() { // 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 threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { + if (isThreadEffectivelySettled(thread)) { settled.push(thread); } else { active.push(thread); @@ -1197,14 +1271,7 @@ export default function SidebarV2() { activeThreads: sortThreadsForSidebarV2(active), settledThreads: sortSettledThreadsForSidebarV2(settled), }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - threads, - ]); + }, [isThreadEffectivelySettled, scopedProjectKeys, threads]); // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the @@ -1257,6 +1324,43 @@ export default function SidebarV2() { // rendered at click time. const orderedThreadKeysRef = useRef(orderedThreadKeys); orderedThreadKeysRef.current = orderedThreadKeys; + // Route actions are independent of the selected project chip. Keep every + // live shell in stable creation order for eligibility, confirmation, and + // post-settle fallback; rendered keyboard traversal stays scoped below. + const allUnarchivedThreads = useMemo( + () => sortThreadsForSidebarV2(threads.filter((thread) => thread.archivedAt === null)), + [threads], + ); + const allUnarchivedThreadKeys = useMemo( + () => + allUnarchivedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [allUnarchivedThreads], + ); + const allUnarchivedThreadKeySet = useMemo( + () => new Set(allUnarchivedThreadKeys), + [allUnarchivedThreadKeys], + ); + useEffect(() => { + setChangeRequestStateByKey((current) => + pruneSidebarChangeRequestStates(current, allUnarchivedThreadKeySet), + ); + }, [allUnarchivedThreadKeySet]); + const allUnarchivedThreadKeysRef = useRef(allUnarchivedThreadKeys); + allUnarchivedThreadKeysRef.current = allUnarchivedThreadKeys; + const allThreadByKey = useMemo( + () => + new Map( + allUnarchivedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [allUnarchivedThreads], + ); + const allThreadByKeyRef = useRef(allThreadByKey); + allThreadByKeyRef.current = allThreadByKey; const threadByKey = useMemo( () => new Map( @@ -1267,6 +1371,16 @@ export default function SidebarV2() { ), [orderedThreads], ); + const visibleThreadKeySet = useMemo(() => new Set(orderedThreadKeys), [orderedThreadKeys]); + const hiddenChangeRequestReporterThreadKeys = useMemo( + () => + getSidebarThreadKeysNeedingChangeRequestReporter( + allUnarchivedThreadKeys, + visibleThreadKeySet, + true, + ), + [allUnarchivedThreadKeys, visibleThreadKeySet], + ); // Handlers read these through refs: depending on per-update Map/Set // identities would give every row a fresh callback prop on each shell // event and defeat row memoization during streaming. @@ -1287,6 +1401,10 @@ export default function SidebarV2() { ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; + const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( + null, + ); + const settleConfirmationButtonRef = useRef(null); const jumpLabelByKey = useMemo(() => { const mapping = new Map(); @@ -1388,26 +1506,30 @@ export default function SidebarV2() { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); + setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); try { // Settling the thread you're looking at moves you forward: the next // remaining card (never a settled row, never one settling in the // same batch), or a fresh draft in this project when it was the // last active one. Snapshot the target before the settle mutates // the partition. Background settles never navigate. - const shell = threadByKeyRef.current.get(threadKey); + const shell = allThreadByKeyRef.current.get(threadKey); let navigateAfterSettle: (() => void) | null = null; - if (routeThreadKey === threadKey) { - const orderedKeys = orderedThreadKeysRef.current; - const settledKeys = settledThreadKeysRef.current; - const currentIndex = orderedKeys.indexOf(threadKey); - const nextCardKey = - currentIndex === -1 - ? null - : ([ - ...orderedKeys.slice(currentIndex + 1), - ...orderedKeys.slice(0, currentIndex), - ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); - const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + if (routeThreadKeyRef.current === threadKey) { + const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ + threadIds: orderedThreadKeysRef.current, + fallbackThreadIds: allUnarchivedThreadKeysRef.current, + settledThreadId: threadKey, + isActive: (candidateKey) => { + const candidate = allThreadByKeyRef.current.get(candidateKey); + return ( + candidate !== undefined && + !opts.coSettlingKeys?.has(candidateKey) && + !isThreadEffectivelySettledRef.current(candidate) + ); + }, + }); + const nextThread = nextThreadKey ? allThreadByKeyRef.current.get(nextThreadKey) : null; navigateAfterSettle = nextThread ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) : shell @@ -1442,7 +1564,7 @@ export default function SidebarV2() { } })(); }, - [navigateToThread, routeThreadKey, router, settleThread], + [navigateToThread, router, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -1696,6 +1818,7 @@ export default function SidebarV2() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; + if (isCommandPaletteOpen() || isModelPickerOpen()) return; const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { @@ -1704,41 +1827,76 @@ export default function SidebarV2() { modelPickerOpen: isModelPickerOpen(), }, }); - const navigateToThreadKey = (targetThreadKey: string | null) => { - if (!targetThreadKey) return false; - const targetThread = threadByKey.get(targetThreadKey); - if (!targetThread) return false; + const routeThread = routeThreadKey ? allThreadByKey.get(routeThreadKey) : null; + const action = resolveThreadSidebarShortcutAction({ + command, + orderedThreadKeys, + routeThreadKey, + settleConfirmationThreadKey, + canSettleRouteThread: + routeThread != null && + serverConfigs.get(routeThread.environmentId)?.environment.capabilities + .threadSettlement === true && + canSettle(routeThread, { now: new Date().toISOString() }) && + !isThreadEffectivelySettled(routeThread), + isRouteThreadSettling: + routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), + }); + if (action.type === "none") return; + if (action.type === "navigate") { + const targetThread = threadByKey.get(action.threadKey); + if (!targetThread) return; event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return true; - }; - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }), - ); return; } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) return; - navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + event.preventDefault(); + event.stopPropagation(); + if (action.type === "consume") return; + setSettleConfirmationThreadKey(action.threadKey); }; window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ + allThreadByKey, + isThreadEffectivelySettled, keybindings, navigateToThread, orderedThreadKeys, routeTerminalOpen, routeThreadKey, + serverConfigs, + settleConfirmationThreadKey, threadByKey, ]); + const settleConfirmationThread = settleConfirmationThreadKey + ? (allThreadByKey.get(settleConfirmationThreadKey) ?? null) + : null; + const confirmShortcutSettle = useCallback(() => { + if (!settleConfirmationThread || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + setSettleConfirmationThreadKey(null); + return; + } + attemptSettle( + scopeThreadRef(settleConfirmationThread.environmentId, settleConfirmationThread.id), + ); + }, [attemptSettle, settleConfirmationThread, settleConfirmationThreadKey]); + + useEffect(() => { + if ( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: settleConfirmationThreadKey, + routeThreadKey, + targetExists: settleConfirmationThread !== null, + targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", + }) + ) { + setSettleConfirmationThreadKey(null); + } + }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); + // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. @@ -1785,6 +1943,19 @@ export default function SidebarV2() { shortcutLabelForCommand(keybindings, "chat.new"); return ( <> + {hiddenChangeRequestReporterThreadKeys.map((threadKey) => { + const thread = allThreadByKey.get(threadKey); + return thread ? ( + + ) : null; + })} @@ -2231,6 +2402,30 @@ export default function SidebarV2() { + { + if (!open) setSettleConfirmationThreadKey(null); + }} + > + + + Settle this thread? + + {settleConfirmationThread + ? `“${settleConfirmationThread.title}” will move out of active work. You can un-settle it later.` + : "This thread will move out of active work. You can un-settle it later."} + + + + }>Cancel + + + + ); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..22a9aa23672 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import { type KeybindingCommand, @@ -21,6 +21,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, shortcutLabelForCommand, @@ -126,6 +127,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { + shortcut: modShortcut("x", { shiftKey: true }), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, @@ -148,6 +154,88 @@ const DEFAULT_BINDINGS = compile([ }, ]); +describe("thread settle shortcut", () => { + it("resolves Mod+Shift+X outside the terminal", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "x", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "thread.settle", + ); + }); + + it("does not resolve while the terminal owns focus", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "x", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); + + it("routes an eligible settle command to the shared confirmation flow", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "confirm-settle", threadKey: "thread-1" }); + }); + + it("consumes settle when live work makes the route thread ineligible", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: false, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + }); + + it("consumes thread navigation while settle confirmation is open", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.next", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: "thread-1", + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.jump.2", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: "thread-1", + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + }); + + it("consumes settle without reopening confirmation while the route thread is settling", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: true, + isRouteThreadSettling: true, + }), + ).toEqual({ type: "consume" }); + }); +}); + describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { assert.isTrue( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a7780..3d1876e5340 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -282,6 +282,74 @@ export function threadTraversalDirectionFromCommand( return null; } +export type ThreadSidebarShortcutAction = + | { readonly type: "none" } + | { readonly type: "consume" } + | { readonly type: "navigate"; readonly threadKey: TThreadKey } + | { readonly type: "confirm-settle"; readonly threadKey: TThreadKey }; + +/** + * Resolves the thread commands shared by both sidebar implementations. + * A settle confirmation is modal with respect to thread shortcuts: navigation + * must not change the route behind it, and a second settle shortcut must not + * replace or reopen it. + */ +export function resolveThreadSidebarShortcutAction(input: { + readonly command: KeybindingCommand | null; + readonly orderedThreadKeys: readonly TThreadKey[]; + readonly jumpThreadKeys?: readonly TThreadKey[]; + readonly routeThreadKey: TThreadKey | null; + readonly settleConfirmationThreadKey: TThreadKey | null; + readonly canSettleRouteThread: boolean; + readonly isRouteThreadSettling: boolean; +}): ThreadSidebarShortcutAction { + const traversalDirection = threadTraversalDirectionFromCommand(input.command); + const jumpIndex = threadJumpIndexFromCommand(input.command ?? ""); + const isThreadCommand = + input.command === "thread.settle" || traversalDirection !== null || jumpIndex !== null; + + if (!isThreadCommand) return { type: "none" }; + if (input.settleConfirmationThreadKey !== null) return { type: "consume" }; + + if (input.command === "thread.settle") { + if ( + input.routeThreadKey === null || + !input.canSettleRouteThread || + input.isRouteThreadSettling + ) { + return { type: "consume" }; + } + return { type: "confirm-settle", threadKey: input.routeThreadKey }; + } + + if (traversalDirection !== null) { + const threadKey = resolveAdjacentItem({ + items: input.orderedThreadKeys, + current: input.routeThreadKey, + direction: traversalDirection, + }); + return threadKey === null ? { type: "none" } : { type: "navigate", threadKey }; + } + + const threadKey = (input.jumpThreadKeys ?? input.orderedThreadKeys)[jumpIndex ?? -1]; + return threadKey === undefined ? { type: "none" } : { type: "navigate", threadKey }; +} + +function resolveAdjacentItem(input: { + readonly items: readonly T[]; + readonly current: T | null; + readonly direction: "previous" | "next"; +}): T | null { + if (input.items.length === 0) return null; + if (input.current === null) { + return input.direction === "previous" ? (input.items.at(-1) ?? null) : (input.items[0] ?? null); + } + const currentIndex = input.items.indexOf(input.current); + if (currentIndex === -1) return null; + const targetIndex = input.direction === "previous" ? currentIndex - 1 : currentIndex + 1; + return input.items[targetIndex] ?? null; +} + export function shouldShowThreadJumpHints( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a0..ca252dc134a 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -33,7 +33,8 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "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" }, - { "key": "mod+o", "command": "editor.openFavorite" } + { "key": "mod+o", "command": "editor.openFavorite" }, + { "key": "mod+shift+x", "command": "thread.settle", "when": "!terminalFocus" } ] ``` @@ -67,6 +68,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor +- `thread.settle`: confirm and settle the open thread, then move to the next active thread - `script.{id}.run`: run a project script by id (for example `script.test.run`) ### Key Syntax diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..cb921e55ec5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; export const THREAD_KEYBINDING_COMMANDS = [ + "thread.settle", "thread.previous", "thread.next", ...THREAD_JUMP_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..0e6afc876cd 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+x", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({