From 08abe2d19306b43252278e990263f2e2662f7fff Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 15:20:44 -0700 Subject: [PATCH 01/21] fix(client-runtime): keep a warm thread un-settled despite a merged/closed PR (#4309) Co-authored-by: Claude Fable 5 (cherry picked from commit 193e3c62e6d52fcc200d823ca6d9061d13f81f23) --- .../src/state/threadSettled.test.ts | 44 +++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 40 ++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 59f9f1d6f55..421d95c6b13 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -163,6 +163,50 @@ describe("effectiveSettled", () => { ).toBe(true); }); + it("does not re-settle a warm thread on the merge signal: a message sent in a settled thread keeps it active until idle", () => { + // The merge signal never clears, so without the idle guard a follow-up + // message would un-settle the row only until its turn completed, then + // snap straight back into the settled tail. + const justActive = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" }); + // The idle gate is strict: activity exactly one hour old is still warm. + const boundary = makeShell({ activityAt: "2026-04-09T23:00:00.000Z" }); + const idle = makeShell({ activityAt: "2026-04-09T22:59:59.999Z" }); + + for (const changeRequestState of ["merged", "closed"] as const) { + expect( + effectiveSettled(justActive, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + }), + ).toBe(false); + expect( + effectiveSettled(boundary, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + }), + ).toBe(false); + expect( + effectiveSettled(idle, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState, + }), + ).toBe(true); + } + }); + + it("re-settles a merged-PR thread once the follow-up burst goes idle", () => { + // Same shell, advancing clock: active while warm, settled again after + // the idle window passes โ€” the burst cools and the merge signal wins. + const shell = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" }); + const options = { autoSettleAfterDays: null, changeRequestState: "merged" as const }; + + expect(effectiveSettled(shell, { ...options, now: NOW })).toBe(false); + expect(effectiveSettled(shell, { ...options, now: "2026-04-10T00:30:00.001Z" })).toBe(true); + }); + it("never settles a starting session, even with a settled override", () => { const shell = makeShell({ settledOverride: "settled", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 3cb4f7e65f7..85d1649c267 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -91,12 +91,29 @@ export function canSettle( } /** - * Settled resolution over the server-backed settled lifecycle. The explicit - * user override (thread.settle / thread.unsettle commands, projected into - * settledOverride + settledAt) wins in both directions; without one, a - * thread auto-settles on a merged/closed PR or inactivity past the window. - * The server un-settles on real activity (user message, session start, - * approval/user-input request), so an override never goes stale silently. + * A merged/closed change request settles its thread only once the thread has + * been idle this long. Without the idle guard the merge signal is permanent: + * sending a message to a merged-PR thread would un-settle the row only until + * its turn completed, then the still-merged PR would snap it straight back + * into the settled tail. An hour keeps the follow-up conversation visible + * while it is warm; once the burst goes stale the merge signal settles it + * again. Activity timestamps can originate on another device while `now` is + * this caller's clock: skew shortens or stretches the window by its size, + * the same exposure the inactivity auto-settle already accepts โ€” worst case + * is a row changing lists early or late, never lost work. + */ +export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000; + +/** + * Settled resolution over the server-backed settled lifecycle. Activity + * blockers (pending approval/user-input, a live session, an unadjudicated + * queued turn) are checked first and hold a thread active regardless of any + * override. Past the blockers, the explicit user override (thread.settle / + * thread.unsettle commands, projected into settledOverride + settledAt) + * wins in both directions; without one, a thread auto-settles on a + * merged/closed PR (once idle) or inactivity past the window. The server + * un-settles on real activity (user message, session start, approval/ + * user-input request), so an override never goes stale silently. */ export function effectiveSettled( shell: OrchestrationThreadShell, @@ -130,7 +147,16 @@ export function effectiveSettled( // until real activity clears it server-side. if (shell.settledOverride === "active") return false; if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { - return true; + // Only an idle thread settles on the merge signal: the signal itself + // never clears, so without this guard fresh activity (a message sent in + // a settled thread) would re-settle the moment its turn completed. + const lastActivityAt = threadLastActivityAt(shell); + if ( + lastActivityAt === null || + Date.parse(lastActivityAt) < Date.parse(options.now) - CHANGE_REQUEST_SETTLE_IDLE_MS + ) { + return true; + } } if (options.autoSettleAfterDays === null) return false; From 08d96c6efc7bc1815eb160478390e351b183c45a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 24 Jul 2026 00:32:14 +0200 Subject: [PATCH 02/21] Fix composer context strip alignment and glass shell (#4404) (cherry picked from commit 6ef7aa8399640fd2bf10fcb126c0db93fee47ff0) --- apps/web/src/components/BranchToolbar.tsx | 2 +- .../BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/ChatView.tsx | 234 +++++++++--------- apps/web/src/index.css | 109 ++++++-- 4 files changed, 216 insertions(+), 131 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index ffab9cbdf86..bee8478a4b7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -248,7 +248,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 671098c4345..9723e30e049 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2352,6 +2352,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const showComposerContextStrip = isGitRepo && activeProject !== null; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -5701,119 +5702,128 @@ function ChatViewContent(props: ChatViewProps) { : undefined } > -
-
- +
+
+
+ +
-
-
-
- {isGitRepo && ( -
- -
- )} +
+
+ {showComposerContextStrip && ( +
+ +
+ )} +
Date: Fri, 24 Jul 2026 00:39:09 +0200 Subject: [PATCH 03/21] Polish iOS git progress overlay with glass effects (#4387) (cherry picked from commit ce467da9cfae3d7952ce13a9e2450e2908e9f224) --- .../threads/GitActionProgressOverlay.tsx | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 4c99c2c866c..2b257ec175c 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,8 +1,9 @@ import * as Haptics from "expo-haptics"; +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; @@ -10,6 +11,9 @@ import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; +const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); +const AnimatedLiquidGlassView = Animated.createAnimatedComponent(LiquidGlassView); + export function GitActionProgressOverlay(props: { readonly progress: GitActionProgress; readonly onDismiss: () => void; @@ -45,7 +49,7 @@ export function GitActionProgressOverlay(props: { return ( + const glassBorder = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); + const isDarkMode = useColorScheme() === "dark"; + const content = ( + <> @@ -89,7 +88,56 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.prUrl ? ( ) : null} - + + ); + + if (isLiquidGlassSupported) { + return ( + + + + {content} + + + + ); + } + + const bgClass = + progress.phase === "error" + ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + : "bg-card border-border"; + + return ( + + {content} + ); } From 083e9cfa4001ec0391f3f39c9997a2e79cfab148 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 24 Jul 2026 00:41:10 +0200 Subject: [PATCH 04/21] Improve composer glass fallbacks (#4406) Co-authored-by: codex (cherry picked from commit 67a7b1a1d3443beeedc6a3bd7fe9dd875f016b11) --- apps/web/src/index.css | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fefa86c741b..619a3e101c3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -453,6 +453,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil inset: 0; border: 1px solid var(--chat-composer-outline); border-radius: inherit; + content: ""; + } + + .chat-composer-glass-shell-with-context .chat-composer-glass-host::after { /* Keep the attachment seam open; the strip continues the outer outline below. */ clip-path: polygon( 0 0, @@ -464,7 +468,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil 22px 100%, 0 100% ); - content: ""; } .chat-composer-context-strip { @@ -512,6 +515,36 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); } + @supports not (clip-path: shape(from 0 0, line to 1px 1px)) { + .chat-composer-glass-shell-with-context::before { + inset-block-end: var(--chat-composer-context-extension); + border-radius: 22px; + clip-path: none; + } + + .chat-composer-context-strip::before { + background: color-mix( + in srgb, + var(--chat-composer-glass-surface) var(--glass-opacity), + transparent + ); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + } + + .dark .chat-composer-context-strip::before { + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), + color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + } + } + .alert-glass { --alert-glass-tint: transparent; From eed8c325bb2460a9edfbf92d173d67e63b39ed7e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:06:04 -0700 Subject: [PATCH 05/21] feat(web): collapse large git diffs by default to make chat more readable (#4409) (cherry picked from commit 51672b6ecb9d927d57d0b12dd071ca0d4a34ef36) --- .../components/chat/ChangedFilesTree.test.tsx | 71 ++++++- .../src/components/chat/ChangedFilesTree.tsx | 189 +++++++++++++----- .../web/src/components/chat/DiffStatLabel.tsx | 10 +- .../components/chat/MessagesTimeline.test.tsx | 12 +- .../src/components/chat/MessagesTimeline.tsx | 107 +++------- .../chat/changedFilesPresentation.test.ts | 69 +++++++ .../chat/changedFilesPresentation.ts | 102 ++++++++++ apps/web/src/uiStateStore.test.ts | 29 ++- apps/web/src/uiStateStore.ts | 54 ++--- 9 files changed, 455 insertions(+), 188 deletions(-) create mode 100644 apps/web/src/components/chat/changedFilesPresentation.test.ts create mode 100644 apps/web/src/components/chat/changedFilesPresentation.ts diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 120827b34b2..ec5875682fc 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => { {}} onToggleAllDirectories={() => {}} onOpenTurnDiff={() => {}} />, ); - expect(markup).toContain('class="sticky top-0 z-10'); - expect(markup).not.toContain("self-start"); + expect(markup).toContain('data-changed-files-state="expanded"'); + expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); - expect(markup).toContain('aria-label="Collapse all"'); - expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('aria-label="Open diff"'); + expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); }); + + it("renders a scope and representative-file preview for a large latest change", () => { + const markup = renderToStaticMarkup( + {}} + onToggleAllDirectories={() => {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('data-changed-files-state="preview"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("apps"); + expect(markup).toContain("2 files"); + expect(markup).toContain("packages"); + expect(markup).toContain("root"); + expect(markup).toContain("App.tsx"); + expect(markup).toContain("git.ts"); + expect(markup).toContain("README.md"); + expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("App.test.tsx"); + }); + + it("keeps older collapsed changes to a one-line receipt", () => { + const markup = renderToStaticMarkup( + {}} + onToggleAllDirectories={() => {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('data-changed-files-state="collapsed"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("Show all"); + expect(markup).not.toContain("App.tsx"); + }); }); describe("ChangedFilesTree", () => { diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 2a357f9a47d..3a705eef36d 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + changedFileName, + selectChangedFilePreview, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; + expanded: boolean; + showCompactPreview: boolean; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; + onExpandedChange: (expanded: boolean) => void; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const { turnId, files, + expanded, + showCompactPreview, allDirectoriesExpanded, resolvedTheme, + onExpandedChange, onToggleAllDirectories, onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); + const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); + const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); + const compactPreviewVisible = showCompactPreview && !expanded; return ( -
-
-

- - {files.length} changed file{files.length === 1 ? "" : "s"} +

+
+
+ {expanded ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnId, files[0]?.path)} /> } > + Open diff - View diff + Open the full diff
- + {expanded ? ( + + ) : compactPreviewVisible ? ( +
+

+ {scopeSummary.map((scope, index) => ( + + {index > 0 ? : null} + {scope.label} + + {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} + + + ))} +

+
+ {previewFiles.map((file) => ( + + ))} + +
+
+ ) : null}
); }); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 8fd7e90e381..6787a8df196 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { <> {showParentheses && (} - +{formatCompactDiffCount(additions)} - -{formatCompactDiffCount(deletions)} + + {showParentheses && )} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c0b41527fa5..296a54457a2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).toContain("sticky top-2 z-10"); expect(markup).not.toContain("self-start"); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); - expect(markup).toContain('aria-label="Collapse all"'); - expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain("1 changed file"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 225d2a53177..ae190e00068 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -32,7 +32,6 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, resolveDiffThemeName, @@ -44,11 +43,8 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, - FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -65,8 +61,8 @@ import { import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; -import { ChangedFilesTree } from "./ChangedFilesTree"; -import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import { ChangedFilesCard } from "./ChangedFilesTree"; +import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -145,6 +141,7 @@ interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; + latestTurnId: TurnId | null; } const TimelineRowCtx = createContext(null!); @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, isRevertingCheckpoint, activeTurnInProgress, + latestTurnId: latestTurn?.turnId ?? null, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], ); // Stable renderItem โ€” no closure deps. Row components read shared state @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + const activity = use(TimelineRowActivityCtx); + const isLatestTurn = activity.latestTurnId === turnSummary.turnId; + const persistedExpanded = useUiStateStore( + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); - const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const [autoExpanded] = useState(() => + shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), + ); + const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); + const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); return ( -
-
-

- - {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} - - {hasNonZeroStat(summaryStat) && ( - - )} -

-
- - - setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) - } - /> - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} - /> - } - > - - - View diff - -
-
- -
+ + setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) + } + onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} + onOpenTurnDiff={onOpenTurnDiff} + /> ); } diff --git a/apps/web/src/components/chat/changedFilesPresentation.test.ts b/apps/web/src/components/chat/changedFilesPresentation.test.ts new file mode 100644 index 00000000000..d13445b0226 --- /dev/null +++ b/apps/web/src/components/chat/changedFilesPresentation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + changedFileName, + selectChangedFilePreview, + shouldAutoExpandChangedFiles, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; + +describe("changed-files presentation", () => { + it("auto-expands only small, low-churn latest changes", () => { + const smallFiles = [ + { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 }, + { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 }, + ]; + + expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true); + expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false); + expect( + shouldAutoExpandChangedFiles( + [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }], + true, + ), + ).toBe(false); + expect( + shouldAutoExpandChangedFiles( + Array.from({ length: 6 }, (_, index) => ({ + path: `src/${index}.ts`, + kind: "modified", + additions: 1, + deletions: 0, + })), + true, + ), + ).toBe(false); + }); + + it("summarizes the most prominent top-level scopes", () => { + const files = [ + { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 }, + ]; + + expect(summarizeChangedFileScopes(files)).toEqual([ + { label: "apps", fileCount: 3 }, + { label: "root", fileCount: 1 }, + { label: "packages", fileCount: 1 }, + ]); + }); + + it("previews files across different scopes before filling from one scope", () => { + const files = [ + { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + ]; + + expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([ + "apps/web/src/App.tsx", + "packages/shared/src/git.ts", + "README.md", + ]); + expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx"); + }); +}); diff --git a/apps/web/src/components/chat/changedFilesPresentation.ts b/apps/web/src/components/chat/changedFilesPresentation.ts new file mode 100644 index 00000000000..bb3cac6c4b1 --- /dev/null +++ b/apps/web/src/components/chat/changedFilesPresentation.ts @@ -0,0 +1,102 @@ +import { type TurnDiffFileChange } from "../../types"; +import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; + +export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5; +export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200; +export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3; +export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4; + +export interface ChangedFilesScopeSummary { + readonly label: string; + readonly fileCount: number; +} + +function pathSegments(pathValue: string): string[] { + return pathValue + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); +} + +export function changedFileName(pathValue: string): string { + return pathSegments(pathValue).at(-1) ?? pathValue; +} + +function changedFileScope(pathValue: string): string { + const segments = pathSegments(pathValue); + return segments.length > 1 ? (segments[0] ?? "root") : "root"; +} + +export function shouldAutoExpandChangedFiles( + files: ReadonlyArray, + isLatestTurn: boolean, +): boolean { + if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) { + return false; + } + const stat = summarizeTurnDiffStats(files); + return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT; +} + +export function summarizeChangedFileScopes( + files: ReadonlyArray, + limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT, +): ChangedFilesScopeSummary[] { + const scopes = new Map(); + files.forEach((file, index) => { + const label = changedFileScope(file.path); + const current = scopes.get(label); + scopes.set(label, { + fileCount: (current?.fileCount ?? 0) + 1, + firstIndex: current?.firstIndex ?? index, + }); + }); + + return Array.from(scopes, ([label, scope]) => ({ + label, + fileCount: scope.fileCount, + firstIndex: scope.firstIndex, + })) + .toSorted( + (left, right) => + right.fileCount - left.fileCount || + left.firstIndex - right.firstIndex || + left.label.localeCompare(right.label), + ) + .slice(0, limit) + .map(({ label, fileCount }) => ({ label, fileCount })); +} + +export function selectChangedFilePreview( + files: ReadonlyArray, + limit = CHANGED_FILES_PREVIEW_FILE_LIMIT, +): TurnDiffFileChange[] { + const selected: TurnDiffFileChange[] = []; + const selectedPaths = new Set(); + const selectedScopes = new Set(); + + for (const file of files) { + const scope = changedFileScope(file.path); + if (selectedScopes.has(scope)) { + continue; + } + selected.push(file); + selectedPaths.add(file.path); + selectedScopes.add(scope); + if (selected.length === limit) { + return selected; + } + } + + for (const file of files) { + if (selectedPaths.has(file.path)) { + continue; + } + selected.push(file); + if (selected.length === limit) { + break; + } + } + + return selected; +} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..30450287353 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => { ); }); - it("stores only collapsed changed-file turns", () => { + it("stores explicit changed-file expansion choices", () => { const threadId = ThreadId.make("thread-1"); const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); @@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => { expect( setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) .threadChangedFilesExpandedById, - ).toEqual({}); + ).toEqual({ + [threadId]: { + "turn-1": true, + }, + }); }); it("stores the endpoint preference by stable key", () => { @@ -155,6 +159,7 @@ describe("parsePersistedState", () => { invalid: "not-a-date", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -172,12 +177,25 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + "turn-2": true, + }, + }, + }); + }); + + it("ignores changed-file expansion values saved with legacy folder semantics", () => { + const parsed = parsePersistedState({ threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, }, }, }); + + expect(parsed.threadChangedFilesExpandedById).toEqual({}); }); it("migrates legacy CWD project preferences into local alias keys", () => { @@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, + "turn-2": true, }, }, }); expect(parsePersistedState(persisted)).toEqual({ ...state, - threadChangedFilesExpandedById: { - "environment:thread-1": { - "turn-1": false, - }, - }, }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..5d744d540a5 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", @@ -24,6 +25,7 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; + threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; threadChangedFilesExpandedById?: Record>; } @@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { projectExpandedById, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), - threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( - parsed.threadChangedFilesExpandedById, - ), + threadChangedFilesExpandedById: + parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION + ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) + : {}, defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && parsed.defaultAdvertisedEndpointKey.length > 0 @@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded( const nextTurns: Record = {}; for (const [turnId, expanded] of Object.entries(turns)) { - if (turnId && typeof expanded === "boolean" && expanded === false) { - nextTurns[turnId] = false; + if (turnId && typeof expanded === "boolean") { + nextTurns[turnId] = expanded; } } @@ -195,14 +198,6 @@ export function persistState(state: UiState): void { ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, ), ); - const threadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { - const nextTurns = Object.fromEntries( - Object.entries(turns).filter(([, expanded]) => expanded === false), - ); - return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; - }), - ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ @@ -210,7 +205,8 @@ export function persistState(state: UiState): void { projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, - threadChangedFilesExpandedById, + threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, + threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded( expanded: boolean, ): UiState { const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; - const currentExpanded = currentThreadState[turnId] ?? true; - if (currentExpanded === expanded) { + if (currentThreadState[turnId] === expanded) { return state; } - if (expanded) { - if (!(turnId in currentThreadState)) { - return state; - } - - const nextThreadState = { ...currentThreadState }; - delete nextThreadState[turnId]; - if (Object.keys(nextThreadState).length === 0) { - const nextState = { ...state.threadChangedFilesExpandedById }; - delete nextState[threadId]; - return { - ...state, - threadChangedFilesExpandedById: nextState, - }; - } - - return { - ...state, - threadChangedFilesExpandedById: { - ...state.threadChangedFilesExpandedById, - [threadId]: nextThreadState, - }, - }; - } - return { ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, [threadId]: { ...currentThreadState, - [turnId]: false, + [turnId]: expanded, }, }, }; From bbbaca633f654dc52ed9dade91a93496d1406fd8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:06:21 -0700 Subject: [PATCH 06/21] Stop new threads inheriting checkout/branch from viewed thread (#4411) Co-authored-by: Claude Fable 5 (cherry picked from commit 2f41c073a2f846e9a96082e76cd1b2c3a7e44c18) --- .../components/BranchToolbar.logic.test.ts | 87 ++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 48 ++++++++ apps/web/src/components/BranchToolbar.tsx | 66 ++++++++++- .../BranchToolbarEnvModeSelector.tsx | 30 ++++- apps/web/src/components/CommandPalette.tsx | 24 +--- apps/web/src/components/Sidebar.logic.test.ts | 108 ------------------ apps/web/src/components/Sidebar.logic.ts | 57 --------- apps/web/src/components/Sidebar.tsx | 88 ++++++-------- apps/web/src/components/SidebarV2.tsx | 31 +++++ apps/web/src/composerDraftStore.ts | 17 ++- apps/web/src/hooks/useHandleNewThread.ts | 108 ++++++++++++++++-- apps/web/src/lib/chatThreadActions.test.ts | 81 ++----------- apps/web/src/lib/chatThreadActions.ts | 58 ++-------- apps/web/src/routes/_chat.tsx | 7 +- 14 files changed, 425 insertions(+), 385 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cbb9ec82b88..c4ae7b96694 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,8 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -19,6 +21,91 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +describe("resolvePreviousWorktreeSeed", () => { + it("picks the most recently updated worktree thread", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/older", + worktreePath: "/repo/.t3/worktrees/older", + updatedAt: "2026-07-20T00:00:00.000Z", + }, + { + branch: "t3/newer", + worktreePath: "/repo/.t3/worktrees/newer", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); + }); + + it("skips the worktree the composer already points at", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/current", + worktreePath: "/repo/.t3/worktrees/current", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + ], + currentWorktreePath: "/repo/.t3/worktrees/current", + }), + ).toBeNull(); + }); + + it("returns null when no thread has a worktree", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], + currentWorktreePath: null, + }), + ).toBeNull(); + }); + + it("ignores archived threads and threads with unparseable timestamps", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/archived", + worktreePath: "/repo/.t3/worktrees/archived", + updatedAt: "2026-07-23T00:00:00.000Z", + archivedAt: "2026-07-23T01:00:00.000Z", + }, + { + branch: "t3/garbage-timestamp", + worktreePath: "/repo/.t3/worktrees/garbage", + updatedAt: "not-a-date", + }, + { + branch: "t3/live", + worktreePath: "/repo/.t3/worktrees/live", + updatedAt: "2026-07-21T00:00:00.000Z", + archivedAt: null, + }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); + }); +}); + +describe("resolvePreviousWorktreeLabel", () => { + it("includes the branch when known", () => { + expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( + "Previous worktree (t3/fix-thing)", + ); + expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( + "Previous worktree", + ); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c083c335292..8fe35fa464a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } +export interface PreviousWorktreeSeed { + branch: string | null; + worktreePath: string; +} + +// The most recently touched worktree in the project that the composer isn't +// already pointing at. Backs the "Previous worktree" entry in the workspace +// selector so a follow-up thread can hop back into the worktree you just +// worked in without hunting for its branch. Archived threads don't compete โ€” +// the rest of the UI hides them, so their worktrees shouldn't resurface here. +export function resolvePreviousWorktreeSeed(input: { + threads: ReadonlyArray<{ + branch: string | null; + worktreePath: string | null; + updatedAt: string; + archivedAt?: string | null; + }>; + currentWorktreePath: string | null; +}): PreviousWorktreeSeed | null { + let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; + for (const thread of input.threads) { + if ( + !thread.worktreePath || + thread.worktreePath === input.currentWorktreePath || + (thread.archivedAt ?? null) !== null + ) { + continue; + } + const updatedAt = toSortableTimestamp(thread.updatedAt); + if (updatedAt === null) { + continue; + } + if (latest === null || updatedAt > latest.updatedAt) { + latest = { + branch: thread.branch, + worktreePath: thread.worktreePath, + updatedAt, + }; + } + } + return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; +} + +export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { + return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index bee8478a4b7..e703427d4b6 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,12 +6,13 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, + HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, @@ -20,6 +21,8 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel: string | null; + onUsePreviousWorktree: () => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace onEnvModeChange(value as EnvMode)} + onValueChange={(value) => { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} > @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {previousWorktreeLabel ? ( + + + + {previousWorktreeLabel} + + + ) : null} @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + // "Previous worktree" hops a draft into the most recently active worktree + // of this project โ€” the "keep going where I just was" follow-up flow. Only + // drafts can hop; started server threads have their workspace pinned. + const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; + const projectRefsForWorktreeLookup = useMemo( + () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), + [canUsePreviousWorktree, activeProjectRef], + ); + const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); + const previousWorktreeSeed = useMemo( + () => + canUsePreviousWorktree + ? resolvePreviousWorktreeSeed({ + threads: projectThreads, + currentWorktreePath: activeWorktreePath, + }) + : null, + [activeWorktreePath, canUsePreviousWorktree, projectThreads], + ); + const previousWorktreeLabel = previousWorktreeSeed + ? resolvePreviousWorktreeLabel(previousWorktreeSeed) + : null; + const onUsePreviousWorktree = useCallback(() => { + if (!previousWorktreeSeed || !activeProjectRef) return; + // Same shape the branch selector writes when picking a branch that + // already lives in a worktree: point the draft at the existing tree. + setDraftThreadContext(draftId ?? threadRef, { + branch: previousWorktreeSeed.branch, + worktreePath: previousWorktreeSeed.worktreePath, + envMode: "worktree", + projectRef: activeProjectRef, + }); + }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + previousWorktreeLabel={previousWorktreeLabel} + onUsePreviousWorktree={onUsePreviousWorktree} /> ) : (
@@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + previousWorktreeLabel={previousWorktreeLabel} + onUsePreviousWorktree={onUsePreviousWorktree} />
)} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..e915c27312c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,4 +1,4 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { @@ -17,11 +17,15 @@ import { SelectValue, } from "./ui/select"; +export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; + interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel?: string | null; + onUsePreviousWorktree?: () => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...(showPreviousWorktree && previousWorktreeLabel + ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] + : []), ], - [activeWorktreePath], + [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); if (envLocked) { @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3b4a6c72d3a..e55817700e5 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; -import { - resolveThreadActionProjectRef, - startNewThreadInProjectFromContext, - startNewThreadFromContext, -} from "../lib/chatThreadActions"; +import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, canNavigateUp, @@ -839,13 +835,7 @@ function OpenCommandPaletteDialog(props: { projectRef.environmentId === contextualProjectRef.environmentId && projectRef.projectId === contextualProjectRef.projectId, ); - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, + await handleNewThread( contextualRefBelongsToGroup ? contextualProjectRef : scopeProjectRef(project.environmentId, project.id), @@ -853,15 +843,7 @@ function OpenCommandPaletteDialog(props: { }, }), ), - [ - activeDraftThread, - activeThread, - contextualProjectRef, - defaultProjectRef, - handleNewThread, - pickerProjects, - projectGroupByTargetKey, - ], + [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], ); const allThreadItems = useMemo( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 65424b830a2..59784bf8fac 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,8 +14,6 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, @@ -371,112 +369,6 @@ describe("isTrailingDoubleClick", () => { }); }); -describe("resolveSidebarNewThreadEnvMode", () => { - it("uses the app default when the caller does not request a specific mode", () => { - expect( - resolveSidebarNewThreadEnvMode({ - defaultEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - - it("preserves an explicit requested mode over the app default", () => { - expect( - resolveSidebarNewThreadEnvMode({ - requestedEnvMode: "local", - defaultEnvMode: "worktree", - }), - ).toBe("local"); - }); -}); - -describe("resolveSidebarNewThreadSeedContext", () => { - it("prefers the default worktree mode over active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/draft", - worktreePath: "/repo/.t3/worktrees/draft", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - envMode: "worktree", - }); - }); - - it("inherits the active server thread context when creating a new thread in the same project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - branch: "effect-atom", - worktreePath: null, - envMode: "local", - }); - }); - - it("prefers the active draft thread context when it matches the target project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }); - }); - - it("falls back to the default env mode when there is no matching active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-2", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - envMode: "worktree", - }); - }); -}); - describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e7082ca8ada..7aee3100d0e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; -export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; title: string; @@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; } -export function resolveSidebarNewThreadEnvMode(input: { - requestedEnvMode?: SidebarNewThreadEnvMode; - defaultEnvMode: SidebarNewThreadEnvMode; -}): SidebarNewThreadEnvMode { - return input.requestedEnvMode ?? input.defaultEnvMode; -} - -export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; - defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin: boolean; - } | null; -}): { - branch?: string | null; - worktreePath?: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin?: boolean; -} { - if (input.defaultEnvMode === "worktree") { - return { - envMode: "worktree", - }; - } - - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - startFromOrigin: input.activeDraftThread.startFromOrigin, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - - return { - envMode: input.defaultEnvMode, - }; -} - export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 014e629fda7..a1d95eaa734 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,8 +177,6 @@ import { isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.confirmThreadArchive, ); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const createThreadForProjectMember = useCallback( (member: SidebarProjectGroupMember) => { - const currentRouteParams = - router.state.matches[router.state.matches.length - 1]?.params ?? {}; - const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); - const currentActiveThread = - currentRouteTarget?.kind === "server" - ? readThreadShell(currentRouteTarget.threadRef) - : null; - const draftStore = useComposerDraftStore.getState(); - const currentActiveDraftThread = - currentRouteTarget?.kind === "server" - ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) - : currentRouteTarget?.kind === "draft" - ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) - : null; - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: member.id, - // The default env mode is a user preference stored on the primary - // environment's settings.json; remote environments never carry it. - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: primaryServerSettings.defaultThreadEnvMode, - }), - activeThread: - currentActiveThread && currentActiveThread.projectId === member.id - ? { - projectId: currentActiveThread.projectId, - branch: currentActiveThread.branch, - worktreePath: currentActiveThread.worktreePath, - } - : null, - activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === member.id - ? { - projectId: currentActiveDraftThread.projectId, - branch: currentActiveDraftThread.branch, - worktreePath: currentActiveDraftThread.worktreePath, - envMode: currentActiveDraftThread.envMode, - startFromOrigin: currentActiveDraftThread.startFromOrigin, - } - : null, - }); if (isMobile) { setOpenMobile(false); } void (async () => { + // No options: branch, worktree, and env mode come from the user's + // configured defaults, never from the currently viewed thread. const result = await settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id), { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - ...(seedContext.startFromOrigin !== undefined - ? { startFromOrigin: seedContext.startFromOrigin } - : {}), - }), + handleNewThread(scopeProjectRef(member.environmentId, member.id)), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } })(); }, - [handleNewThread, isMobile, primaryServerSettings.defaultThreadEnvMode, router, setOpenMobile], + [handleNewThread, isMobile, setOpenMobile], ); const handleCreateThreadClick = useCallback( @@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; const clicked = await api.contextMenu.show( [ + ...(thread.branch + ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec position, ); + if (clicked === "new-thread-on-branch") { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + if (clicked === "rename") { startThreadRename(threadKey, thread.title); return; @@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyPathToClipboard, copyThreadIdToClipboard, deleteThread, + handleNewThread, markThreadUnread, memberProjectByScopedKey, project.workspaceRoot, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 361c3460bfd..1a78ef54eff 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1577,6 +1577,14 @@ export default function SidebarV2() { const clicked = await settlePromise(() => api.contextMenu.show( [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + label: `New thread on ${thread.branch}`, + }, + ] + : []), ...(supportsSettlement ? [ isSettled @@ -1593,6 +1601,29 @@ export default function SidebarV2() { ); if (clicked._tag === "Failure") return; switch (clicked.value) { + case "new-thread-on-branch": { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "settle": attemptSettle(threadRef); return; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..d4f38adcacd 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -406,6 +406,15 @@ interface ComposerDraftStoreState { setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, + opts?: { + /** + * Replace the stored entry outright instead of preserving its + * existing options when the incoming selection has none. Used when + * the selection is a complete snapshot (e.g. carried from another + * thread) rather than a model-only change. + */ + replaceOptions?: boolean; + }, ) => void; /** Replace the model options for one or more providers in the draft. */ setModelOptions: ( @@ -2594,7 +2603,7 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - setModelSelection: (threadRef, modelSelection) => { + setModelSelection: (threadRef, modelSelection, opts) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { return; @@ -2609,8 +2618,10 @@ const composerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; if (normalized) { const current = nextMap[normalized.instanceId]; - if (normalized.options !== undefined) { - // Explicit options provided โ†’ use them + if (normalized.options !== undefined || opts?.replaceOptions) { + // Explicit options provided (or the caller passed a complete + // snapshot whose absent options mean "no options") โ†’ use the + // selection as-is. nextMap[normalized.instanceId] = normalized as ModelSelection; } else { // No options in selection โ†’ preserve existing options, update provider+model diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cd8546893b0..2479d6ba02f 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -54,14 +54,52 @@ export function useNewThreadHandler() { }, ): Promise => { const { + getComposerDraft, getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, + setModelSelection, } = useComposerDraftStore.getState(); const currentRouteTarget = getCurrentRouteTarget(); + // A new thread carries the user's *working mode* from the thread being + // viewed: model (including options like reasoning effort and context + // window), permission mode, and interaction mode. Branch, worktree, and + // env mode never carry implicitly โ€” those come from the configured + // defaults unless the caller passes them explicitly. + const carrySourceShell = + currentRouteTarget?.kind === "server" + ? readThreadShell(currentRouteTarget.threadRef) + : null; + const carrySourceDraft = + currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; + // Composer overrides win over the persisted thread state โ€” they are + // what the user currently sees in the composer controls. + const carrySourceComposer = currentRouteTarget + ? getComposerDraft( + currentRouteTarget.kind === "server" + ? currentRouteTarget.threadRef + : currentRouteTarget.draftId, + ) + : null; + const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; + const composerModelSelection = composerActiveProvider + ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) + : null; + const carryModelSelection = + composerModelSelection ?? carrySourceShell?.modelSelection ?? null; + const carryRuntimeMode = + carrySourceComposer?.runtimeMode ?? + carrySourceShell?.runtimeMode ?? + carrySourceDraft?.runtimeMode ?? + null; + const carryInteractionMode = + carrySourceComposer?.interactionMode ?? + carrySourceShell?.interactionMode ?? + carrySourceDraft?.interactionMode ?? + null; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -92,25 +130,70 @@ export function useNewThreadHandler() { : null; if (reusableStoredDraftThread) { return (async () => { - if ( + const isDraftAlreadyOpen = + currentRouteTarget?.kind === "draft" && + currentRouteTarget.draftId === reusableStoredDraftThread.draftId; + const hasExplicitWorkspaceOption = hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption - ) { + hasStartFromOriginOption; + // Resurrecting a stored draft must not resurrect its stale context: + // explicit workspace options win outright; otherwise the env context + // resets to the configured defaults so drafts seeded before a + // defaults change (or by the old carry-over behavior) stop landing + // on "current checkout" branches forever. Composer text is + // preserved. When the draft is already open and no options were + // passed, leave it alone entirely โ€” the user may have just picked a + // branch in the composer. + const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; + const workspaceContext = hasExplicitWorkspaceOption + ? { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + } + : isDraftAlreadyOpen + ? null + : { + branch: null, + worktreePath: null, + envMode: defaultEnvMode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + }; + if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...workspaceContext, + ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); + if (carryModelSelection) { + // The carried selection is a complete snapshot of the viewed + // thread's model state: absent options mean "no options", not + // "keep the stale draft's options". + setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, { + replaceOptions: true, + }); + } } + // The workspace context must also ride along here: when projectRef + // targets a different physical member of the logical project, + // createDraftThreadState treats the remap as a project change and + // would otherwise wipe branch/worktree and force "local" mode, + // undoing the write above. setLogicalProjectDraftThreadId( logicalProjectKey, projectRef, reusableStoredDraftThread.draftId, { threadId: reusableStoredDraftThread.threadId, + ...(workspaceContext ?? {}), + ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); if ( @@ -176,9 +259,18 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, + ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); + if (carryModelSelection) { + // After sticky state so the viewed thread's exact selection + // (model + options like effort and context window) wins over the + // globally sticky one. replaceOptions: the carried selection is a + // complete snapshot โ€” absent options mean "no options", not "keep + // whatever sticky state just wrote". + setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); + } await router.navigate({ to: "/draft/$draftId", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2189f1e3ca2..0902d8de795 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -4,9 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, resolveNewDraftStartFromOrigin, - startNewLocalThreadFromContext, startNewThreadFromContext, - startNewThreadInProjectFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -40,16 +38,12 @@ describe("chatThreadActions", () => { ).toBe(false); }); - it("prefers the active draft thread project when resolving thread actions", () => { + it("prefers the active thread project when resolving thread actions", () => { const projectRef = resolveThreadActionProjectRef( createContext({ - activeDraftThread: { + activeThread: { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID, - branch: "feature/refactor", - worktreePath: "/tmp/worktree", - envMode: "worktree", - startFromOrigin: true, }, }), ); @@ -57,95 +51,40 @@ describe("chatThreadActions", () => { expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); - it("falls back to the default project ref when there is no active thread context", () => { + it("falls back to the active draft thread project when there is no active thread", () => { const projectRef = resolveThreadActionProjectRef( - createContext({ - defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), - }), - ); - - expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); - }); - - it("starts a contextual new thread from the active draft thread", async () => { - const handleNewThread = vi.fn(async () => {}); - - const didStart = await startNewThreadFromContext( createContext({ activeDraftThread: { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID, - branch: "feature/refactor", - worktreePath: "/tmp/worktree", - envMode: "worktree", - startFromOrigin: true, }, - handleNewThread, }), ); - expect(didStart).toBe(true); - expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { - branch: "feature/refactor", - worktreePath: "/tmp/worktree", - envMode: "worktree", - startFromOrigin: true, - }); + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); - it("preserves an explicitly disabled origin base in contextual thread options", async () => { - const handleNewThread = vi.fn(async () => {}); - - await startNewThreadFromContext( + it("falls back to the default project ref when there is no active thread context", () => { + const projectRef = resolveThreadActionProjectRef( createContext({ - activeDraftThread: { - environmentId: ENVIRONMENT_ID, - projectId: PROJECT_ID, - branch: "feature/refactor", - worktreePath: "/tmp/worktree", - envMode: "worktree", - startFromOrigin: false, - }, - handleNewThread, + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), }), ); - expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { - branch: "feature/refactor", - worktreePath: "/tmp/worktree", - envMode: "worktree", - startFromOrigin: false, - }); + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); - it("does not carry branch or worktree context into a different project", async () => { + it("inherits only the project from context, never branch or worktree state", async () => { const handleNewThread = vi.fn(async () => {}); - const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); - await startNewThreadInProjectFromContext( + const didStart = await startNewThreadFromContext( createContext({ activeThread: { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID, - branch: "feature/refactor", - worktreePath: "/tmp/worktree", }, handleNewThread, }), - targetProjectRef, - ); - - expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); - }); - - it("delegates the target environment defaults to the new-thread handler", async () => { - const handleNewThread = vi.fn(async () => {}); - - const didStart = await startNewLocalThreadFromContext( - createContext({ - defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), - handleNewThread, - }), ); expect(didStart).toBe(true); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index d8b831ac3ad..4a150d617ea 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; interface ThreadContextLike { environmentId: EnvironmentId; projectId: ProjectId; - branch: string | null; - worktreePath: string | null; -} - -interface DraftThreadContextLike extends ThreadContextLike { - envMode: DraftThreadEnvMode; - startFromOrigin: boolean; } interface NewThreadHandler { @@ -26,10 +19,8 @@ interface NewThreadHandler { ): Promise; } -type NewThreadOptions = NonNullable[1]>; - export interface ChatThreadActionContext { - readonly activeDraftThread: DraftThreadContextLike | null; + readonly activeDraftThread: ThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; readonly handleNewThread: NewThreadHandler; @@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef( return context.defaultProjectRef; } -function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { - return { - branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, - worktreePath: - context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, - envMode: - context.activeDraftThread?.envMode ?? - (context.activeThread?.worktreePath ? "worktree" : "local"), - ...(context.activeDraftThread - ? { startFromOrigin: context.activeDraftThread.startFromOrigin } - : {}), - }; -} - -export async function startNewThreadInProjectFromContext( - context: ChatThreadActionContext, - projectRef: ScopedProjectRef, -): Promise { - const contextualProjectRef = resolveThreadActionProjectRef(context); - const matchesContext = - contextualProjectRef?.environmentId === projectRef.environmentId && - contextualProjectRef.projectId === projectRef.projectId; - if (!matchesContext) { - await context.handleNewThread(projectRef); - return; - } - await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); -} - +// New threads inherit only the *project* from the current context. Branch, +// worktree, and env mode always come from the user's configured defaults โ€” +// carrying them over from the viewed thread meant "new thread" silently +// reused checkouts and branches. Explicit affordances (branch toolbar's +// "new thread in this worktree") pass those options to handleNewThread +// directly instead. export async function startNewThreadFromContext( context: ChatThreadActionContext, ): Promise { @@ -94,18 +62,6 @@ export async function startNewThreadFromContext( return false; } - await startNewThreadInProjectFromContext(context, projectRef); - return true; -} - -export async function startNewLocalThreadFromContext( - context: ChatThreadActionContext, -): Promise { - const projectRef = resolveThreadActionProjectRef(context); - if (!projectRef) { - return false; - } - await context.handleNewThread(projectRef); return true; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 2e29d222b5b..d3cf003d99c 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { - startNewLocalThreadFromContext, - startNewThreadFromContext, -} from "../lib/chatThreadActions"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void startNewLocalThreadFromContext({ + void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, From 291285dfc0e4d4a6d1fbe2121abcc42d461a4268 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:38:05 -0700 Subject: [PATCH 07/21] fix: tone down branch-mismatch banner (#4416) Co-authored-by: Claude Fable 5 (cherry picked from commit fc3f78f5d917fdeee6271d2f6fd42961369c7d9f) --- .../web/src/components/ChatView.logic.test.ts | 59 +++++ apps/web/src/components/ChatView.logic.ts | 40 ++++ apps/web/src/components/ChatView.tsx | 223 +++++++++++------- 3 files changed, 236 insertions(+), 86 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2f865855598..57c12959ffb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,16 +12,20 @@ import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => { }); }); +describe("branchMismatchKey", () => { + it("builds a key from thread id and both branches", () => { + expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( + "thread-1:feat/a:feat/b", + ); + }); + + it("returns null without a thread or mismatch", () => { + expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); + expect(branchMismatchKey("thread-1", null)).toBeNull(); + }); +}); + +describe("shouldShowBranchMismatchBanner", () => { + const base = { + hasMismatch: true, + isDismissed: false, + composerHasContent: false, + wasShownForCurrentMismatch: false, + }; + + it("stays hidden during passive browsing (even though the composer autofocuses)", () => { + expect(shouldShowBranchMismatchBanner(base)).toBe(false); + }); + + it("shows once the composer has draft content", () => { + expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); + }); + + it("stays mounted after the draft clears once shown for the current mismatch", () => { + expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( + true, + ); + }); + + it("never shows when dismissed or without a mismatch", () => { + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), + ).toBe(false); + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), + ).toBe(false); + }); +}); + +describe("session branch mismatch dismissal", () => { + it("tracks dismissed keys and treats other keys as active", () => { + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); + dismissBranchMismatchForSession("t1:a:b"); + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); + expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); + expect(isBranchMismatchDismissedForSession(null)).toBe(false); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 591f56ea4c7..466c9b24c87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy( }; } +export function branchMismatchKey( + threadId: string | null, + mismatch: { threadBranch: string; currentBranch: string } | null, +): string | null { + if (!threadId || !mismatch) { + return null; + } + return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; +} + +// The mismatch banner only matters when the user is about to send: passive +// reading of an old thread carries no risk (the branch picker tint already +// covers ambient awareness). Draft content is the intent signal โ€” composer +// focus is useless here because ChatView autofocuses the composer on every +// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once +// revealed so it doesn't flicker away when the draft is cleared. +export function shouldShowBranchMismatchBanner(input: { + hasMismatch: boolean; + isDismissed: boolean; + composerHasContent: boolean; + wasShownForCurrentMismatch: boolean; +}): boolean { + if (!input.hasMismatch || input.isDismissed) { + return false; + } + return input.composerHasContent || input.wasShownForCurrentMismatch; +} + +// Session-scoped (module-level so it survives ChatView remounts, e.g. route +// changes). Durable cross-device dismissal is planned as a server-side ack. +const sessionDismissedBranchMismatchKeys = new Set(); + +export function dismissBranchMismatchForSession(key: string): void { + sessionDismissedBranchMismatchKeys.add(key); +} + +export function isBranchMismatchDismissedForSession(key: string | null): boolean { + return key !== null && sessionDismissedBranchMismatchKeys.has(key); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9723e30e049..e6dcbe0a48d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -231,13 +231,17 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, + shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, @@ -3914,52 +3928,57 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - const [branchRepairAction, setBranchRepairAction] = useState< - "update-thread" | "switch-checkout" | null - >(null); - const handleUpdateThreadToCheckout = useCallback(async () => { - if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) { - return; - } - setBranchRepairAction("update-thread"); - const updateResult = await updateThreadMetadata({ - environmentId, - input: { - threadId: activeThread.id, - branch: localCheckoutBranchMismatch.currentBranch, - worktreePath: null, - }, - }); - setBranchRepairAction(null); - if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to update thread branch", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - return; - } - scheduleComposerFocus(); - }, [ - activeThread, - branchRepairAction, - environmentId, + const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); + const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); + // Once revealed for a given mismatch, the banner stays mounted until the + // mismatch changes or resolves, so clearing the draft doesn't flicker it. + const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); + // Dismissal lives in a module-level set (survives remounts); this tick just + // forces a re-render so the banner leaves immediately. + const [, setBranchMismatchDismissTick] = useState(0); + const composerHasDraftContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0), + ); + }); + const activeBranchMismatchKey = branchMismatchKey( + activeThread?.id ?? null, localCheckoutBranchMismatch, - scheduleComposerFocus, - updateThreadMetadata, - ]); + ); + const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ + hasMismatch: localCheckoutBranchMismatch !== null, + isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), + composerHasContent: composerHasDraftContent, + wasShownForCurrentMismatch: + revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, + }); + useEffect(() => { + setRevealedBranchMismatchKey((revealed) => { + if (showBranchMismatchBanner) { + return activeBranchMismatchKey; + } + // Hysteresis is scoped to an uninterrupted mismatch: reset when the + // mismatch resolves or changes so a recurrence re-gates on intent. + return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; + }); + }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { if ( !activeProjectCwd || !activeThread || !localCheckoutBranchMismatch || - branchRepairAction !== null + isRestoringThreadBranch ) { return; } - setBranchRepairAction("switch-checkout"); + setIsRestoringThreadBranch(true); const checkoutResult = await switchGitRef({ environmentId, input: { @@ -3968,7 +3987,7 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (checkoutResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ @@ -3988,7 +4007,7 @@ function ChatViewContent(props: ChatViewProps) { input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, }); if (updateResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(updateResult)) { toastManager.add( stackedThreadToast({ @@ -4003,76 +4022,78 @@ function ChatViewContent(props: ChatViewProps) { } } gitStatusQuery.refresh(); - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ activeProjectCwd, activeThread, - branchRepairAction, environmentId, gitStatusQuery, + isRestoringThreadBranch, localCheckoutBranchMismatch, scheduleComposerFocus, switchGitRef, updateThreadMetadata, ]); + const handleRestoreThreadBranch = useCallback(() => { + if (gitStatusQuery.data?.hasWorkingTreeChanges) { + setBranchRestoreConfirmOpen(true); + return; + } + void handleSwitchCheckoutToThread(); + }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - if (!localCheckoutBranchMismatch) { + if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return systemComposerBannerItems; } - const isRepairingBranch = branchRepairAction !== null; return [ ...systemComposerBannerItems, { - id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, - variant: "warning", - icon: , - title: "You're on a different branch", - className: - "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none", - actionClassName: - "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3", - description: ( -

- This thread is on{" "} - - {localCheckoutBranchMismatch.threadBranch} - - , but you're currently checked out at{" "} - - {localCheckoutBranchMismatch.currentBranch} - - . Sending a message will update the thread. -

+ id: `branch-mismatch:${activeBranchMismatchKey}`, + variant: "info", + icon: , + title: ( + + Branch changed โ€” was + + + {localCheckoutBranchMismatch.threadBranch} + + } + /> + + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will + continue on {localCheckoutBranchMismatch.currentBranch}. + + + ), + className: "dark:shadow-none", actions: ( - <> - - - + ), + dismissLabel: "Dismiss branch change notice", + onDismiss: () => { + dismissBranchMismatchForSession(activeBranchMismatchKey); + setBranchMismatchDismissTick((tick) => tick + 1); + }, }, ]; }, [ - activeThread?.id, - branchRepairAction, - handleSwitchCheckoutToThread, - handleUpdateThreadToCheckout, + activeBranchMismatchKey, + handleRestoreThreadBranch, + isRestoringThreadBranch, localCheckoutBranchMismatch, + showBranchMismatchBanner, systemComposerBannerItems, ]); @@ -5835,6 +5856,36 @@ function ChatViewContent(props: ChatViewProps) {
+ + + + + Switch to{" "} + + {localCheckoutBranchMismatch?.threadBranch ?? ""} + + ? + + + You have uncommitted changes. They'll carry over to the other branch, or block + the switch if they conflict. + + + + }>Cancel + + + + + {pullRequestDialogState ? ( Date: Thu, 23 Jul 2026 18:39:42 -0700 Subject: [PATCH 08/21] fix: Claude Code skills discoverable for the composer $ picker (#4414) Co-authored-by: Claude Fable 5 (cherry picked from commit 6b9a5987f686d0d904abe6f51b4be3bf873907e9) --- apps/server/package.json | 3 +- .../src/provider/Drivers/ClaudeDriver.ts | 3 + .../src/provider/Drivers/ClaudeSkills.test.ts | 205 ++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 148 +++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 8 +- pnpm-lock.yaml | 3 + 6 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeSkills.ts diff --git a/apps/server/package.json b/apps/server/package.json index 37d3de3c94b..8626a7d0a29 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,7 +35,8 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0" + "node-pty": "^1.1.0", + "yaml": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..c2fc11311aa 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -118,6 +118,7 @@ export const ClaudeDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; @@ -165,9 +166,11 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + cwd, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts new file mode 100644 index 00000000000..1ad843d7573 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -0,0 +1,205 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("discovers user and project skills with frontmatter metadata", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "codex-review", + [ + "---", + "name: codex-review", + "description: Ask Codex for a review.", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "codex-review", + path: path.join(configDir, "skills", "codex-review", "SKILL.md"), + enabled: true, + scope: "user", + description: "Ask Codex for a review.", + }, + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Deploy the app.", + }, + ]); + }), + ); + + it.effect("prefers project skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal(skills.length, 1); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Project deploy."); + }), + ); + + it.effect("falls back to the directory name and skips malformed frontmatter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); + yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + // A stray file (not a directory with SKILL.md) must be skipped. + yield* fs.makeDirectory(skillsDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + // A skill with no frontmatter falls back to its directory name; a skill + // whose frontmatter fails to parse is skipped entirely (Claude Code + // won't load it either). + assert.deepEqual( + skills.map((skill) => skill.name), + ["no-frontmatter"], + ); + assert.equal(skills[0]?.description, undefined); + }), + ); + + it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const environmentConfigDir = path.join(tempDir, "env-config"); + + yield* writeSkill( + path.join(environmentConfigDir, "skills"), + "env-skill", + ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["env-skill"], + ); + + // An explicit homePath wins over the environment variable, matching + // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI. + const explicitHome = path.join(tempDir, "explicit-home"); + yield* writeSkill( + path.join(explicitHome, "skills"), + "explicit-skill", + ["---", "name: explicit-skill", "---"].join("\n"), + ); + const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + assert.deepEqual( + explicitSkills.map((skill) => skill.name), + ["explicit-skill"], + ); + }), + ); + + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own + // cwd (the workspace), so discovery must do the same. + yield* writeSkill( + path.join(workspace, "relative-config", "skills"), + "relative-skill", + ["---", "name: relative-skill", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, { + CLAUDE_CONFIG_DIR: "relative-config", + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["relative-skill"], + ); + assert.equal(skills[0]?.scope, "user"); + }), + ); + + it.effect("returns an empty list when no skill roots exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + + const skills = yield* discoverClaudeSkills( + { homePath: path.join(tempDir, "missing-home") }, + path.join(tempDir, "missing-workspace"), + ); + + assert.deepEqual(skills, []); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts new file mode 100644 index 00000000000..335c3d4681d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -0,0 +1,148 @@ +/** + * ClaudeSkills โ€” filesystem discovery of Claude Code skills for the `$` picker. + * + * Claude Code loads skills from `/skills` (user scope) and + * `/.claude/skills` (project scope), one directory per skill with a + * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces + * skills only as slash commands without their filesystem paths, so the + * provider snapshot scans the same locations directly, mirroring how the + * Codex app-server reports its skills. + * + * @module provider/Drivers/ClaudeSkills + */ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +type ClaudeSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +type SkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "missing" }; + } + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed" }; + } + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +/** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` + * already present in the process environment, then `~/.claude`. + */ +const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // No tilde expansion here: the spawned CLI receives this env var verbatim + // (env vars are never shell-expanded), so a literal `~` must stay literal + // for discovery to scan the same directory the runtime would. A relative + // value is resolved against the workspace cwd โ€” the subprocess's own cwd โ€” + // for the same reason. + const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); + } + return path.join(NodeOS.homedir(), ".claude"); +}); + +/** + * Enumerate Claude Code skills from the user config dir and the workspace. + * Discovery is best-effort: unreadable roots and malformed skill entries are + * skipped so a broken skill never degrades the provider snapshot. On name + * collisions the project-scoped skill wins, matching Claude Code's + * most-specific-wins resolution. + */ +export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( + config: Pick, + cwd?: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + + const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + { directory: path.join(configDirPath, "skills"), scope: "user" }, + ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ]; + + const skillsByName = new Map(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + // Malformed frontmatter means the skill won't load in Claude Code + // either โ€” skip it rather than surfacing a broken entry under its + // directory name. + if (frontmatter.kind === "malformed") { + continue; + } + + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 76079504f72..a8c49eb7928 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; @@ -40,6 +41,7 @@ import { } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -812,10 +814,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Path.Path + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -930,6 +933,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); @@ -940,6 +944,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, @@ -961,6 +966,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30c54c341b1..851cd0914cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,6 +487,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 From 7fe7a67a0b97c7405d7ce05ee0659f3411ea3579 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:18:32 -0700 Subject: [PATCH 09/21] fix(web): keep settled threads reachable when opened directly (#4413) Co-authored-by: Claude Fable 5 (cherry picked from commit bb38c3320d0a4eec4e9097fdc400970d881ff559) --- apps/web/src/components/ChatView.tsx | 101 +++++++++++++++++++++++++- apps/web/src/components/SidebarV2.tsx | 33 +++++---- apps/web/src/hooks/useNowMinute.ts | 69 ++++++++++++++++++ 3 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/hooks/useNowMinute.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e6dcbe0a48d..ebaf1e2c264 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -138,7 +139,13 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { + CheckCircle2Icon, + ChevronDownIcon, + GitBranchIcon, + TriangleAlertIcon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -153,7 +160,8 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -202,6 +210,7 @@ import { useThread, useThreadProposedPlans, useThreadRefs, + useThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -220,6 +229,7 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -3928,6 +3938,63 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); + // Settled state of the open thread, resolved exactly like the sidebar + // partition (same shell, same capability gate, same PR auto-settle input) + // so the banner and the sidebar row never disagree. + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const activeThreadPr = resolveThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, + }); + const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const nowMinute = useNowMinute(); + const activeThreadSettled = useMemo(() => { + if (activeThreadShell === null || !supportsSettlement) return false; + return effectiveSettled(activeThreadShell, { + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: activeThreadPr?.state ?? null, + }); + }, [ + activeThreadPr?.state, + activeThreadShell, + autoSettleAfterDays, + nowMinute, + supportsSettlement, + ]); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); + // Keyed by thread, not a boolean: the pending state must follow the thread + // it belongs to across navigation, and a request resolving for thread A + // must never clear (or re-enable) thread B's button. + const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); + const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; + const handleUnsettleActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsettlingThreadKey(threadKey); + try { + const result = await unsettleThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + 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.", + }), + ); + } + } finally { + setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsettleThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -4035,6 +4102,31 @@ function ChatViewContent(props: ChatViewProps) { switchGitRef, updateThreadMetadata, ]); + // The stack renders items[0] front-most and tucks the rest behind hover, so + // ordering is priority: system banners, then the branch-mismatch notice, + // and the informational settled banner last โ€” it must never cover another. + const settledComposerBannerItem = useMemo(() => { + if (!activeThreadSettled) { + return null; + } + return { + id: `thread-settled:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: , + title: "This thread is settled", + description: "Sending a message moves it back to Active in the sidebar.", + actions: ( + + ), + }; + }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4043,8 +4135,9 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return systemComposerBannerItems; + return [...systemComposerBannerItems, ...settledItems]; } return [ ...systemComposerBannerItems, @@ -4087,12 +4180,14 @@ function ChatViewContent(props: ChatViewProps) { setBranchMismatchDismissTick((tick) => tick + 1); }, }, + ...settledItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, + settledComposerBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1a78ef54eff..c2995359034 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -78,6 +78,7 @@ import { 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 { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -952,14 +953,7 @@ export default function SidebarV2() { // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - useEffect(() => { - const id = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - return () => window.clearInterval(id); - }, []); + const nowMinute = useNowMinute(); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. @@ -1223,11 +1217,24 @@ export default function SidebarV2() { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } - const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); - const visibleSettledThreads = useMemo( - () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), - [hiddenSettledCount, settledThreads, settledVisibleCount], - ); + const visibleSettledThreads = useMemo(() => { + if (settledThreads.length <= settledVisibleCount) return settledThreads; + const visible = settledThreads.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); + } + return visible; + }, [routeThreadKey, settledThreads, settledVisibleCount]); + const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts new file mode 100644 index 00000000000..1b9f77b2189 --- /dev/null +++ b/apps/web/src/hooks/useNowMinute.ts @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; + +/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. + One module-level timer feeds every consumer through useSyncExternalStore, + so all surfaces resolving effectiveSettled against it (sidebar partition, + composer banner) share a single value by construction and tick on UTC + minute boundaries together. */ + +function currentMinute(): string { + return new Date().toISOString().slice(0, 16); +} + +let nowMinute = currentMinute(); +let timerId: number | null = null; +let timerIsInterval = false; +const listeners = new Set<() => void>(); + +function tick(): void { + const next = currentMinute(); + if (next !== nowMinute) { + nowMinute = next; + for (const listener of listeners) listener(); + } +} + +function startTimer(): void { + // Align to the next UTC minute boundary, then tick every 60s. Ticks re-read + // the clock, so a throttled or late timer self-corrects when it fires. + timerIsInterval = false; + timerId = window.setTimeout( + () => { + tick(); + timerIsInterval = true; + timerId = window.setInterval(tick, 60_000); + }, + 60_000 - (Date.now() % 60_000), + ); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0) { + startTimer(); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && timerId !== null) { + if (timerIsInterval) window.clearInterval(timerId); + else window.clearTimeout(timerId); + timerId = null; + } + }; +} + +function getSnapshot(): string { + // With no timer running (no subscribers yet โ€” e.g. the first render after + // a full unmount), the stored minute may be stale; re-read it so a fresh + // mount renders the current minute instead of waiting for the first tick. + // While the timer runs the cached value is returned untouched, as + // useSyncExternalStore requires between change notifications. + if (timerId === null) { + nowMinute = currentMinute(); + } + return nowMinute; +} + +export function useNowMinute(): string { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} From 2887b4320ebb0e216e5d65a561e25d7811c62abf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 22:09:32 -0700 Subject: [PATCH 10/21] feat(sidebar-v2): thread snoozing (#4311) Co-authored-by: Claude Fable 5 Co-authored-by: maria (cherry picked from commit 202e5609ffb294bc0aa86c08ce1d3751de567226) --- apps/mobile/src/features/home/HomeScreen.tsx | 60 +- .../threads/ThreadNavigationSidebar.tsx | 55 +- .../src/features/threads/threadListV2.test.ts | 79 ++ .../src/features/threads/threadListV2.ts | 42 +- apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 34 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 20 + apps/server/src/orchestration/Schemas.ts | 4 + .../src/orchestration/decider.snoozed.test.ts | 286 +++++++ apps/server/src/orchestration/decider.ts | 261 ++++-- .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 + .../Layers/ProjectionRepositories.test.ts | 13 +- .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../034_ProjectionThreadsSnoozed.ts | 23 + .../persistence/Services/ProjectionThreads.ts | 2 + apps/web/src/components/ChatView.tsx | 97 ++- .../web/src/components/Sidebar.snooze.test.ts | 94 +++ apps/web/src/components/Sidebar.snooze.ts | 127 +++ apps/web/src/components/SidebarV2.tsx | 783 ++++++++++++++---- apps/web/src/hooks/useThreadActions.ts | 92 +- apps/web/src/state/entities.ts | 9 + apps/web/src/timestampFormat.ts | 2 +- .../client-runtime/src/operations/commands.ts | 22 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../client-runtime/src/state/threadReducer.ts | 24 + .../client-runtime/src/state/threadSettled.ts | 124 +++ .../src/state/threadSnoozed.test.ts | 237 ++++++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.ts | 62 ++ 34 files changed, 2389 insertions(+), 235 deletions(-) create mode 100644 apps/server/src/orchestration/decider.snoozed.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts create mode 100644 apps/web/src/components/Sidebar.snooze.test.ts create mode 100644 apps/web/src/components/Sidebar.snooze.ts create mode 100644 packages/client-runtime/src/state/threadSnoozed.test.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 108a4b7056f..54e242c2166 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -472,6 +472,10 @@ export function HomeScreen(props: HomeScreenProps) { // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled, v2ScopedProjectGroup, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const threadListV2Items = threadListV2Layout.items; const renderV2Item = useCallback( @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) { // Self-contained: v1's listEmpty keys off projectGroups, which ignores the // v2 project scope, so it can be null (results elsewhere) while this list // is empty. Search outranks the scope โ€” "No results" names the actionable - // fact when a query is active. Pending tasks render in the header, so the - // list showing them isn't empty in the user's eyes. + // fact when a query is active. Snoozed threads outrank the rest: "No + // threads yet" over an inbox that is merely all-snoozed reads as data + // loss. Pending tasks render in the header, so the list showing them + // isn't empty in the user's eyes. + const v2SnoozedCount = threadListV2Layout.snoozedCount; const v2ListEmpty = v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + ) : v2ScopedProjectGroup !== null ? ( new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, threadListV2Enabled, threads, selectedProjectScope, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks render above the thread rows (mirrors the @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane( }), [filterIcon, filterMenu, props.onOpenSettings], ); + // "No threads yet" over an inbox that is merely all-snoozed reads as + // data loss; name the snoozed threads instead. + const snoozedCount = threadListV2Layout.snoozedCount; const listEmpty = ( {catalogState.isLoadingConnections ? "Loading threadsโ€ฆ" : props.searchQuery.trim().length > 0 - ? "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + ? snoozedCount > 0 + ? // Snoozed matches passed this same search filter โ€” "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" + : snoozedCount > 0 + ? snoozedCount === 1 + ? "1 thread snoozed" + : `${snoozedCount} threads snoozed` + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index cdbf2e3c585..e62b9ceda34 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("hides snoozed threads and counts them โ€” visibility parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("woken"), + title: "Woken", + // Wake time already passed: back in the active list. + snoozedUntil: "2026-06-01T18:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + // Same createdAt โ†’ static sort tiebreaks by id; the point is the woken + // thread is BACK in the card block and the snoozed one is gone. + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); + expect(layout.snoozedCount).toBe(1); + }); + + it("classifies snooze with the second-precise clock and reports the next wake", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("just-woke"), + title: "Just woke", + // Woke 30s ago: hidden under the minute-floored clock, visible + // under the precise one. + snoozedUntil: "2026-06-02T00:00:30.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("still-snoozed"), + title: "Still snoozed", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + // Minute-floored partition clock vs precise snooze clock. + now: "2026-06-02T00:01:00.000Z", + snoozeNow: "2026-06-02T00:01:07.500Z", + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); + expect(layout.snoozedCount).toBe(1); + expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); + }); + + it("keeps snoozed threads visible on environments without the snooze capability", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + snoozeEnvironmentIds: new Set(), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); + expect(layout.snoozedCount).toBe(0); + }); + it("partitions settled threads into a slim tail with one divider", () => { const { items } = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 071e1d93be0..efa68153a3f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,4 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Snoozed threads hidden from the list (visibility parity with web's + collapsed Snoozed shelf; mobile has no shelf UI yet). */ + readonly snoozedCount: number; + /** Soonest wake time among hidden snoozed threads, or null. Callers arm + a timeout at this boundary so the list re-partitions the moment a + snooze expires instead of on the next minute tick. */ + readonly nextSnoozeWakeAt: string | null; } /** @@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: { other environments never classify as settled โ€” the user could neither un-settle nor pin them. Absent = no gating (tests). */ readonly settlementEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.snooze/unsnooze. Same + contract as settlementEnvironmentIds. */ + readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ readonly now?: string; + /** Second-precise clock for snooze classification. Callers pass a + minute-quantized `now` for memoization; snooze wake times are + second-precise, so classifying with the floored minute would hold a + woken thread hidden for up to a minute. Defaults to `now`. */ + readonly snoozeNow?: string; }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); + const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + let snoozedCount = 0; + let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { } if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + // Visibility parity with web: a snoozed thread leaves the list until it + // wakes (or raises its hand โ€” effectiveSnoozed refuses blocked/failed + // work). Snooze outranks settled classification, same as web. + if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + snoozedCount += 1; + if ( + thread.snoozedUntil != null && + (nextSnoozeWakeAt === null || + parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) + ) { + nextSnoozeWakeAt = thread.snoozedUntil; + } + continue; + } if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { if (last) { items[items.length - 1] = { ...last, isLast: true }; } - return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; + return { + items, + hiddenSettledCount: orderedSettled.length - visibleSettled.length, + snoozedCount, + nextSnoozeWakeAt, + }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 80bc1916b11..8e340ef33bd 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -60,6 +60,8 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 181237d76b6..0eaf5a7c16a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + threadSnooze: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cfb88a06cd2..1f24a4a0200 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.snoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: event.payload.snoozedUntil, + snoozedAt: event.payload.snoozedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsnoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: null, + snoozedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 15ded458e22..d4a24a209ad 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [ { @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 155e9ab0013..3d05bef4bdf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 0d0d7bdd5e4..3b558d24739 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,8 @@ import { ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, + ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, + ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; +export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; +export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts new file mode 100644 index 00000000000..1012240b18a --- /dev/null +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -0,0 +1,286 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The decider's clock is the Effect test clock, pinned to the epoch, so +// "future" wake times are relative to 1970-01-01T00:00:00.000Z. +const FUTURE_WAKE = "1970-01-02T09:00:00.000Z"; +const PAST_WAKE = "1969-12-31T09:00:00.000Z"; +const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; + readonly archivedAt?: string | null; + readonly activities?: OrchestrationThread["activities"]; + readonly messages?: OrchestrationThread["messages"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), + deletedAt: null, + messages: input.messages ?? [], + proposedPlans: [], + activities: input.activities ?? [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("snoozed thread decider", (it) => { + it.effect("snoozes a thread to a future wake time", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.snoozed"); + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); + expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("rejects a wake time that is not in the future", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-past"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: PAST_WAKE, + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects an unparseable wake time", () => + Effect.gen(function* () { + // IsoDateTime is structurally a string, so garbage can reach the + // decider; a NaN wake time must never persist as snooze state. + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-garbage"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "not-a-date", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing blocked-on-you work", () => + Effect.gen(function* () { + const requestActivity = { + id: EventId.make("activity-req-1"), + tone: "approval" as const, + kind: "approval.requested", + summary: "approval.requested", + payload: { requestId: "req-1" }, + turnId: null, + createdAt: NOW, + } as OrchestrationThread["activities"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-blocked"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ activities: [requestActivity] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => + Effect.gen(function* () { + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-again"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.snoozed") { + // Original snoozedAt preserved; updatedAt must not churn. + expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-extend"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "1970-01-03T09:00:00.000Z", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unsnoozes with reason user and re-emits idempotently when awake", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unsnoozed"); + if (events[0]?.type === "thread.unsnoozed") { + expect(events[0].payload.reason).toBe("user"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + + const awake = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze-awake"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({}), + }); + const awakeEvents = Array.isArray(awake) ? awake : [awake]; + expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); + if (awakeEvents[0]?.type === "thread.unsnoozed") { + // No state change โ€” keep the existing updatedAt. + expect(awakeEvents[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects snoozing a thread with a queued turn start", () => + Effect.gen(function* () { + // The decider clock is the Effect test clock pinned to the epoch: a + // user message 30s before it with no adopting turn is queued work. + const queuedMessage = { + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "1969-12-31T23:59:30.000Z", + updatedAt: "1969-12-31T23:59:30.000Z", + } as OrchestrationThread["messages"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-queued"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ messages: [queuedMessage] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-archived"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("a user message spends the snooze return ticket (activity wake)", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(result) ? result : [result]; + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + expect(unsnoozed).toBeDefined(); + if (unsnoozed?.type === "thread.unsnoozed") { + expect(unsnoozed.payload.reason).toBe("activity"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cba967afc7c..100369ae6e3 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -53,6 +53,13 @@ function isStaleRequestFailureDetail(payload: Record | null): b ); } +// Scans the read model's activities, which the projector caps at the most +// recent 500. That bound is safe here: an OPEN approval/user-input request +// blocks its turn, so the thread cannot accumulate hundreds of later +// activities while one is outstanding โ€” a request that has scrolled out of +// the window is one whose turn kept running, i.e. it was resolved or went +// stale. (The projection pipeline's pendingApprovalCount reads the same +// capped stream and stays consistent with this view.) function hasOpenBlockingRequest(thread: { readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; }): boolean { @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } +/** + * A queued turn start โ€” a user message no turn has picked up yet โ€” is work + * in flight even though session is still null (turn.start emits + * message-sent + turn-start-requested; the session arrives later). Detection + * mirrors the client's hasQueuedTurnStart: the newest user message is + * strictly newer than every latestTurn timestamp (adoption stamps the new + * turn's requestedAt with the message time, clearing this), and only within + * the adoption grace window โ€” historical threads whose last user message + * postdates their turn timestamps (older-server data, mid-turn messages) + * must not be blocked forever. A failed session start (status "error") + * clears the block immediately. + * + * The age check is bounded on BOTH sides: message timestamps are + * client-supplied, so a client clock ahead of the server yields a negative + * age. Without the lower bound that negative age satisfies `<= grace` for + * as long as the skew lasts, extending the block far past the intended two + * minutes. + */ +function threadHasQueuedTurnStart( + thread: { + readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; + readonly latestTurn: { + readonly requestedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + occurredAt: string, +): boolean { + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + return ( + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + ); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); } const occurredAt = yield* nowIso; - // A queued turn start โ€” a user message no turn has picked up yet โ€” is - // work in flight even though session is still null (turn.start emits - // message-sent + turn-start-requested; the session arrives later). - // Settling in that window would hide just-requested work. Detection - // mirrors the client's hasQueuedTurnStart: the newest user message is - // strictly newer than every latestTurn timestamp (adoption stamps the - // new turn's requestedAt with the message time, clearing this), and - // only within the adoption grace window โ€” historical threads whose - // last user message postdates their turn timestamps (older-server - // data, mid-turn messages) must stay settleable. A failed session - // start (status "error") clears the block immediately. - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - // The age check is bounded on BOTH sides: message timestamps are - // client-supplied, so a client clock ahead of the server yields a - // negative age. Without the lower bound that negative age satisfies - // `<= grace` for as long as the skew lasts, extending the settle - // block far past the intended two minutes. - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - const hasQueuedTurnStart = - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; - if (hasQueuedTurnStart) { + // Settling inside the adoption window would hide just-requested work. + if (threadHasQueuedTurnStart(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.snooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // A wake time in the past would create a thread that is snoozed and + // woken at once โ€” the row would never leave the inbox but still carry + // snooze state. Reject instead of silently normalizing. The negated + // comparison also catches unparseable wake times (IsoDateTime is + // structurally just a string): NaN fails every comparison, and an + // unparseable snoozedUntil must never persist. + if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }), + ); + } + // Blocked-on-you work must not be snoozed away: a pending approval or + // user-input request is the agent waiting on the user, and hiding it + // defeats the request. (A running session IS snoozable โ€” snooze only + // affects visibility, never the agent.) + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }), + ); + } + // A queued turn start โ€” a user message no turn has adopted yet โ€” is + // invisible pending work: no session, no pending flags. Snoozing in + // that window would hide a just-requested turn exactly the way settle + // would. + if (threadHasQueuedTurnStart(thread, occurredAt)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }), + ); + } + // Re-snoozing an already-snoozed thread to the SAME wake time is a + // duplicate (double-click, raced clients): re-emit with the original + // timestamps so the projection is a no-op. A different wake time is a + // real change and stamps fresh. + const existingSnoozedAt = + thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null + ? thread.snoozedAt + : null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.snoozed", + payload: { + threadId: command.threadId, + snoozedUntil: command.snoozedUntil, + snoozedAt: existingSnoozedAt ?? occurredAt, + updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsnooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): waking a thread that + // is not snoozed lands on the same null state without churning + // updatedAt. + const alreadyAwake = thread.snoozedUntil == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // Real activity resets ANY override: it wakes an explicitly settled // thread, and it clears a keep-active pin back to neutral so the // thread can auto-settle again after this burst of work goes stale. - if (targetThread.settledOverride === null) { - return [userMessageEvent, turnStartRequestedEvent]; + // A snooze clears the same way โ€” sending a message to a snoozed + // thread is the user re-engaging, so the return ticket is spent. + const lifecycleResetEvents: Array> = []; + if (targetThread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; + if (targetThread.snoozedUntil != null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Only a session coming alive is activity worth waking a settled thread // for โ€” status writes like ready/stopped/error arrive after the fact and - // must not fight a user's explicit settle. + // must not fight a user's explicit settle. Snooze is deliberately NOT + // cleared here: snooze never pauses the agent, so its session starting + // or erroring is not the user re-engaging. Blocked/failed work still + // surfaces immediately โ€” effectiveSnoozed refuses to classify a thread + // with a raised hand (approval / input / failure / fresh completion) + // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; // Real activity resets ANY override (settled wakes, active unpins). diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e9a55c0796b..9c07a312023 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,8 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c8f47dcebbf..0504cb36f9a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -23,8 +23,10 @@ import { ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, + ThreadSnoozedPayload, ThreadUnarchivedPayload, ThreadUnsettledPayload, + ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -290,6 +292,8 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], activities: [], @@ -365,6 +369,30 @@ export function projectEvent( })), ); + case "thread.snoozed": + return decodeForEvent(ThreadSnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: payload.snoozedUntil, + snoozedAt: payload.snoozedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsnoozed": + return decodeForEvent(ThreadUnsnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: null, + snoozedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 497aa8b7d2c..4763f565653 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + snoozedUntil: "2026-03-26T09:00:00.000Z", + snoozedAt: "2026-03-25T00:00:00.000Z", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { } assert.strictEqual(row.settledOverride, "settled"); assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); + assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); - // Un-settle to the keep-active pin and confirm the flip persists. + // Un-settle to the keep-active pin and wake the snooze; confirm the + // flips persist. yield* threads.upsert({ ...row, settledOverride: "active", settledAt: null, + snoozedUntil: null, + snoozedAt: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.snoozedUntil, null); + assert.strictEqual(updated?.snoozedAt, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index e0c85e91494..7e86d49eac3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -45,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at, settled_override, settled_at, + snoozed_until, + snoozed_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.snoozedUntil}, + ${row.snoozedAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + snoozed_until = excluded.snoozed_until, + snoozed_at = excluded.snoozed_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 2855baf58f9..7e7c54ec4d1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,7 @@ import Migration0033 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0034 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0035 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0036 from "./Migrations/033_ProjectionThreadsSettled.ts"; +import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; /** * Migration loader with all migrations defined inline. @@ -104,6 +105,7 @@ export const migrationEntries = [ [34, "AuthAuthorizationScopes", Migration0034], [35, "AuthPairingProofKeyThumbprint", Migration0035], [36, "ProjectionThreadsSettled", Migration0036], + [37, "ProjectionThreadsSnoozed", Migration0037], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts new file mode 100644 index 00000000000..5259ae44501 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "snoozed_until")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_until TEXT + `; + } + + if (!columns.some((column) => column.name === "snoozed_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 8057d434950..056425ae886 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -38,6 +38,8 @@ export const ProjectionThread = Schema.Struct({ archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + snoozedUntil: Schema.NullOr(IsoDateTime), + snoozedAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ebaf1e2c264..ef281d91e78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,7 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { + AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, @@ -3949,7 +3950,24 @@ function ChatViewContent(props: ChatViewProps) { hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const activeThreadSnoozed = + activeThreadShell !== null && + supportsSnooze && + effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + void snoozeWakeTick; + if (!activeThreadSnoozed) return; + const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); + if (!Number.isFinite(wakeAtMs)) return; + const id = window.setTimeout( + () => bumpSnoozeWakeTick((tick) => tick + 1), + Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -3995,6 +4013,34 @@ function ChatViewContent(props: ChatViewProps) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); + const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + reportFailure: false, + }); + const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); + const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; + const handleUnsnoozeActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsnoozingThreadKey(threadKey); + try { + const result = await unsnoozeThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + 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.", + }), + ); + } + } finally { + setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsnoozeThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -4104,29 +4150,48 @@ function ChatViewContent(props: ChatViewProps) { ]); // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, - // and the informational settled banner last โ€” it must never cover another. - const settledComposerBannerItem = useMemo(() => { - if (!activeThreadSettled) { + // and the informational parked-thread banner last โ€” it must never cover another. + const parkedThreadBannerItem = useMemo(() => { + if (!activeThreadSnoozed && !activeThreadSettled) { return null; } + const isSnoozed = activeThreadSnoozed; return { - id: `thread-settled:${activeThread?.id ?? "unknown"}`, + id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, variant: "info", - icon: , - title: "This thread is settled", - description: "Sending a message moves it back to Active in the sidebar.", + icon: isSnoozed ? : , + title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: isSnoozed + ? "Sending a message wakes it and moves it back to Active in the sidebar." + : "Sending a message moves it back to Active in the sidebar.", actions: ( ), }; - }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); + }, [ + activeThread?.id, + activeThreadSettled, + activeThreadSnoozed, + handleUnsnoozeActiveThread, + handleUnsettleActiveThread, + isUnsnoozing, + isUnsettling, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4135,9 +4200,9 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...settledItems]; + return [...systemComposerBannerItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, @@ -4180,14 +4245,14 @@ function ChatViewContent(props: ChatViewProps) { setBranchMismatchDismissTick((tick) => tick + 1); }, }, - ...settledItems, + ...parkedThreadItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, - settledComposerBannerItem, + parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts new file mode 100644 index 00000000000..4a4518a2591 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; + +// Local-time constructor so preset math is timezone-stable in tests. +function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +describe("resolveSnoozePresets", () => { + it("offers hour, evening, tomorrow, next week in the morning", () => { + // Wednesday 2026-04-08 10:00 local. + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "evening", + "tomorrow", + "next-week", + ]); + const evening = presets.find((preset) => preset.id === "evening"); + expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + const tomorrowDate = new Date(tomorrow!.snoozedUntil); + expect(tomorrowDate.getDate()).toBe(9); + expect(tomorrowDate.getHours()).toBe(9); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + const nextWeekDate = new Date(nextWeek!.snoozedUntil); + expect(nextWeekDate.getDay()).toBe(1); + expect(nextWeekDate.getDate()).toBe(13); + }); + + it("whenLabel complements the label instead of repeating it", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + for (const preset of presets) { + // Day words live in the label column; the time column is time-only + // (plus a weekday for next week, which names a different day). + expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); + } + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + expect(tomorrow!.whenLabel).toMatch(/9/); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + expect(nextWeek!.whenLabel).toMatch(/Mon/); + }); + + it("drops the evening preset once evening is near or past", () => { + expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + }); + + it("puts next week a full week out when today is Monday", () => { + // Monday 2026-04-06. + const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10)); + const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); + expect(nextWeek.getDay()).toBe(1); + expect(nextWeek.getDate()).toBe(13); + }); +}); + +describe("snoozeWakeLabel", () => { + const now = localDate(2026, 4, 8, 10); + + it("formats minutes, hours, and days, rounding up", () => { + expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); + expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); + expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); + }); + + it("reports now for past and malformed wake times", () => { + expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); + expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); + }); +}); + +describe("snoozeWakeDescription", () => { + const now = localDate(2026, 4, 8, 10); + + it("uses bare time today, 'tomorrow' next day, weekday within the week", () => { + expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now)).not.toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/); + }); +}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts new file mode 100644 index 00000000000..dd56cec7290 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -0,0 +1,127 @@ +/** + * Snooze preset resolution for the sidebar snooze menu. Pure functions so + * the preset math (evening/tomorrow/next-week boundaries) is unit-testable + * without a DOM. + * + * Presets deliberately skew short: agent-thread rhythms are hours (a CI + * run, a teammate review, the next work session), not days. + */ +import { parseTimestampDate } from "../timestampFormat"; + +type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; + +export interface SnoozePreset { + readonly id: SnoozePresetId; + readonly label: string; + /** Menu-row time column. Complements the label instead of repeating it: + "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + readonly whenLabel: string; + /** ISO wake time. */ + readonly snoozedUntil: string; +} + +function timeOfDayLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +const EVENING_HOUR = 18; +const MORNING_HOUR = 9; +const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; + +function atHour(base: Date, hour: number): Date { + const next = new Date(base); + next.setHours(hour, 0, 0, 0); + return next; +} + +// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets +// land on the wrong local day across DST transitions (a spring-forward day +// is 23 hours, so 23:30 + 24h skips the whole next day). +function addDays(base: Date, days: number): Date { + const next = new Date(base); + next.setDate(next.getDate() + days); + return next; +} + +/** + * Presets for "snooze until", computed against local time. "This evening" + * only appears while it is still meaningfully before evening; after that + * the list starts at "Tomorrow". + */ +export function resolveSnoozePresets(now: Date): ReadonlyArray { + const inAnHour = new Date(now.getTime() + HOUR_MS); + const presets: SnoozePreset[] = [ + { + id: "hour", + label: "In 1 hour", + whenLabel: timeOfDayLabel(inAnHour), + snoozedUntil: inAnHour.toISOString(), + }, + ]; + + const evening = atHour(now, EVENING_HOUR); + // Suppress the evening preset once it is within an hour (or past): it + // would duplicate "In 1 hour" or point at the past. + if (evening.getTime() - now.getTime() > HOUR_MS) { + presets.push({ + id: "evening", + label: "This evening", + whenLabel: timeOfDayLabel(evening), + snoozedUntil: evening.toISOString(), + }); + } + + const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); + presets.push({ + id: "tomorrow", + label: "Tomorrow", + whenLabel: timeOfDayLabel(tomorrow), + snoozedUntil: tomorrow.toISOString(), + }); + + // Next Monday 9:00 (a week out when today is Monday). + const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); + presets.push({ + id: "next-week", + label: "Next week", + whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, + snoozedUntil: nextWeek.toISOString(), + }); + + return presets; +} + +/** + * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes + * round up so a snooze never reads "0m" while still hidden. + */ +export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return "now"; + const remainingMs = wake.getTime() - now.getTime(); + if (remainingMs <= 0) return "now"; + if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; + if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; + return `${Math.ceil(remainingMs / DAY_MS)}d`; +} + +/** + * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", + * "17:30" (today). + */ +export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return ""; + const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + if (dayDelta === 0) return time; + if (dayDelta === 1) return `tomorrow ${time}`; + const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); + if (dayDelta < 7) return `${weekday} ${time}`; + const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return `${date}, ${time}`; +} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c2995359034..e018491348e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,11 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -9,11 +14,15 @@ import { } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { + AlarmClockIcon, + AlarmClockOffIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, + ClockIcon, CopyIcon, FolderIcon, FolderPlusIcon, @@ -36,6 +45,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; -import { formatRelativeTimeLabel } from "../timestampFormat"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, + firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -107,6 +118,12 @@ import { } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveSnoozePresets, + snoozeWakeDescription, + snoozeWakeLabel, + type SnoozePreset, +} from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({ ); } +/** + * Hover entry point for snooze: a clock button opening the preset menu. + * Controlled by the row (which also uses the open state to pin its hover + * actions while the menu is up). + */ +function SnoozePopoverButton(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSnooze: (preset: SnoozePreset) => void; +}) { + const { open, onOpenChange, onSnooze } = props; + // Presets resolve at open time so "In 1 hour" is relative to the click, + // not to when the row mounted. + const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]); + return ( + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + > + + + + {presets.map((preset) => ( + + ))} + + + ); +} + 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"; + variantAction: "settle" | "unsettle" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + // Same contract for thread.snooze/unsnooze. + snoozeSupported: boolean; + // Compact wake countdown ("2h") for rows in the snoozed shelf. + snoozeWakeLabelText: string | null; + // When a snooze ended (timer or early wake); drives the Woke pill until + // the user visits the thread. + wokeAt: string | null; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { 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; }) { const { @@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu, onRenameTitleChange, onSettle, + onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, + onUnsnooze, renamingTitle, thread, variant, @@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); + // A woken thread reappears at its original position (the sort is + // deliberately static), so the pill has to carry the weight. Snoozing is + // an explicit act, so unlike Done, a never-visited woke thread still + // shows the pill; visiting clears it. An unparseable visit timestamp + // counts as never-visited โ€” corrupt local data must not eat the wake + // signal. + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human โ€” done (unread), read-but-unsettled, and failed. - // The status label keeps its hue, so waiting rows stay findable. In-flight - // rows recede the same as read-ready ones (inbox-zero: working threads - // aren't your problem yet) โ€” only the colored status label stands out. + // rows that need a human โ€” done (unread), read-but-unsettled, failed, and + // freshly woken. The status label keeps its hue, so waiting rows stay + // findable. In-flight rows recede the same as read-ready ones (inbox-zero: + // working threads aren't your problem yet) โ€” only the colored status label + // stands out. const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; + (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. @@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { icon: null, className: "text-red-700 dark:text-red-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", } - : null; + : 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,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsettle, threadRef], ); + const handleUnsnoozeClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsnooze(threadRef); + }, + [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); @@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "card" ? cn( "truncate", - isUnread + isUnread || isWoke ? "text-foreground" : shouldRecede ? "text-muted-foreground/80" @@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : cn( "truncate group-hover/v2-row:text-foreground", - props.isActive + props.isActive || isWoke ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {prBadge} - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched โ€” the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + Woke + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + ) : null}
@@ -819,7 +999,8 @@ export default function SidebarV2() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = + useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -954,6 +1135,12 @@ export default function SidebarV2() { // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); + // Snooze wake times are second-precise, so classifying with the quantized + // minute would hold a woken thread on the shelf for up to a minute. The + // tick is a plain counter bumped exactly at the next wake boundary (armed + // below, after the partition knows the boundary); the partition reads a + // fresh clock whenever it recomputes. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. @@ -1165,8 +1352,14 @@ 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, settledThreads } = useMemo(() => { + 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 && @@ -1174,6 +1367,7 @@ export default function SidebarV2() { 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, @@ -1182,9 +1376,16 @@ export default function SidebarV2() { // 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; - if ( + // 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 }) ) { @@ -1195,7 +1396,14 @@ export default function SidebarV2() { } 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, @@ -1203,9 +1411,28 @@ export default function SidebarV2() { 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 + // soonest-first, so entry 0 is the boundary. + useEffect(() => { + const nextWakeAtMs = + snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null + ? Date.parse(snoozedThreads[0].snoozedUntil) + : Number.NaN; + if (Number.isNaN(nextWakeAtMs)) return; + // setTimeout delays are signed 32-bit: anything larger overflows and + // fires immediately, turning a far-future wake (event-condition snoozes + // synced from elsewhere) into a tight re-arm loop. Clamped, the timer + // just re-arms every ~24.8 days until the wake is in range. + const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => window.clearTimeout(id); + }, [snoozedThreads]); + // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the // filter context changes so a scope/search flip never inherits a deep @@ -1239,10 +1466,40 @@ export default function SidebarV2() { () => 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]); + + // 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 + // 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; + // 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]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSettledThreads], - [activeThreads, visibleSettledThreads], + () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -1287,6 +1544,17 @@ export default function SidebarV2() { ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; + const snoozedThreadKeys = useMemo( + () => + new Set( + snoozedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [snoozedThreads], + ); + const snoozedThreadKeysRef = useRef(snoozedThreadKeys); + snoozedThreadKeysRef.current = snoozedThreadKeys; const jumpLabelByKey = useMemo(() => { const mapping = new Map(); @@ -1382,6 +1650,36 @@ export default function SidebarV2() { // A settle per thread at a time: double clicks and repeated menu picks // must not dispatch a second settle that fails and toasts a false error. const settlingThreadKeysRef = useRef(new Set()); + // Parking the thread you're looking at (settle or snooze) moves you + // forward: the next remaining card (never a settled or snoozed row, never + // one leaving in the same batch), or a fresh draft in this project when it + // was the last active one. Callers snapshot the plan BEFORE the command + // mutates the partition; background parks never navigate (null plan). + const planForwardNavigation = useCallback( + (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { + if (routeThreadKeyRef.current !== threadKey) return null; + const shell = threadByKeyRef.current.get(threadKey); + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const snoozedKeys = snoozedThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( + (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), + ) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + return nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + : () => void router.navigate({ to: "/" }); + }, + [navigateToThread, router], + ); + const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { @@ -1389,34 +1687,7 @@ export default function SidebarV2() { if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); 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); - 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; - navigateAfterSettle = nextThread - ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : shell - ? () => - void handleNewThreadRef.current( - scopeProjectRef(shell.environmentId, shell.projectId), - ) - : () => void router.navigate({ to: "/" }); - } + 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. @@ -1442,7 +1713,7 @@ export default function SidebarV2() { } })(); }, - [navigateToThread, routeThreadKey, router, settleThread], + [planForwardNavigation, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -1462,6 +1733,80 @@ export default function SidebarV2() { }, [unsettleThread], ); + const attemptUnsnooze = useCallback( + (threadRef: ScopedThreadRef) => { + 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.", + }), + ); + } + })(); + }, + [unsnoozeThread], + ); + // One snooze per thread at a time โ€” same double-dispatch guard as settle. + const snoozingThreadKeysRef = useRef(new Set()); + const attemptSnooze = useCallback( + ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) return; + snoozingThreadKeysRef.current.add(threadKey); + 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.", + }), + ); + } + 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), + }, + }), + ); + // 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) { + navigateAfterSnooze?.(); + } + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [attemptUnsnooze, planForwardNavigation, snoozeThread], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -1477,10 +1822,35 @@ 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) => { + const thread = threadByKeyRef.current.get(threadKey); + return thread ? [thread] : []; + }); + const canSnoozeSelection = snoozableThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + canSnooze(thread, { now: selectionNow }), + ); + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ { id: "settle", label: `Settle (${count})` }, + ...(canSnoozeSelection + ? [ + { + id: "snooze", + label: `Snooze (${count})`, + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1488,6 +1858,23 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (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, + }); + } + 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 @@ -1552,11 +1939,13 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, removeFromSelection, + serverConfigs, ], ); @@ -1580,7 +1969,12 @@ export default function SidebarV2() { const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); + const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + // Presets resolve at menu-open time (same as the popover). + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -1599,6 +1993,21 @@ export default function SidebarV2() { : { id: "settle", label: "Settle thread" }, ] : []), + ...(supportsSnooze + ? [ + isSnoozed + ? { id: "unsnooze", label: "Wake thread" } + : { + id: "snooze", + label: "Snooze", + disabled: !canSnooze(thread, { now: new Date().toISOString() }), + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -1607,6 +2016,13 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) attemptSnooze(threadRef, preset); + return; + } switch (clicked.value) { case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it @@ -1637,6 +2053,9 @@ export default function SidebarV2() { case "unsettle": attemptUnsettle(threadRef); return; + case "unsnooze": + attemptUnsnooze(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -1676,7 +2095,9 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, attemptUnsettle, + attemptUnsnooze, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, @@ -1930,7 +2351,7 @@ export default function SidebarV2() {
) : null} - +
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey( - scopeThreadRef(previousThread.environmentId, previousThread.id), - ), + {(() => { + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "active" | "snoozed" | "settled", + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - + // 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"; + return ( + + ); + }; + const items: ReactNode[] = activeThreads.map((thread) => + renderThreadRow(thread, "active"), ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail โ€” - // and row heights stay independent of neighbor classification. - return [ -
  • -
    - Settled - -
    -
  • , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( + // 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) { + items.push( +
  • + +
  • , + ); + for (const thread of visibleSnoozedThreads) { + items.push(renderThreadRow(thread, "snoozed")); + } + } + if (settledThreads.length > 0) { + items.push( +
  • + +
  • , + ); + } + for (const thread of renderedSettledThreads) { + items.push(renderThreadRow(thread, "settled")); + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? (
+ {codeViewFiles.length > 0 && ( + + + } + > + {allDiffFilesCollapsed ? ( + + ) : ( + + )} + + + {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} + + + )} { + it("reports whether every rendered file is collapsed", () => { + expect(areAllDiffFilesCollapsed(FILE_KEYS, new Set(FILE_KEYS))).toBe(true); + expect(areAllDiffFilesCollapsed(FILE_KEYS, new Set([FIRST_FILE_KEY]))).toBe(false); + expect(areAllDiffFilesCollapsed([], new Set())).toBe(false); + }); + + it("collapses all files when any rendered file is expanded", () => { + expect(toggleAllDiffFiles(FILE_KEYS, new Set([FIRST_FILE_KEY]))).toEqual(new Set(FILE_KEYS)); + }); + + it("expands all files when every rendered file is collapsed", () => { + expect(toggleAllDiffFiles(FILE_KEYS, new Set(FILE_KEYS))).toEqual(new Set()); + }); +}); diff --git a/apps/web/src/lib/diffCollapse.ts b/apps/web/src/lib/diffCollapse.ts new file mode 100644 index 00000000000..51bdcaf94ac --- /dev/null +++ b/apps/web/src/lib/diffCollapse.ts @@ -0,0 +1,13 @@ +export function areAllDiffFilesCollapsed( + fileKeys: ReadonlyArray, + collapsedFileKeys: ReadonlySet, +): boolean { + return fileKeys.length > 0 && fileKeys.every((fileKey) => collapsedFileKeys.has(fileKey)); +} + +export function toggleAllDiffFiles( + fileKeys: ReadonlyArray, + collapsedFileKeys: ReadonlySet, +): ReadonlySet { + return areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? new Set() : new Set(fileKeys); +} From b4356cf04d0c1ccf6da258bf1334008027ad209f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 19:28:37 -0700 Subject: [PATCH 18/21] feat(web): show fast mode as a bolt instead of a "Normal" label (#4488) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 5719e8ac4020dda0e375ef61d044b61f55a0df8a) --- .../src/components/chat/TraitsPicker.test.ts | 110 ++++++++++++++++++ apps/web/src/components/chat/TraitsPicker.tsx | 79 +++++++++---- 2 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/TraitsPicker.test.ts diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts new file mode 100644 index 00000000000..93157490090 --- /dev/null +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { buildTraitsTriggerDisplay } from "./TraitsPicker"; + +function selectDescriptor( + id: string, + options: ReadonlyArray<{ id: string; label: string }>, + currentValue: string, +): Extract { + return { id, label: id, type: "select", options: [...options], currentValue }; +} + +function fastModeDescriptor( + currentValue: boolean, +): Extract { + return { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue }; +} + +const EFFORT = selectDescriptor( + "effort", + [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + "high", +); +const CONTEXT_WINDOW = selectDescriptor( + "contextWindow", + [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M" }, + ], + "1m", +); + +function display(descriptors: ReadonlyArray, fastModeEnabled: boolean) { + return buildTraitsTriggerDisplay({ + descriptors, + primarySelectDescriptorId: "effort", + ultrathinkPromptControlled: false, + fastModeEnabled, + }); +} + +describe("buildTraitsTriggerDisplay", () => { + it("omits fast mode from the label entirely when it is off", () => { + expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW], false)).toEqual({ + label: "High ยท 1M", + showFastModeIcon: false, + }); + }); + + it("shows the bolt instead of a text label when fast mode is on", () => { + expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW], true)).toEqual({ + label: "High ยท 1M", + showFastModeIcon: true, + }); + }); + + it("keeps non-fastMode booleans as text labels", () => { + const thinking: Extract = { + id: "thinking", + label: "Thinking", + type: "boolean", + currentValue: true, + }; + expect(display([EFFORT, thinking], false)).toEqual({ + label: "High ยท Thinking On", + showFastModeIcon: false, + }); + }); + + it("falls back to a text label when fast mode is the only trait", () => { + expect(display([fastModeDescriptor(true)], true)).toEqual({ + label: "Fast", + showFastModeIcon: false, + }); + expect(display([fastModeDescriptor(false)], false)).toEqual({ + label: "Normal", + showFastModeIcon: false, + }); + }); + + it("stays blank when descriptors resolve to no label and there is no fast mode", () => { + // A select with neither a currentValue nor an isDefault option yields no + // label. Without a fastMode descriptor present that must stay blank rather + // than falling through to a bogus "Normal". + const unresolved: Extract = { + id: "effort", + label: "effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }; + expect(display([unresolved], false)).toEqual({ label: "", showFastModeIcon: false }); + }); + + it("still renders the prompt-controlled ultrathink label alongside the bolt", () => { + expect( + buildTraitsTriggerDisplay({ + descriptors: [EFFORT, fastModeDescriptor(true)], + primarySelectDescriptorId: "effort", + ultrathinkPromptControlled: true, + fastModeEnabled: true, + }), + ).toEqual({ label: "Ultrathink", showFastModeIcon: true }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 60f7f828ad4..d48fe484eb6 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, ZapIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; import { Menu, @@ -379,6 +379,46 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); +/** + * Build the traits trigger's text label plus whether the fast-mode bolt should + * render. Fast mode is a lightning bolt when on and nothing at all when off โ€” + * "Normal" is the near-universal case and isn't worth the horizontal space. The + * one exception is when fast mode is the only trait, where a bare bolt (or bare + * chevron) would leave the trigger unreadable. + */ +export function buildTraitsTriggerDisplay(input: { + descriptors: ReadonlyArray; + primarySelectDescriptorId: string | null; + ultrathinkPromptControlled: boolean; + fastModeEnabled: boolean; +}): { label: string; showFastModeIcon: boolean } { + let hasFastMode = false; + const labels: Array = []; + for (const descriptor of input.descriptors) { + if (descriptor.id === "fastMode" && descriptor.type === "boolean") { + hasFastMode = true; + continue; + } + const label = + input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId + ? "Ultrathink" + : descriptor.type === "boolean" + ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` + : getProviderOptionCurrentLabel(descriptor); + if (typeof label === "string" && label.length > 0) { + labels.push(label); + } + } + + // Only fall back to text when fast mode is genuinely the sole trait. Keying + // off an empty label list alone would also catch descriptors that resolved to + // no label at all, printing a bogus "Normal" for a model without fast mode. + if (labels.length === 0 && hasFastMode) { + return { label: input.fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false }; + } + return { label: labels.join(" ยท "), showFastModeIcon: input.fastModeEnabled }; +} + export const TraitsPicker = memo(function TraitsPicker({ provider, instanceId, @@ -393,7 +433,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled, fastModeEnabled } = getTraitsSectionVisibility({ provider, models, @@ -415,23 +455,18 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const triggerLabels: Array = []; - for (const descriptor of descriptors) { - const label = - ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id - ? "Ultrathink" - : descriptor.type === "boolean" - ? descriptor.id === "fastMode" - ? descriptor.currentValue === true - ? "Fast" - : "Normal" - : `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel(descriptor); - if (typeof label === "string" && label.length > 0) { - triggerLabels.push(label); - } - } - const triggerLabel = triggerLabels.join(" ยท "); + const { label: triggerLabel, showFastModeIcon } = buildTraitsTriggerDisplay({ + descriptors, + primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled, + fastModeEnabled, + }); + const fastModeIcon = showFastModeIcon ? ( + <> +