From be1a836745395286cbd392512179ab5816f538ba Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:50:54 +0000 Subject: [PATCH 01/81] chore(release): prepare v0.0.32 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1526965427f..a69cda53bf4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 8e7b5b38591..360f00569b5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.31", + "version": "0.0.32", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 0a2f0d8e86b..f396bff7a5e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c3bd819023b..357156ec039 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.31", + "version": "0.0.32", "private": true, "files": [ "dist" From 6d70e6d77824eed51b1531a2f42b2ed5cdae8501 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 09:21:04 -0400 Subject: [PATCH 02/81] fix(mobile): reconnects no longer shift the thread list (#5372) Co-authored-by: Claude Opus 5 (1M context) --- .../src/components/CompactBrandTitle.tsx | 23 ++- apps/mobile/src/features/home/HomeHeader.tsx | 35 ++-- .../src/features/home/HomeRouteScreen.tsx | 20 +- apps/mobile/src/features/home/HomeScreen.tsx | 46 +---- .../home/WorkspaceConnectionStatus.tsx | 56 ----- .../home/WorkspaceConnectionTitle.tsx | 194 ++++++++++++++++++ ...ts => workspace-connection-status.test.ts} | 33 +++ .../home/workspace-connection-status.ts | 20 ++ .../threads/ThreadNavigationSidebar.tsx | 51 +++-- 9 files changed, 326 insertions(+), 152 deletions(-) delete mode 100644 apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx create mode 100644 apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx rename apps/mobile/src/features/home/{WorkspaceConnectionStatus.test.ts => workspace-connection-status.test.ts} (72%) diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f0710e85d36..28f7cfe57a7 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; +/** + * Horizontal correction applied to content rendered in the brand title slot, + * shared with the connection-status swap so both align identically. + */ +export function brandTitleOffset(nativeLeadingItem: boolean): number { + if (Platform.OS !== "ios") return 0; + if (nativeLeadingItem) { + return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; + } + return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; +} + /** * Compact brand lockup sized for native navigation bars. */ @@ -28,16 +40,7 @@ export function CompactBrandTitle( const mutedColor = useThemeColor("--color-foreground-muted"); const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = - Platform.OS !== "ios" - ? 0 - : props.nativeLeadingItem - ? Platform.isPad - ? IPAD_NATIVE_LEADING_TITLE_OFFSET - : IOS_NATIVE_LEADING_TITLE_OFFSET - : Platform.isPad - ? IPAD_HOME_TITLE_OFFSET - : 0; + const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); return ( void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - {stageLabel} - - - + {/* Brand slot doubles as the connection status surface: while an + environment reconnects, the lockup fades to a status label in + place (no layout shift in the list below). */} + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + {stageLabel} + + + + } + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail header. */} - + {/* Restore the compact title after the split branch blanks the detail + header. The brand slot doubles as the connection status surface: + while an environment reconnects, the lockup fades to a status label + in place (no layout shift in the list below). */} + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + })} + /> + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -161,9 +172,6 @@ export function HomeRouteScreen() { onUnpinThread={unpinThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b609..7de62898725 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -71,8 +71,6 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -97,7 +95,6 @@ interface HomeScreenProps { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; - readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -976,20 +973,13 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); + // Connection state surfaces in the header title slot + // (WorkspaceConnectionTitle) — nothing renders inside the list, so + // reconnects never shift the rows. const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; if (!hasAnyThreads) { return ( @@ -1008,41 +998,17 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading && !shouldShowConnectionStatus ? ( + {emptyState.loading ? ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + const listHeader = Platform.OS === "ios" ? null : ; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). @@ -1123,7 +1089,6 @@ export function HomeScreen(props: HomeScreenProps) { }} /> - {connectionStatus} ); } @@ -1178,7 +1143,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx deleted file mode 100644 index 1e986ad1a50..00000000000 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { ActivityIndicator, Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; -import type { WorkspaceState } from "../../state/workspaceModel"; -import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; - -export function WorkspaceConnectionStatus(props: { - readonly state: WorkspaceState; - readonly onPress: () => void; - readonly variant?: "floating" | "sidebar"; -}) { - const iconColor = useThemeColor("--color-icon-muted"); - const isSynchronizing = - props.state.networkStatus !== "offline" && - props.state.connectionError === null && - (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); - const variant = props.variant ?? "floating"; - - return ( - - {isSynchronizing ? ( - - ) : ( - - )} - - {workspaceConnectionStatusLabel(props.state)} - - {variant === "sidebar" ? ( - - ) : null} - - ); -} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx new file mode 100644 index 00000000000..1867042988b --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -0,0 +1,194 @@ +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useWorkspaceState } from "../../state/workspace"; +import { + workspaceConnectionStatusPresentation, + type WorkspaceConnectionStatusPresentation, +} from "./workspace-connection-status"; + +/** + * Delay before a connection interruption surfaces in the title slot. Sub-second + * blips (the common reconnect case) resolve without any UI at all. + */ +const STATUS_SHOW_DELAY_MS = 800; +const FADE_IN_MS = 250; + +/** + * Connection status presentation, debounced for display: null until the + * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, + * then live-updating until the workspace reconnects (null again immediately). + */ +function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { + const { state } = useWorkspaceState(); + const presentation = workspaceConnectionStatusPresentation(state); + const hasStatus = presentation !== null; + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!hasStatus) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS); + return () => clearTimeout(timer); + }, [hasStatus]); + + return visible ? presentation : null; +} + +/** + * One-shot entrance fade for the status label. Deliberately JS-driven: this can + * mount inside a native header item (RNSScreenStackHeaderSubview), where + * native-driver animated nodes blank the re-hosted view entirely. The JS driver + * updates opacity through the ordinary style path, which those subviews handle. + */ +function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.timing(opacity, { + duration: FADE_IN_MS, + toValue: 1, + useNativeDriver: false, + }); + animation.start(); + return () => animation.stop(); + }, [opacity]); + + return ( + + {props.children} + + ); +} + +/** + * Renders the brand/title slot of a thread-list surface, swapping the brand + * for the workspace connection status while an environment is unavailable. + * + * Both states occupy the same slot, so connection changes never shift the + * layout below. While connected the brand renders untouched — no wrapper — + * keeping the native header item on the exact element tree that predates the + * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted + * a row above the thread list. + */ +export function WorkspaceConnectionTitle(props: { + /** Content shown while connected (brand lockup or a screen title). */ + readonly brand: ReactNode; + /** Opens environment settings. Status is not pressable when omitted. */ + readonly onPress?: () => void; + /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */ + readonly grow?: boolean; + readonly size?: "navbar" | "pageTitle"; + /** Horizontal correction so the status aligns with the brand in native title slots. */ + readonly statusOffset?: number; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const status = useDelayedConnectionStatus(); + const size = props.size ?? "navbar"; + + if (status === null) { + return props.grow ? ( + + {props.brand} + + ) : ( + <>{props.brand} + ); + } + + return ( + + + {status.showsProgress ? ( + + ) : ( + + )} + + {status.label} + + + + ); +} + +/** + * getCompactBrandHeaderOptions with the brand slot upgraded to the + * connection-status swap. Screens with an environment-settings callback apply + * this over the static brand options at mount. + */ +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; +}): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ + { + element: ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(true)} + /> + ), + hidesSharedBackground: true, + type: "custom", + }, + ], + }; + } + + return { + headerTitle: () => ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(false)} + /> + ), + headerTitleStyle: opts.fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts similarity index 72% rename from apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts rename to apps/mobile/src/features/home/workspace-connection-status.test.ts index 8c3c873cc9e..15a990bb1cb 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -84,4 +85,36 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); + + it("presents nothing while connected", () => { + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); + }); + + it("presents progress while reconnecting but not while offline", () => { + const reconnecting = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); + + const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); + expect(workspaceConnectionStatusPresentation(offline)).toEqual({ + label: "You are offline", + showsProgress: false, + }); + }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..6f9898b1bb0 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -1,5 +1,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; +export interface WorkspaceConnectionStatusPresentation { + readonly label: string; + /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */ + readonly showsProgress: boolean; +} + export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || @@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { } return "Not connected"; } + +/** Header-title presentation of the connection state, or null while connected. */ +export function workspaceConnectionStatusPresentation( + state: WorkspaceState, +): WorkspaceConnectionStatusPresentation | null { + if (!shouldShowWorkspaceConnectionStatus(state)) return null; + return { + label: workspaceConnectionStatusLabel(state), + showsProgress: + state.networkStatus !== "offline" && + state.connectionError === null && + (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot), + }; +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 322ac60759d..206232ebe7b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -55,8 +55,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { + getConnectionAwareBrandHeaderOptions, + WorkspaceConnectionTitle, +} from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ { @@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane( - - - ) : null - } ListEmptyComponent={listEmpty} /> @@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane( - - Threads - + {/* Title slot doubles as the connection status surface: while an + environment reconnects, "Threads" fades to a status label in + place (no layout shift in the list below). */} + + Threads + + } + /> - - {showsConnectionStatus ? ( - - - - ) : null} ); From 5661c6116c9d6e9e93e59cf067fc02dd3303ceef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 09:57:22 -0400 Subject: [PATCH 03/81] feat(web): drag pinned threads into your own order (#5581) Co-authored-by: Claude Fable 5 --- .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 44 ++ .../src/features/home/useThreadListActions.ts | 112 ++++- .../threads/ThreadNavigationSidebar.tsx | 36 ++ .../features/threads/thread-list-v2-items.tsx | 45 +- .../src/features/threads/threadListV2.ts | 7 +- .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 20 + .../Layers/ProjectionSnapshotQuery.test.ts | 10 +- .../Layers/ProjectionSnapshotQuery.ts | 10 + apps/server/src/orchestration/Schemas.ts | 2 + .../src/orchestration/decider.pinned.test.ts | 98 +++++ apps/server/src/orchestration/decider.ts | 43 ++ .../orchestration/projector.pinned.test.ts | 77 ++++ apps/server/src/orchestration/projector.ts | 16 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../038_ProjectionThreadsPinOrderKey.ts | 16 + .../persistence/Services/ProjectionThreads.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 133 ++++++ apps/web/src/components/Sidebar.logic.ts | 9 + apps/web/src/components/SidebarV2.tsx | 386 ++++++++++++++---- apps/web/src/hooks/useThreadActions.ts | 72 +++- apps/web/src/state/entities.ts | 13 + docs/README.md | 1 + docs/user/thread-sidebar.md | 13 + .../client-runtime/src/operations/commands.ts | 11 + .../src/state/threadCommands.ts | 9 + .../client-runtime/src/state/threadDetail.ts | 1 + .../client-runtime/src/state/threadReducer.ts | 14 + .../src/state/threadSort.test.ts | 73 +++- .../client-runtime/src/state/threadSort.ts | 176 ++++++++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.ts | 38 ++ 34 files changed, 1407 insertions(+), 92 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts create mode 100644 docs/user/thread-sidebar.md diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index adfc4e15dca..7760920f7db 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -44,6 +44,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + movePinnedThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -170,6 +171,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7de62898725..64f0480d223 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -110,6 +111,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onMovePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -521,6 +526,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + void props.onMovePinnedThread(thread, direction); + }, + [props.onMovePinnedThread], + ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onUnpinThread(thread); @@ -595,6 +606,29 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order (reorder-capable threads only) for the + // Move up/down position flags. Computed from all shells, not the rendered + // list, so search/scope filtering never disables or misdirects a move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + props.threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, props.threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -781,11 +815,18 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0} + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onMovePinnedThread={handleMovePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -798,6 +839,8 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + arrangedPinnedKeys, + handleMovePinnedThread, handlePinThread, handleSettleThread, handleSnoozeThread, @@ -807,6 +850,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..3103c5379be 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,9 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { + pinOrderKeyBetween, + planPinnedMove, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; /** Version skew: never send settle/unsettle to a server that predates them @@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,6 +223,10 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly movePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); @@ -331,9 +347,21 @@ export function useThreadListActions(): { return false; } selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; + } + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } const result = await pinMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id }, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -378,6 +406,85 @@ export function useThreadListActions(): { [unpinMutation], ); + // Move up / Move down for the pinned block. Computed against the CANONICAL + // keyed pinned order (not the rendered list), so the move is valid even + // while search or a project scope filters rows: the same fractional-key + // scheme web dragging uses, one write to one thread per move (plus a + // one-time section materialization when legacy keyless pins are involved). + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); + // One move at a time: a second tap before the first write's event lands + // would plan from the same stale snapshot and silently collapse two moves + // into one — same double-dispatch guard as snoozeThread. + const movePinnedInFlightRef = useRef(false); + const movePinnedThread = useCallback( + async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (movePinnedInFlightRef.current) return false; + if (!environmentSupportsPinReorder(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + ); + return false; + } + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const pinned = sortPinnedThreadsByOrderKey( + shells.filter( + (shell) => + shell.pinnedAt != null && + shell.archivedAt === null && + environmentSupportsPinReorder(shell.environmentId), + ), + ); + const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map( + pinned.map((shell) => [ + scopedThreadKey(shell.environmentId, shell.id), + shell.pinOrderKey ?? null, + ]), + ), + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + }); + if (assignments === null || assignments.length === 0) return false; + const shellByKey = new Map( + pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + selectionHaptic(); + movePinnedInFlightRef.current = true; + try { + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned thread could not be moved.", + ); + // No rollback: keys already written are valid orderings on their + // own (each write is a complete, consistent placement), so a + // partial materialization leaves the list sensible, not corrupt. + return false; + } + } + return true; + } finally { + movePinnedInFlightRef.current = false; + } + }, + [reorderPinnedMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -389,6 +496,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + movePinnedThread, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 206232ebe7b..d80d906ada1 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -209,6 +210,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + movePinnedThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -494,6 +496,28 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order for Move up/down flags — computed from + // all shells so search/scope filtering never disables a valid move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -930,11 +954,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={ + arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 + } + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1055,13 +1088,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, + arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + movePinnedThread, openPendingTask, + pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 24f7166916b..eb618146172 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.pin.reorder. Gates the pinned + Move up / Move down menu items. */ + readonly pinReorderSupported?: boolean; + readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the pinned block so the menu disables the move that + would fall off the end of the list. */ + readonly canMovePinnedUp?: boolean; + readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -382,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -416,6 +425,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleMovePinnedUp = useCallback( + () => onMovePinnedThread?.(thread, "up"), + [onMovePinnedThread, thread], + ); + const handleMovePinnedDown = useCallback( + () => onMovePinnedThread?.(thread, "down"), + [onMovePinnedThread, thread], + ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => props.pinningSupported ? [ + ...(pinnedRow && props.pinReorderSupported === true + ? [ + { + id: "move-pin-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMovePinnedUp !== true }, + } satisfies MenuAction, + { + id: "move-pin-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMovePinnedDown !== true }, + } satisfies MenuAction, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -491,6 +530,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); + if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -507,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleMovePinnedDown, + handleMovePinnedUp, handlePin, handleSettle, handleSnooze, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fa5f58d5d0e..ef9216ad96f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,7 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -383,8 +384,8 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin survives underneath, so a woken thread reappears at - // its original spot in the creation-ordered pinned block. + // hand). The pin (and its pinOrderKey) survives underneath, so a woken + // thread reappears at its exact spot in the pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreadsByOrderKey(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..c697b4bd98f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadPinning: true, + threadPinReorder: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7776e374ee2..38a70240d97 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pin-reordered": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5dda7aa86b..c89124751b5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + pinned_at, + pin_order_key, created_at, updated_at, deleted_at @@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + '2026-02-24T00:00:01.000Z', + 'gm', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -317,7 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, deletedAt: null, messages: [ @@ -433,7 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9633f162d2b..e744574a73c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ee96e422945..7e866cf8959 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -15,6 +15,7 @@ import { ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index bed41e13a17..4ad00ba994b 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinOrderKey?: string | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -44,6 +45,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinOrderKey: input.pinOrderKey ?? null, deletedAt: null, messages: [], proposedPlans: [], @@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); }), ); + + it.effect("a fresh pin carries the client's order key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBe("g"); + } + }), + ); + + it.effect( + "re-pinning ignores the incoming order key so raced pins cannot move a placed thread", + () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed-again"), + threadId: ThreadId.make("thread-1"), + orderKey: "t", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBeUndefined(); + } + }), + ); + + it.effect("reorders a pinned thread, stamping the new key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.orderKey).toBe("m"); + // A real move stamps the command time (the test clock), not the + // thread's previous updatedAt. + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("reordering onto the same key preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-noop"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects reordering an unpinned thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-unpinned"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..3de2592c884 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, + // A fresh pin takes the client's slot in the arranged order; on a + // re-pin the existing key wins so raced duplicates cannot move a + // thread the user already placed. + ...(existingPinnedAt === null && command.orderKey !== undefined + ? { pinOrderKey: command.orderKey } + : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; @@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Only pinned threads have a slot in the arranged order. Rejecting + // (rather than silently pinning) keeps a raced reorder-after-unpin + // from resurrecting a pin the user just cleared. + if (thread.pinnedAt == null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }), + ); + } + // Idempotent by re-emission (see thread.settle): a duplicate drop on + // the same slot keeps the existing updatedAt so it projects as a no-op. + const keyUnchanged = thread.pinOrderKey === command.orderKey; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + orderKey: command.orderKey, + updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts index 35bd063667a..791bd4b75e6 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () => expect(unpinned.threads[0]?.pinnedAt).toBeNull(); }), ); + +it.effect("projects pin order key lifecycle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinOrderKey ?? null).toBeNull(); + + // Fresh pin carries the client's slot in the arranged order. + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt: now, + pinOrderKey: "g", + updatedAt: now, + }, + }), + ); + expect(pinned.threads[0]?.pinOrderKey).toBe("g"); + + // Re-pins and events from pre-reorder servers omit the field entirely; + // the existing key must survive rather than being nulled out. + const repinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(repinned.threads[0]?.pinOrderKey).toBe("g"); + + // A drag persists the new slot. + const reordered = yield* projectEvent( + repinned, + makeEvent({ + sequence: 4, + type: "thread.pin-reordered", + payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now }, + }), + ); + expect(reordered.threads[0]?.pinOrderKey).toBe("m"); + + // Unpin clears the slot: re-pinning is "pin again", not "restore an + // ancient position". + const unpinned = yield* projectEvent( + reordered, + makeEvent({ + sequence: 5, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinOrderKey).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..5acf3ee6968 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,6 +24,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -402,6 +403,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: payload.pinnedAt, + ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}), updatedAt: payload.updatedAt, }), })), @@ -413,6 +415,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + // Unpin clears the slot: re-pinning is "pin again", not "restore + // an ancient position". + pinOrderKey: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.pin-reordered": + return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinOrderKey: payload.orderKey, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b..b7d8ae13747 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pin_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f335bdfda7..733c52fab3e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +100,7 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinOrderKey", Migration0038], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts new file mode 100644 index 00000000000..d6735ebdbfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts @@ -0,0 +1,16 @@ +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 === "pin_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pin_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298..c572e1d11cc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ac2716a196e..d15433e56b9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -26,6 +26,9 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, + pinOrderKeyBetween, + planPinnedReorder, + sortPinnedThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("pinOrderKeyBetween", () => { + it("produces keys that sort between their bounds", () => { + const middle = pinOrderKeyBetween(null, null)!; + const top = pinOrderKeyBetween(null, middle)!; + const bottom = pinOrderKeyBetween(middle, null)!; + expect(top < middle).toBe(true); + expect(middle < bottom).toBe(true); + + const between = pinOrderKeyBetween(top, middle)!; + expect(top < between && between < middle).toBe(true); + }); + + it("extends into new digits when bounds are adjacent", () => { + const key = pinOrderKeyBetween("g", "h")!; + expect("g" < key && key < "h").toBe(true); + }); + + it("stays strictly ordered under repeated top insertion", () => { + // Every new pin lands at the head of the arranged run; keys must keep + // sorting before the previous head without ever bottoming out. + let head: string | null = null; + const keys: string[] = []; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(null, head)!; + expect(key).not.toBeNull(); + if (head !== null) expect(key < head).toBe(true); + keys.push(key); + head = key; + } + expect(new Set(keys).size).toBe(100); + }); + + it("stays strictly ordered under repeated middle insertion", () => { + let low = pinOrderKeyBetween(null, null)!; + let high = pinOrderKeyBetween(low, null)!; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(low, high)!; + expect(low < key && key < high).toBe(true); + if (i % 2 === 0) low = key; + else high = key; + } + }); + + it("returns null for corrupt or out-of-order bounds instead of throwing", () => { + expect(pinOrderKeyBetween("z", "a")).toBeNull(); + expect(pinOrderKeyBetween("A!", null)).toBeNull(); + expect(pinOrderKeyBetween(null, "ma")).toBeNull(); + expect(pinOrderKeyBetween("m", "m")).toBeNull(); + }); +}); + +describe("planPinnedReorder", () => { + it("writes only the moved thread when neighbors are keyed", () => { + const assignments = planPinnedReorder({ + orderedIds: ["a", "c", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.id).toBe("c"); + expect(assignments[0]!.orderKey > "f" && assignments[0]!.orderKey < "m").toBe(true); + }); + + it("treats list edges as open bounds", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a"], + keysById: new Map([ + ["a", "m"], + ["b", null], + ]), + movedId: "b", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.orderKey < "m").toBe(true); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + }); + expect(assignments.map((entry) => entry.id)).toEqual(["b", "a", "c"]); + const keys = assignments.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("sortPinnedThreadsForSidebarV2", () => { + const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ + id: input.id, + createdAt: input.createdAt, + pinOrderKey: input.pinOrderKey ?? null, + }); + + it("sorts keyed threads by key ahead of keyless threads in creation order", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), + pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), + pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), + pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "first", + "second", + "keyless-new", + "keyless-old", + ]); + }); + + it("breaks equal keys by id so raced writes render identically everywhere", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), + pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("sortSettledThreadsForSidebarV2", () => { const settled = (input: { id: string; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 677e85c3746..e516822fd56 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -510,6 +510,15 @@ export function sortThreadsForSidebarV2< ); } +// Pinned-reorder key math and the keyed sort live in client-runtime +// (state/thread-sort) so web and mobile compute identical pinned orders. +export { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; + /** * Search the already-ordered sidebar thread collection by title only. * Keeping the input order means lifecycle ordering (active, snoozed, settled) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 81d88350808..2a34b65cf2a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,21 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSettled, @@ -116,6 +132,7 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, + planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, @@ -123,6 +140,7 @@ import { resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebarV2, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; @@ -390,6 +408,26 @@ function SnoozePopoverButton(props: { ); } +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -410,6 +448,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; // 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 @@ -974,10 +1016,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const diff = latestTurnDiff(thread); + const sortable = props.sortable; return (
  • ()( "ThreadPinningUnsupportedError", { @@ -109,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadPinReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -132,6 +158,9 @@ export function useThreadActions() { const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false, }); + const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -506,7 +535,7 @@ export function useThreadActions() { ); const pinThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( @@ -518,9 +547,21 @@ export function useThreadActions() { ), ); } + // Every pin path places the thread at the top of the arranged run: + // callers with a better anchor (the sidebar, which knows the displayed + // order) pass their own key; everyone else (chat header, context menus) + // gets the default so the same action never places differently. + // orderKey rides only to servers that decode it; pre-reorder servers + // get the bare pin they understand and the thread stays keyless. + const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) + ? (opts.orderKey ?? topOfPinnedRunOrderKey()) + : undefined; return pinThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId }, + input: { + threadId: target.threadId, + ...(orderKey !== undefined ? { orderKey } : {}), + }, }); }, [pinThreadMutation], @@ -546,6 +587,29 @@ export function useThreadActions() { [unpinThreadMutation], ); + const reorderPinnedThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + // Callers (the sidebar drag handler) only enable dragging on + // reorder-capable environments; this guard covers races around + // capability changes mid-drag. + if (!readEnvironmentSupportsPinReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinReorderUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderPinnedThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderPinnedThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -641,12 +705,14 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, }), [ archiveThread, confirmAndDeleteThread, deleteThread, pinThread, + reorderPinnedThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c0018b24935..7bca3118237 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm ); } +/** Whether the environment's server understands thread.pin.reorder (and + orderKey on thread.pin). Same version-skew contract as settlement. */ +export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } @@ -273,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); } +export function readThreadShells(): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); +} + export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { return ( appAtomRegistry diff --git a/docs/README.md b/docs/README.md index bc359826a04..b0006e954f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Organizing threads](./user/thread-sidebar.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md new file mode 100644 index 00000000000..99c180bafdd --- /dev/null +++ b/docs/user/thread-sidebar.md @@ -0,0 +1,13 @@ +# Organizing threads + +Pin a thread from its context menu to keep it in the pinned section above your active work. +Pinned threads are shown independently of their project, including when you connect to more than +one environment. + +On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu +and choose **Move up** or **Move down**. The order is stored by the server and appears on your +other connected devices. + +If reordering is unavailable for one environment, update the T3 Code server running in that +environment. Older servers can still pin and unpin threads, but do not understand synced ordering; +their pinned threads keep the default newest-first order below the ones you have arranged. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ee200d3a22d..cb74f117b77 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">; export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; +export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn }); }); +export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderPinnedThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pin.reorder", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 2eabc5aec16..ed3537e4f83 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -13,6 +13,7 @@ import { type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, type PinThreadInput, + type ReorderPinnedThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -32,6 +33,7 @@ import { setThreadInteractionMode, setThreadRuntimeMode, pinThread, + reorderPinnedThread, settleThread, snoozeThread, startThreadTurn, @@ -55,6 +57,7 @@ export type { SetThreadInteractionModeInput, SetThreadRuntimeModeInput, PinThreadInput, + ReorderPinnedThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderPin: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-pin", + execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 30e8ef58248..5a2ffa442e0 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -62,6 +62,7 @@ export function mergeEnvironmentThread( snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, + pinOrderKey: shell.pinOrderKey, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 0c6649f3868..970fd94b1a1 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -173,6 +173,9 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; @@ -183,6 +186,17 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.pin-reordered": + return { + kind: "updated", + thread: { + ...thread, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index dd6f8c3a295..f4ea270a001 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; +import { + planPinnedMove, + sortPinnedThreadsByOrderKey, + sortThreads, + type ThreadSortInput, +} from "./threadSort.ts"; type TestThread = { readonly id: string } & ThreadSortInput; @@ -69,3 +74,69 @@ describe("sortThreads", () => { expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); }); }); + +describe("planPinnedMove", () => { + it("moves a thread up with a single key write", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + direction: "up", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("c"); + expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true); + }); + + it("returns null when the move falls off the end of the list", () => { + const input = { + orderedIds: ["a", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ]), + }; + expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull(); + expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull(); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + direction: "up", + }); + expect(assignments).not.toBeNull(); + const keys = assignments!.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + }); +}); + +describe("sortPinnedThreadsByOrderKey", () => { + it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => { + const sorted = sortPinnedThreadsByOrderKey([ + { + id: "thread-1", + createdAt: "2026-03-09T10:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-b", + }, + { + id: "thread-1", + createdAt: "2026-03-09T11:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-a", + }, + ]); + expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aed63cd442d..9352d58dbc8 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -102,3 +102,179 @@ export function getLatestThreadForProject< )[0] ?? null ); } + +// ── Pinned reorder: fractional index keys ────────────────────────────── +// Pinned threads carry an optional pinOrderKey (a base-26 string). The +// pinned block sorts keyed threads by plain string comparison, so a drag +// (web) or Move up/down (mobile) writes ONE key to ONE thread on that +// thread's own server — neighbors, possibly living on other servers, are +// never touched, and every client connected to the same servers converges +// on the same order. +const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; + +function isValidPinOrderKey(key: string): boolean { + if (key.length === 0) return false; + for (const char of key) { + if (!PIN_ORDER_DIGITS.includes(char)) return false; + } + // A trailing minimum digit would leave no room to sort a key immediately + // before this one; generators never produce it, so treat it as corrupt. + return key.at(-1) !== PIN_ORDER_DIGITS[0]; +} + +/** Midpoint of two digit strings interpreted as fractions in (0, 1). + "" stands for the open bound on either side. Requires a < b. */ +function pinOrderMidpoint(a: string, b: string): string { + if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); + if (b !== "") { + // Recurse past the longest common prefix ("a" pads the shorter side). + let n = 0; + while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; + if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); + } + const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); + const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); + if (digitB - digitA > 1) { + return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); + } + // Consecutive leading digits: either b has spare digits to shorten into, + // or we extend a (never producing a trailing minimum digit — the base + // case midpoint("", "") is the middle of the alphabet). + if (b.length > 1) return b.charAt(0); + return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); +} + +/** Key that sorts strictly between two neighbors; null bounds mean "top of + the pinned block" / "bottom of the keyed run". Returns null instead of + throwing when existing keys are corrupt or out of order — callers fall + back to rewriting the section. */ +export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { + const a = before ?? ""; + const b = after ?? ""; + if (a !== "" && !isValidPinOrderKey(a)) return null; + if (b !== "" && !isValidPinOrderKey(b)) return null; + if (b !== "" && a >= b) return null; + return pinOrderMidpoint(a, b); +} + +/** Evenly spaced keys for rewriting a whole pinned section (used when a + drop lands next to keyless threads, so single-key insertion has nothing + to anchor on). Two base-26 digits give 675 slots — far beyond any real + pinned section — with monotonicity enforced as a belt-and-braces. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; + const step = space / (count + 1); + const keys: string[] = []; + let previous = 0; + for (let i = 0; i < count; i += 1) { + let value = Math.max(Math.round(step * (i + 1)), previous + 1); + // Skip values whose low digit is the minimum (a trailing "a" key). + if (value % PIN_ORDER_DIGITS.length === 0) value += 1; + value = Math.min(value, space - 1); + previous = value; + keys.push( + PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + + PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), + ); + } + return keys; +} + +/** + * Assignments needed to realize a new pinned order. When the moved thread + * sits between two keyed (or absent) neighbors, this is a single write to + * the moved thread. When a neighbor is keyless (threads pinned before + * reordering shipped), the whole section gets fresh spread keys — a + * one-time materialization; every move after that is single-write. + */ +export function planPinnedReorder(input: { + /** Thread ids in the desired visual order (after the move). */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { + const { orderedIds, keysById, movedId } = input; + const movedIndex = orderedIds.indexOf(movedId); + if (movedIndex === -1) return []; + const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; + const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; + const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; + const beforeUsable = beforeId === null || beforeKey != null; + const afterUsable = afterId === null || afterKey != null; + if (beforeUsable && afterUsable) { + const key = pinOrderKeyBetween(beforeKey, afterKey); + if (key !== null) return [{ id: movedId, orderKey: key }]; + } + // Keyless neighbor (or corrupt keys): rewrite the section in the new order. + const keys = generateSpreadPinOrderKeys(orderedIds.length); + return orderedIds.flatMap((id, index) => { + const key = keys[index]!; + return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; + }); +} + +/** + * Pinned block order: user-arranged keys first (string comparison, id + * tiebreak), then keyless threads newest-created first — so threads on + * servers that predate reordering keep the static creation order at the + * bottom of the block instead of breaking the section. + */ +export function sortPinnedThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly pinOrderKey?: string | null | undefined; + /** Thread ids are only unique within an environment, and the pinned + block merges environments — the tiebreak needs both parts or two + clients could render equal-key threads in stream-arrival order. */ + readonly environmentId?: string | undefined; + }, +>(threads: readonly T[]): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const thread of threads) { + (thread.pinOrderKey != null ? keyed : keyless).push(thread); + } + const identityTiebreak = (left: T, right: T) => + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? ""); + keyed.sort((left, right) => { + const leftKey = left.pinOrderKey!; + const rightKey = right.pinOrderKey!; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); + }); + keyless.sort((left, right) => { + const leftMs = Date.parse(left.createdAt); + const rightMs = Date.parse(right.createdAt); + return ( + (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || + identityTiebreak(left, right) + ); + }); + return [...keyed, ...keyless]; +} + +/** + * planPinnedReorder specialized for mobile's Move up / Move down menu + * actions: swap the moved thread with its displayed neighbor. Null when the + * move falls off either end of the list. Same single-write-per-move + * semantics as a web drag. + */ +export function planPinnedMove(input: { + /** Reorder-capable pinned thread ids in displayed order. */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; + readonly direction: "up" | "down"; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null { + const { orderedIds, keysById, movedId, direction } = input; + const from = orderedIds.indexOf(movedId); + if (from === -1) return null; + const to = direction === "up" ? from - 1 : from + 1; + if (to < 0 || to >= orderedIds.length) return null; + const newOrder = [...orderedIds]; + newOrder.splice(from, 1); + newOrder.splice(to, 0, movedId); + return planPinnedReorder({ orderedIds: newOrder, keysById, movedId }); +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4c44a959655..329ff911503 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pin.reorder (and orderKey on thread.pin). + Same version-skew contract as threadSettlement. */ + threadPinReorder: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 26204961923..87270d98c1f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({ // thread renders in the pinned block and never classifies into a shelf. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Fractional index for user-arranged pinned order. Keyed threads sort by + // string comparison ahead of keyless ones (which keep creation order), so + // servers never need each other's threads to agree on the merged list. + // Optional so payloads from pre-reorder servers still decode. + pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, threadId: ThreadId, + // Initial slot in the user-arranged pinned order (see ThreadPinReorderCommand). + // Optional: clients on pre-reorder servers omit it, and the pinned block + // falls back to creation order for keyless threads. + orderKey: Schema.optional(TrimmedNonEmptyString), }); const ThreadUnpinCommand = Schema.Struct({ @@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadPinReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.pin.reorder"), + commandId: CommandId, + threadId: ThreadId, + // Fractional index key: pinned threads sort by plain string comparison of + // these keys, so a drag writes one key to one thread — neighbors (possibly + // on other servers) are never touched. Clients compute a key that sorts + // between the dropped position's neighbors. + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unsnoozed", "thread.pinned", "thread.unpinned", + "thread.pin-reordered", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadPinnedPayload = Schema.Struct({ threadId: ThreadId, pinnedAt: IsoDateTime, + // Absent on re-pins of an already-pinned thread (the existing key wins) + // and on pins from clients that predate reordering. + pinOrderKey: Schema.optional(TrimmedNonEmptyString), updatedAt: IsoDateTime, }); @@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadPinReorderedPayload = Schema.Struct({ + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unpinned"), payload: ThreadUnpinnedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pin-reordered"), + payload: ThreadPinReorderedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), From e2cd2383cec150ea674030d408f2a99a411c94fc Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:29:50 -0400 Subject: [PATCH 04/81] chore(ci): vouch StiensWout (#5637) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a..90806ab0604 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -26,6 +26,7 @@ github:notkainoa github:PatrickBauer github:realAhmedRoach github:shiroyasha9 +github:StiensWout github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 From 72d673a855c730536f0cf3bb964ba523e0af9e2e Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 7 Aug 2026 19:45:56 +0100 Subject: [PATCH 05/81] feat(desktop): remember recently used sites in the Browser panel (#5270) --- apps/web/src/browser/browserTargetResolver.ts | 5 +- apps/web/src/browserHistoryStore.test.ts | 444 ++++++++++++++++++ apps/web/src/browserHistoryStore.ts | 398 ++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 +- apps/web/src/components/ChatView.tsx | 45 +- .../src/components/preview/BrowserMockup.tsx | 2 +- .../preview/PreviewEmptyState.test.tsx | 89 ++++ .../components/preview/PreviewEmptyState.tsx | 67 ++- .../preview/PreviewRecentUrlCard.tsx | 51 ++ .../components/preview/PreviewView.test.tsx | 39 ++ .../src/components/preview/PreviewView.tsx | 59 ++- .../components/preview/openDiscoveredPort.ts | 2 + .../preview/openTerminalLinkInPreview.ts | 2 + .../preview/useDiscoveredLocalServers.test.ts | 24 +- .../preview/useDiscoveredLocalServers.ts | 10 +- apps/web/src/environmentGrouping.test.ts | 5 + 16 files changed, 1207 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/browserHistoryStore.test.ts create mode 100644 apps/web/src/browserHistoryStore.ts create mode 100644 apps/web/src/components/preview/PreviewEmptyState.test.tsx create mode 100644 apps/web/src/components/preview/PreviewRecentUrlCard.tsx diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbae..3c3be59b457 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; -const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); +export const normalizeHostname = (host: string): string => + host.toLowerCase().replace(/^\[|\]$/g, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; -const isLocalLoopbackHost = (host: string): boolean => { +export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts new file mode 100644 index 00000000000..29d27eb5535 --- /dev/null +++ b/apps/web/src/browserHistoryStore.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const { readPreparedConnection } = vi.hoisted(() => ({ + readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), +})); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + BROWSER_HISTORY_MAX_PROJECTS, + BROWSER_HISTORY_MAX_TITLE_LENGTH, + type BrowserHistoryEntry, + evictExcessProjects, + mergeBrowserHistoryState, + migratePersistedBrowserHistoryState, + normalizeHistoryUrl, + recordVisitForThread, + removeUrlForThread, + resetBrowserHistoryForTests, + setTitleForThreadUrl, + upsertHistoryEntry, + useBrowserHistoryStore, +} from "./browserHistoryStore"; + +function entry(overrides: Partial = {}): BrowserHistoryEntry { + return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; +} + +beforeEach(() => readPreparedConnection.mockReturnValue(null)); +afterEach(() => vi.restoreAllMocks()); + +function spyOnPersistWrites() { + const storage = useBrowserHistoryStore.persist.getOptions().storage; + if (!storage) throw new Error("Browser history persistence storage is unavailable."); + return vi.spyOn(storage, "setItem"); +} + +describe("normalizeHistoryUrl", () => { + it("normalizes bare loopback hosts to http and keeps path/query", () => { + expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( + "http://localhost:3000/admin?tab=1", + ); + }); + + it("normalizes bare public hosts to https", () => { + expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); + }); + + it("preserves hash routes and strips credentials", () => { + expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( + "http://localhost:3000/app#/route", + ); + expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/"); + }); + + it("rejects non-http(s), unparseable, and oversized urls", () => { + expect(normalizeHistoryUrl("ftp://example.com")).toBeNull(); + expect(normalizeHistoryUrl("")).toBeNull(); + expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull(); + }); +}); + +describe("upsertHistoryEntry", () => { + it("prepends new urls", () => { + const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); + expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); + expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); + }); + + it("moves revisits to front, updates the timestamp, and keeps the title", () => { + const existing = [ + entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), + entry({ url: "http://b.test/", lastVisitedAt: 400 }), + ]; + const next = upsertHistoryEntry(existing, "http://b.test/", 3000); + expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); + expect(next[0]?.lastVisitedAt).toBe(3000); + expect(next[1]?.title).toBe("A"); + }); + + it("caps the list at the per-project limit", () => { + const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => + entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), + ); + const next = upsertHistoryEntry(full, "http://new.test/", 9999); + expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + expect(next[0]?.url).toBe("http://new.test/"); + const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; + expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); + expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); + }); + + it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { + const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { + insertOrdered: true, + }); + expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + }); + + it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { + const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); + expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]); + }); +}); + +describe("evictExcessProjects", () => { + it("keeps the most recently visited projects when over the cap", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ + `project-${i}`, + [entry({ lastVisitedAt: i })], + ]), + ); + const next = evictExcessProjects(byProjectKey); + expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(next["project-0"]).toBeUndefined(); + expect(next["project-1"]).toBeUndefined(); + expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); + }); +}); + +describe("migratePersistedBrowserHistoryState", () => { + it("drops malformed state and invalid entries", () => { + expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); + expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} }); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + { url: "", lastVisitedAt: 100 }, + { url: "ftp://ghost.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: Number.NaN }, + "junk", + ], + bad: "junk", + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + ]); + expect(migrated.byProjectKey["bad"]).toBeUndefined(); + }); + + it("normalizes persisted urls with the same rules as live writes", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "https://a.test/path#section", lastVisitedAt: 100 }, + ]); + }); + + it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ + `project-${index}`, + [{ url: `http://project-${index}.test/`, lastVisitedAt: index }], + ]), + ); + byProjectKey["project-1"] = [ + { url: "a.test/", lastVisitedAt: 1 }, + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]; + + const migrated = migratePersistedBrowserHistoryState({ byProjectKey }); + + expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(migrated.byProjectKey["project-0"]).toBeUndefined(); + expect(migrated.byProjectKey["project-1"]).toEqual([ + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]); + }); + + it("rejects a lastVisitedAt outside Date's valid range", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: 1e20 }, + ], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); + }); + + it("truncates oversized persisted titles to the contract bound", () => { + const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], + }, + }); + expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( + BROWSER_HISTORY_MAX_TITLE_LENGTH, + ); + expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( + oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), + ); + }); +}); + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("useBrowserHistoryStore", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("records visits for registered threads under the project key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, + ]); + }); + + it("does not persist when a thread is already registered to the same project", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const persist = spyOnPersistWrites(); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + expect(persist).not.toHaveBeenCalled(); + }); + + it("ignores invalid urls whether queued pending or recorded post-registration", () => { + recordVisitForThread(threadRef, "ftp://a.test/", 1); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "ftp://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + }); + + it("sets titles update-only via the thread helper", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + recordVisitForThread(threadRef, "http://a.test/#/settings", 1); + setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); + }); + + it("does not persist when the title is already set", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + const persist = spyOnPersistWrites(); + const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; + + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + + expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); + expect(persist).not.toHaveBeenCalled(); + }); + + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community", + title: "Community", + }); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community/", + title: "Community", + }); + }); + + it("matches a requested localhost URL to the resolved environment host", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); + + it("deduplicates loopback aliases and the resolved environment host", () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 3 }, + ]); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4); + recordVisitForThread(threadRef, "http://localhost:5173/app", 5); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 5 }, + ]); + }); + + it("does not match a genuinely different path via the trailing-slash comparison", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); + }); + + it("updates only the most recent entry when several share a trailing-slash comparison key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + recordVisitForThread(threadRef, "http://a.test/community", 2); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); + expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); + expect(entries?.[1]?.title).toBeUndefined(); + }); + + it("truncates oversized titles to the contract bound", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); + setTitleForThreadUrl(threadRef, "http://a.test/", oversized); + const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; + expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); + expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); + }); + + it("removes entries", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + removeUrlForThread(threadRef, "http://a.test/"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + ]); + }); +}); + +describe("pendingVisitsByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("queues a visit recorded before registration and drains it in order on registration", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + "http://a.test/", + ]); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1); + expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); + }); + + it("caps the per-thread pending list at 10, dropping the oldest", () => { + for (let i = 0; i < 12; i++) { + recordVisitForThread(threadRef, `http://a.test/${i}`, i); + } + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); + expect(urls).toHaveLength(10); + expect(urls).not.toContain("http://a.test/0"); + expect(urls).not.toContain("http://a.test/1"); + expect(urls?.[0]).toBe("http://a.test/11"); + }); + + it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { + const otherThreadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-2"), + }; + useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(otherThreadRef, "http://newer.test/", 2000); + recordVisitForThread(threadRef, "http://older.test/", 1000); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on. + expect(entries?.[0]?.lastVisitedAt).toBe(2000); + }); +}); + +describe("pendingTitlesByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("buffers a title set before registration and applies it once the matching visit drains", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); + expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); + }); + + it("preserves environment host matching while a title is pending", () => { + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); +}); + +describe("mergeBrowserHistoryState", () => { + it("sanitizes same-version corrupt persisted data and preserves actions", () => { + // `migrate` only runs when versions differ; `merge` runs on every rehydrate. + const current = useBrowserHistoryStore.getState(); + const merged = mergeBrowserHistoryState( + { + byProjectKey: { + a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }], + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }, + projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 }, + }, + current, + ); + expect(merged.byProjectKey).toEqual({ + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }); + expect(typeof merged.recordVisit).toBe("function"); + expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); + expect(merged.pendingVisitsByThreadKey).toEqual({}); + expect(merged.pendingTitlesByThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts new file mode 100644 index 00000000000..4c0a560817b --- /dev/null +++ b/apps/web/src/browserHistoryStore.ts @@ -0,0 +1,398 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; + +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { readPreparedConnection } from "~/state/session"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; +import { resolveStorage } from "./lib/storage"; + +export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; + +export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; +export const BROWSER_HISTORY_MAX_PROJECTS = 20; +export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; +const MAX_VALID_DATE_MS = 8_640_000_000_000_000; + +export function isValidHistoryTimestamp(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS + ); +} + +export function normalizeHistoryUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(normalizePreviewUrl(raw)); + } catch { + return null; + } + parsed.username = parsed.password = ""; + return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; +} + +export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(visitLookupKey(normalized, environmentHostname)); + if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) + parsed.pathname = parsed.pathname.slice(0, -1); + return parsed.href; +} + +function visitLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(normalized); + const host = normalizeHostname(parsed.hostname); + const environmentHost = environmentHostname && normalizeHostname(environmentHostname); + if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) + parsed.hostname = "local"; + return parsed.href; +} + +function isStableLocalUrl(normalized: string): boolean { + const host = normalizeHostname(new URL(normalized).hostname); + return isLocalLoopbackHost(host) || host === "0.0.0.0"; +} + +export function upsertHistoryEntry( + entries: ReadonlyArray, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, +): BrowserHistoryEntry[] { + const key = visitLookupKey(url, options?.environmentHostname); + const existing = entries.find( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, + ); + const rest = entries.filter( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, + ); + const visitedAt = + options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; + const storedUrl = + existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; + const entry: BrowserHistoryEntry = existing + ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } + : { url, lastVisitedAt: visitedAt }; + if (!options?.insertOrdered) + return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); + const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); + return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); +} + +export function evictExcessProjects( + byProjectKey: Record, +): Record { + const keys = Object.keys(byProjectKey); + if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; + const kept = keys + .toSorted( + (a, b) => + (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), + ) + .slice(0, BROWSER_HISTORY_MAX_PROJECTS); + return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); +} + +export function migratePersistedBrowserHistoryState(persistedState: unknown): { + byProjectKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; + const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; + const byProjectKey: Record = {}; + for (const [projectKey, value] of Object.entries(raw as Record)) { + if (!Array.isArray(value)) continue; + const seenUrls = new Set(); + const entries = value + .flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const { url, lastVisitedAt, title } = candidate as Record; + if (typeof url !== "string") return []; + const normalizedUrl = normalizeHistoryUrl(url); + if (!normalizedUrl) return []; + if (!isValidHistoryTimestamp(lastVisitedAt)) return []; + return [ + { + url: normalizedUrl, + lastVisitedAt, + ...(typeof title === "string" && title.length > 0 + ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } + : {}), + }, + ]; + }) + .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .filter((entry) => { + const key = visitLookupKey(entry.url); + if (seenUrls.has(key)) return false; + seenUrls.add(key); + return true; + }) + .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + if (entries.length > 0) byProjectKey[projectKey] = entries; + } + return { byProjectKey: evictExcessProjects(byProjectKey) }; +} + +const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; + +const PENDING_MAX_PER_THREAD = 10; +const PENDING_MAX_THREADS = 20; + +type PendingVisit = { url: string; at: number; environmentHostname: string | null }; +type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; + +interface BrowserHistoryStoreState { + byProjectKey: Record; + projectKeyByThreadKey: Record; + pendingVisitsByThreadKey: Record; + pendingTitlesByThreadKey: Record; + recordVisit: ( + projectKey: string, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, + ) => void; + setTitleForUrl: ( + projectKey: string, + url: string, + title: string, + environmentHostname?: string | null, + ) => void; + removeUrl: (projectKey: string, url: string) => void; + registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; +} + +function addPendingByThread( + pendingByThreadKey: Record, + threadKey: string, + item: T, +): Record { + const existing = pendingByThreadKey[threadKey] ?? []; + const next = { ...pendingByThreadKey }; + next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); + const keys = Object.keys(next); + if (keys.length > PENDING_MAX_THREADS) { + const oldestKey = keys[0]; + if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; + } + return next; +} + +export const useBrowserHistoryStore = create()( + persist( + (set, get) => ({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + recordVisit: (projectKey, url, at, options) => { + const normalized = normalizeHistoryUrl(url); + if (!normalized) return; + set((state) => { + return { + byProjectKey: evictExcessProjects({ + ...state.byProjectKey, + [projectKey]: upsertHistoryEntry( + state.byProjectKey[projectKey] ?? [], + normalized, + at, + options, + ), + }), + }; + }); + }, + setTitleForUrl: (projectKey, url, title, environmentHostname) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); + if (!normalized || !entries || trimmed.length === 0) return; + const key = titleLookupKey(normalized, environmentHostname); + const index = entries.findIndex( + (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, + ); + if (index === -1 || entries[index]?.title === trimmed) return; + set({ + byProjectKey: { + ...state.byProjectKey, + [projectKey]: entries.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, title: trimmed } : candidate, + ), + }, + }); + }, + removeUrl: (projectKey, url) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + if (!normalized || !entries) return; + const next = entries.filter((candidate) => candidate.url !== normalized); + if (next.length === entries.length) return; + if (next.length === 0) { + const { [projectKey]: _removed, ...rest } = state.byProjectKey; + set({ byProjectKey: rest }); + return; + } + set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); + }, + registerThreadProject: (ref, projectKey) => { + const threadKey = scopedThreadKey(ref); + const state = get(); + const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; + const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; + if ( + state.projectKeyByThreadKey[threadKey] === projectKey && + !pendingVisits && + !pendingTitles + ) { + return; + } + const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; + const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; + delete nextPendingVisits[threadKey]; + delete nextPendingTitles[threadKey]; + set({ + projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, + pendingVisitsByThreadKey: nextPendingVisits, + pendingTitlesByThreadKey: nextPendingTitles, + }); + for (const visit of pendingVisits ?? []) + get().recordVisit(projectKey, visit.url, visit.at, { + insertOrdered: true, + environmentHostname: visit.environmentHostname, + }); + for (const pendingTitle of pendingTitles ?? []) + get().setTitleForUrl( + projectKey, + pendingTitle.url, + pendingTitle.title, + pendingTitle.environmentHostname, + ); + }, + }), + { + name: BROWSER_HISTORY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byProjectKey: state.byProjectKey, + projectKeyByThreadKey: state.projectKeyByThreadKey, + }), + migrate: migratePersistedBrowserHistoryState, + merge: mergeBrowserHistoryState, + }, + ), +); + +export function mergeBrowserHistoryState( + persistedState: unknown, + currentState: BrowserHistoryStoreState, +): BrowserHistoryStoreState { + const migrated = migratePersistedBrowserHistoryState(persistedState); + return { + ...currentState, + ...migrated, + projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }; +} + +function migratePersistedThreadProjectKeys( + persistedState: unknown, + byProjectKey: Record, +): Record { + if (!persistedState || typeof persistedState !== "object") return {}; + const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record) + .filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1] in byProjectKey, + ) + .slice(-100), + ); +} + +export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + const visitAt = at ?? Date.now(); + const connection = readPreparedConnection(ref.environmentId); + const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { + url, + at: visitAt, + environmentHostname, + }), + }); + return; + } + state.recordVisit(projectKey, url, visitAt, { environmentHostname }); +} + +export function setTitleForThreadUrl( + ref: ScopedThreadRef, + url: string, + title: string, + environmentHostname?: string | null, +): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { + url, + title, + environmentHostname, + }), + }); + return; + } + state.setTitleForUrl(projectKey, url, title, environmentHostname); +} + +export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + if (!projectKey) return; + state.removeUrl(projectKey, url); +} + +const EMPTY_HISTORY: ReadonlyArray = []; + +export function useThreadRecentHistory( + ref: ScopedThreadRef, + limit: number, +): ReadonlyArray { + return useBrowserHistoryStore( + useShallow((state) => { + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + const entries = projectKey ? state.byProjectKey[projectKey] : undefined; + return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; + }), + ); +} + +export function resetBrowserHistoryForTests(): void { + useBrowserHistoryStore.setState({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }); + useBrowserHistoryStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b..b5d33facc96 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -1336,7 +1337,10 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { + if (result._tag === "Success") recordVisitForThread(threadRef, url); + return result; + }); }, [openPreview, threadRef], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b2b6f61357..cfbe1ac8d96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -172,9 +172,14 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + useEnvironmentSettings, +} from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { + derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) { return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ @@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const clientSettingsHydrated = useClientSettingsHydrated(); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; + // Reuse the sidebar's grouping so history follows the project rows the user + // sees. Deriving the key from the active project alone would miss the + // identity a duplicate row borrows from its siblings. + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + useBrowserHistoryStore + .getState() + .registerThreadProject( + activeThreadRef, + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), + ); + }, [ + activeProject, + activeThreadRef, + allProjects, + clientSettingsHydrated, + primaryEnvironmentId, + projectGroupingSettings, + ]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; @@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); diff --git a/apps/web/src/components/preview/BrowserMockup.tsx b/apps/web/src/components/preview/BrowserMockup.tsx index 3b1882bbda9..35cfbb421e7 100644 --- a/apps/web/src/components/preview/BrowserMockup.tsx +++ b/apps/web/src/components/preview/BrowserMockup.tsx @@ -1,6 +1,6 @@ import { cn } from "~/lib/utils"; -/** Browser-window thumbnail glyph for the "Local" recommendation cards. */ +/** Browser-window thumbnail glyph for preview recommendation cards. */ export function BrowserMockup({ className }: { className?: string }) { return (
    ({ + servers: [] as Array<{ + host: string; + port: number; + url: string; + requestedUrl: string; + processName: string | null; + pid: number | null; + terminal: null; + source: "scanner"; + listening: boolean; + }>, +})); + +vi.mock("./useDiscoveredLocalServers", () => ({ + useDiscoveredLocalServers: () => mocks.servers, +})); + +import { PreviewEmptyState } from "./PreviewEmptyState"; + +const environmentId = EnvironmentId.make("env-1"); + +function server(port: number) { + return { + host: "localhost", + port, + url: `http://localhost:${port}`, + requestedUrl: `http://localhost:${port}`, + processName: "node", + pid: 1, + terminal: null, + source: "scanner" as const, + listening: true, + }; +} + +function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { + return renderToStaticMarkup( + undefined} + onOpenUrl={() => undefined} + />, + ); +} + +describe("PreviewEmptyState", () => { + it("renders a history entry in both groups when its host:port matches a live server", () => { + mocks.servers = [server(5173)]; + const html = render([ + { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, + { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, + ]); + expect(html).toContain("Recently used"); + expect(html).toContain("Local servers"); + expect(html).toContain("myapp.test/admin#users"); + expect(html).toContain("Admin"); + expect(html).toContain("Recent Local"); + expect(html).toContain("node"); + }); + + it("renders only the recents group when no servers are found", () => { + mocks.servers = []; + const html = render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); + expect(html).toContain("Recently used"); + expect(html).not.toContain("Local servers"); + }); + + it("keeps the original empty state when both groups are empty", () => { + mocks.servers = []; + const html = render([]); + expect(html).toContain("No preview yet"); + }); + + it("renders an out-of-range lastVisitedAt entry without throwing", () => { + mocks.servers = []; + let html = ""; + expect(() => { + html = render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); + }).not.toThrow(); + expect(html).toContain("myapp.test"); + expect(html).toContain("Remove"); + }); +}); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 12126c66408..3b9aacf4dfd 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,15 +1,19 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { Globe, RadioTower } from "lucide-react"; +import { Globe, History, RadioTower } from "lucide-react"; +import type { BrowserHistoryEntry } from "~/browserHistoryStore"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; +import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentlySeenUrls?: ReadonlyArray | undefined; + recentEntries: ReadonlyArray; + onRemoveRecent: (url: string) => void; onOpenUrl: (url: string) => void; } @@ -17,6 +21,8 @@ export function PreviewEmptyState({ environmentId, configuredUrls, recentlySeenUrls, + recentEntries, + onRemoveRecent, onOpenUrl, }: Props) { const servers = useDiscoveredLocalServers({ @@ -24,8 +30,9 @@ export function PreviewEmptyState({ configuredUrls, recentlySeenUrls, }); + const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8); - if (servers.length === 0) { + if (servers.length === 0 && recents.length === 0) { return ( @@ -42,23 +49,45 @@ export function PreviewEmptyState({ return (
    -
    -
    - -

    Local servers

    -
    -
    - {servers.map((server) => ( - onOpenUrl(server.url)} - /> - ))} -
    -

    - Select a listening port to open it in this browser tab. -

    +
    + {recents.length > 0 ? ( +
    +
    + +

    Recently used

    +
    +
    + {recents.map((entry) => ( + onOpenUrl(entry.url)} + onRemove={() => onRemoveRecent(entry.url)} + /> + ))} +
    +
    + ) : null} + {servers.length > 0 ? ( +
    +
    + +

    Local servers

    +
    +
    + {servers.map((server) => ( + onOpenUrl(server.requestedUrl)} + /> + ))} +
    +

    + Select a listening port to open it in this browser tab. +

    +
    + ) : null}
    ); diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx new file mode 100644 index 00000000000..892ff579d1d --- /dev/null +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -0,0 +1,51 @@ +import { X } from "lucide-react"; + +import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { BrowserMockup } from "./BrowserMockup"; + +interface Props { + entry: BrowserHistoryEntry; + onOpen: () => void; + onRemove: () => void; +} + +export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { + const parsed = new URL(entry.url); + const path = parsed.pathname === "/" ? "" : parsed.pathname; + const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; + const visitedAt = isValidHistoryTimestamp(entry.lastVisitedAt) + ? formatRelativeTimeLabel(new Date(entry.lastVisitedAt).toISOString()) + : ""; + useNowMinute(); + return ( +
    + + +
    + ); +} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 4121b72602f..d9671e2f2d9 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -24,6 +24,17 @@ const mocks = vi.hoisted(() => ({ toggleAnnotation: null as (() => void) | null, pictureInPicture: false, showEmptyState: false, + recordVisitForThread: vi.fn(), +})); + +const EMPTY_HISTORY: never[] = []; + +vi.mock("~/browserHistoryStore", () => ({ + recordVisitForThread: mocks.recordVisitForThread, + setTitleForThreadUrl: vi.fn(), + removeUrlForThread: vi.fn(), + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + useThreadRecentHistory: () => EMPTY_HISTORY, })); vi.mock("~/state/session", () => ({ @@ -232,6 +243,7 @@ describe("PreviewView navigation", () => { mocks.toggleAnnotation = null; mocks.pictureInPicture = false; mocks.showEmptyState = false; + mocks.recordVisitForThread.mockClear(); }); it.each([ @@ -267,6 +279,27 @@ describe("PreviewView navigation", () => { ); }); + it("records a history visit with the normalized requested url on submit", async () => { + renderToStaticMarkup( + , + ); + + mocks.submittedUrl?.("localhost:3000/admin"); + await vi.waitFor(() => { + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:3000/admin", + ); + }); + }); + it("maps an empty-state localhost server onto the WSL host", async () => { mocks.showEmptyState = true; renderToStaticMarkup( @@ -296,6 +329,12 @@ describe("PreviewView navigation", () => { }, "http://172.25.85.75:5173/app?mode=test#top", ); + await vi.waitFor(() => + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:5173/app?mode=test#top", + ), + ); }); it("opens and closes a thread-scoped floating preview for the active tab", async () => { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index a2435627c62..6979a1a4006 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -11,6 +11,13 @@ import { import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + recordVisitForThread, + removeUrlForThread, + setTitleForThreadUrl, + useThreadRecentHistory, +} from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; @@ -20,6 +27,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; @@ -83,12 +91,24 @@ export function PreviewView({ const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); + // Kept in sync so the title effect can depend on the stable thread key + // instead of the thread object, which is recreated on every update. + const threadRefRef = useRef(threadRef); + threadRefRef.current = threadRef; const previewState = useThreadPreviewState(threadRef); + const recentHistoryEntries = useThreadRecentHistory( + threadRef, + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + ); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); + const environmentHostname = environmentHttpBaseUrl + ? new URL(environmentHttpBaseUrl).hostname + : null; const open = useAtomCommand(previewEnvironment.open); const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); @@ -128,20 +148,27 @@ export function PreviewView({ runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); + const navUrl = navStatus._tag === "Success" ? navStatus.url : null; + const navTitle = navStatus._tag === "Success" ? navStatus.title : null; + const latestHistoryUrl = recentHistoryEntries[0]?.url; + const threadKey = scopedThreadKey(threadRef); + useEffect(() => { + if (!navUrl || !navTitle || !latestHistoryUrl) return; + // Agent-driven pages only enrich an existing requested URL. + setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname); + // threadKey stands in for threadRef, whose identity churns on every thread update. + }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]); + const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { if (runtimeTabId && previewBridge) { - // Drive the webview imperatively; `usePreviewBridge` mirrors the - // resolved URL back to the server so other clients stay in sync. + // The bridge mirrors the resolved URL back to the server. await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); - } else { - await openPreviewSession({ - openPreview: open, - threadRef, - url: resolvedUrl, - }); + return true; } + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + return result._tag === "Success"; }, [open, runtimeTabId, threadRef], ); @@ -149,23 +176,29 @@ export function PreviewView({ const handleSubmitUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl(normalizePreviewUrl(next)); + const normalized = normalizePreviewUrl(next); + if (await navigateToResolvedUrl(normalized)) { + recordVisitForThread(threadRef, normalized); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl], + [navigateToResolvedUrl, threadRef], ); const handleOpenServerUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next)); + const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); + if (await navigateToResolvedUrl(resolved)) { + recordVisitForThread(threadRef, next); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl, threadRef.environmentId], + [navigateToResolvedUrl, threadRef], ); const handleRefresh = useCallback(() => { @@ -680,6 +713,8 @@ export function PreviewView({ environmentId={threadRef.environmentId} configuredUrls={configuredUrls} recentlySeenUrls={previewState.recentlySeenUrls} + recentEntries={recentHistoryEntries} + onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} onOpenUrl={(next) => void handleOpenServerUrl(next)} /> ) : null} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 664c2e33a5c..a49acbd8610 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -6,6 +6,7 @@ import { import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -21,6 +22,7 @@ export async function openDiscoveredPort(input: { url: resolvedUrl, }); return mapAtomCommandResult(result, (snapshot) => { + recordVisitForThread(input.threadRef, input.port.url); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 312eab9eb35..f4e0373a73c 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview( input.fallbackToBrowser(); return; } + recordVisitForThread(input.threadRef, input.url); applyPreviewServerSnapshot(input.threadRef, result.value); useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); return; diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index bb3b7cd6fa8..cdc92714025 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; -const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ +const scannerServer = ( + overrides: Partial, +): DiscoveredLocalServer & { requestedUrl: string } => ({ host: "localhost", port: 5173, url: "http://localhost:5173", + requestedUrl: overrides.url ?? "http://localhost:5173", processName: "vite", pid: 1234, terminal: null, @@ -24,6 +27,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ host: "localhost", port: 5173, + requestedUrl: "http://localhost:5173", source: "scanner", listening: true, processName: "vite", @@ -56,6 +60,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ source: "configured", listening: false, + requestedUrl: "http://localhost:5173/", }); }); @@ -68,6 +73,7 @@ describe("mergeServers", () => { expect(result.map((s) => s.port)).toEqual([5173, 8080]); expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/"); }); it("ignores non-loopback URLs in configured/recent inputs", () => { @@ -102,6 +108,22 @@ describe("mergeServers", () => { }); expect(result).toHaveLength(1); }); + + it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { + const result = mergeServers({ + scanner: [ + scannerServer({ + port: 5173, + url: "https://env-42.example.dev:5173/", + requestedUrl: "http://localhost:5173/", + }), + ], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); + }); }); describe("PreviewableServer interface", () => { diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 118a56b9068..77491a93c10 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { * `configured` entry can also be `listening` when the scan enriched it. */ listening: boolean; + /** + * Pre-resolution loopback url. `url` is the resolved navigation target + * (volatile on a remote environment); history must key off this instead. + */ + requestedUrl: string; } interface UseDiscoveredLocalServersInput { @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( scanner: scannerSnapshot.map((server) => ({ ...server, url: resolveDiscoveredServerUrl(input.environmentId, server.url), + requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], recentlySeenUrls: input.recentlySeenUrls ?? [], @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( } export function mergeServers(input: { - scanner: ReadonlyArray; + scanner: ReadonlyArray; configuredUrls: ReadonlyArray; recentlySeenUrls: ReadonlyArray; }): ReadonlyArray { @@ -60,6 +66,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, @@ -95,6 +102,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..9029f1204d3 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -279,6 +279,11 @@ describe("environment grouping", () => { expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( repositoryIdentity.canonicalKey, ); + // Deriving from the stale project alone misses the identity its sibling + // carries, so consumers must go through the map to match the sidebar. + expect( + deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings), + ).not.toBe(repositoryIdentity.canonicalKey); }); it("builds one picker entry per logical project and targets the preferred environment", () => { From 82406bce99c2cf46d1d8fbad26bfefc2ab3169ce Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:39 -0400 Subject: [PATCH 06/81] chore: vouch chrisdeeming (#5641) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 90806ab0604..c74a0dc48ff 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -11,6 +11,7 @@ # Keep entries sorted alphabetically. github:adityavardhansharma github:binbandit +github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev From f0fb406acf419c1ab3d37d5f42093c07921484b2 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 7 Aug 2026 15:41:59 -0400 Subject: [PATCH 07/81] feat(web): make sidebar artwork theme-aware (#5636) --- .../components/settings/ThemeEditorPanel.tsx | 41 +++++++++++++- apps/web/src/hooks/useSettings.test.ts | 31 ++++++++++ apps/web/src/hooks/useSettings.ts | 32 ++++++++++- apps/web/src/themePalette.test.ts | 56 ++++++++++++++++++- apps/web/src/themePalette.ts | 31 +++++++++- 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0074ac89304..f015fce03d0 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -165,6 +165,7 @@ export function ThemeEditorPanel({ const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [sidebarArtwork, setSidebarArtwork] = useState(false); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState(() => getThemeEditorColorsByAppearance(), @@ -178,6 +179,7 @@ export function ThemeEditorPanel({ const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState(null); const [usageCount, setUsageCount] = useState(null); + const previousMergeTargetIdRef = useRef(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -259,6 +261,9 @@ export function ThemeEditorPanel({ setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); + // Artwork is opt-in for new themes, including duplicates. Editing keeps + // the theme's existing choice. + setSidebarArtwork(editingTheme?.sidebarArtwork === true); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A @@ -315,6 +320,18 @@ export function ThemeEditorPanel({ // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (previousMergeTargetIdRef.current === mergeTargetId) return; + previousMergeTargetIdRef.current = mergeTargetId; + // A matching name makes that existing theme the surviving merge target. + // Seed theme-level options from it so adding a palette or renaming onto it + // does not silently reset them. Leaving the merge restores the edited + // theme's option (or the off-by-default choice for a new theme). + setSidebarArtwork( + mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true, + ); + }, [editingTheme, mergeTarget, mergeTargetId]); + useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; @@ -330,8 +347,8 @@ export function ThemeEditorPanel({ // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; - applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); - }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]); useEffect(() => { if (!open) return; @@ -657,6 +674,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -687,6 +705,7 @@ export function ThemeEditorPanel({ ...(getThemeModes(editingTheme).length > 1 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } : {}), + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -713,6 +732,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, [activeAppearance]: colorsForSave[activeAppearance], }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -723,6 +743,7 @@ export function ThemeEditorPanel({ name, appearance: activeAppearance, colors: colorsForSave[activeAppearance], + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -773,6 +794,7 @@ export function ThemeEditorPanel({ name, onOpenChange, onSaved, + sidebarArtwork, simpleColorsDirtyByAppearance, takenAppearances, ]); @@ -832,6 +854,20 @@ export function ThemeEditorPanel({
    ); + const renderSidebarArtworkToggle = () => ( + + ); + const renderColorsHeader = () => (
    @@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({

    ) : null} {renderAppearanceButtons()} + {renderSidebarArtworkToggle()}
    {renderColorsHeader()} {renderColorFields()} diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 741579661e7..b332fe13c2f 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => { "pill", ); }); + + it("uses a pill instead of artwork with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("pill"); + }); + + it("respects none with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "none", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("none"); + }); + + it("keeps artwork when the palette theme opts into it", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + paletteThemeAllowsArtwork: true, + }), + ).toBe("artwork"); + }); }); describe("mergeEnvironmentSettings", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f7..f4797bb775d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { APP_STAGE_LABEL } from "~/branding"; import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; +import { + getThemeDefinition, + getThemePreviewSidebarArtwork, + resolveThemeHalf, + subscribeToThemePreview, +} from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -226,15 +233,36 @@ export function useClientSettings( export function resolveEnvironmentIdentificationMode(input: { mode: EnvironmentIdentificationMode; settingsHydrated: boolean; + paletteThemeActive?: boolean; + paletteThemeAllowsArtwork?: boolean; }): EnvironmentIdentificationMode { // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. - return input.settingsHydrated ? input.mode : "none"; + if (!input.settingsHydrated) return "none"; + // Stage artwork has fixed colors that can clash with palette themes. Keep an + // explicit "none", but use the theme-aware pill in place of artwork. + return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" + ? "pill" + : input.mode; } export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { const settingsHydrated = useClientSettingsHydrated(); const mode = useClientSettingsValue().environmentIdentificationMode; - return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); + const { resolvedTheme, theme, themeHalves } = useTheme(); + const previewSidebarArtwork = useSyncExternalStore( + subscribeToThemePreview, + getThemePreviewSidebarArtwork, + () => null, + ); + const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme); + const activeThemeDefinition = getThemeDefinition(activeTheme); + return resolveEnvironmentIdentificationMode({ + mode, + settingsHydrated, + paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, + paletteThemeAllowsArtwork: + previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, + }); } /** diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 2ed4ff3891d..671b5dbb76d 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + applyThemeColorPreview, + applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, + getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, @@ -15,6 +18,7 @@ import { resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, + subscribeToThemePreview, subscribeToCustomThemes, T3_CHAT_THEME, EMBER_THEME, @@ -200,6 +204,49 @@ describe("theme files", () => { }); }); + it("keeps sidebar artwork opt-in through theme files", () => { + const withoutArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Plain sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + }); + const withArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Art sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + sidebarArtwork: true, + }); + + expect(withoutArtwork.sidebarArtwork).toBeUndefined(); + expect(withArtwork.sidebarArtwork).toBe(true); + expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true); + }); + + it("publishes sidebar artwork changes from the live theme preview", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToThemePreview(listener); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + style: { removeProperty: vi.fn(), setProperty: vi.fn() }, + }, + }); + + applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true); + expect(getThemePreviewSidebarArtwork()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + + applyThemePalette("system"); + expect(getThemePreviewSidebarArtwork()).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + vi.unstubAllGlobals(); + }); + it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, @@ -387,6 +434,7 @@ describe("theme files", () => { name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", accent: "#5b6cff" }, + sidebarArtwork: true, }), ); const updatedTheme = updateCustomTheme({ @@ -395,11 +443,17 @@ describe("theme files", () => { colors: { ...createdTheme.colors, accent: "#7c3aed" }, }); - expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" }); + expect(updatedTheme).toMatchObject({ + id: "aurora", + label: "Aurora Night", + sidebarArtwork: true, + }); + invalidateCustomThemes(); expect(getCustomThemes()).toEqual([updatedTheme]); expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ id: "aurora", label: "Aurora Night", + sidebarArtwork: true, }); vi.unstubAllGlobals(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index dafc5dbf457..2f6fb043454 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; + /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */ + sidebarArtwork?: boolean; /** True when the palette was generated by the guided editor from its * canvas and accent; such themes reopen in guided mode. */ managed?: boolean; @@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{ appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; + sidebarArtwork?: boolean; managed?: boolean; }>; @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ const customThemeListeners = new Set<() => void>(); let customThemesSnapshot: ReadonlyArray | null = null; +const themePreviewListeners = new Set<() => void>(); +let themePreviewSidebarArtwork: boolean | null = null; + +export function getThemePreviewSidebarArtwork(): boolean | null { + return themePreviewSidebarArtwork; +} + +export function subscribeToThemePreview(listener: () => void): () => void { + themePreviewListeners.add(listener); + return () => themePreviewListeners.delete(listener); +} + +function setThemePreviewSidebarArtwork(next: boolean | null): void { + if (themePreviewSidebarArtwork === next) return; + themePreviewSidebarArtwork = next; + for (const listener of themePreviewListeners) listener(); +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { appearance: value.appearance, colors, ...(variants ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { appearance, colors: { ...fallback, ...overrides }, ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), + ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), ...(theme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; @@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview"; * can be judged against the real interface instead of a miniature. Callers * restore the stored theme (refreshTheme) when the draft goes away. */ -export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { +export function applyThemeColorPreview( + colors: ThemeColors, + appearance: ThemeAppearance, + sidebarArtwork = false, +): void { if (typeof document === "undefined") return; const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(sidebarArtwork); root.dataset.themeId = THEME_PREVIEW_ID; root.classList.toggle("dark", appearance === "dark"); for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { @@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(null); const palette = getThemeDefinition(theme); if (palette) { From 7a84f6cf10a5040d29c6392ad216d976458363cd Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:03:31 +0800 Subject: [PATCH 08/81] fix(web): reconnect the composer seam for remote non-Git projects (#5633) --- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 2 +- .../src/components/BranchToolbarEnvironmentSelector.tsx | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 440f48d7c90..5ceec813187 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -126,7 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index ca778daad31..64bcd8c57cb 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 2cf99547752..56fb91fb4b8 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,9 +41,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); + // The static label carries the xs control's height (h-7 sm:h-6) as well as + // its padding: the composer context strip has no min-height of its own, and + // the glass seam joining it to the composer assumes a fixed strip height, so + // a shorter label would drag the seam out of line whenever this label is the + // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( From 45d9aa90baab8f2d6b13c7ae3cf2f97128edaf7b Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 7 Aug 2026 23:03:52 +0200 Subject: [PATCH 09/81] fix(web): show Stop button while input is pending (#5554) --- .../chat/ComposerPrimaryActions.test.ts | 78 ++++++++++++++++++- .../chat/ComposerPrimaryActions.tsx | 32 ++++---- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index b7624db0a8f..ba416e9fce3 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -1,6 +1,64 @@ -import { describe, expect, it } from "vite-plus/test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; + +function renderPendingActions(isRunning: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderStandaloneStop() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { @@ -91,3 +149,19 @@ describe("formatPendingPrimaryActionLabel", () => { ).toBe("Submit answers"); }); }); + +describe("ComposerPrimaryActions", () => { + it("offers Stop generation while a running turn is waiting for user input", () => { + expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); + }); + + it("does not offer Stop generation for a pending request without a running turn", () => { + expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); + }); + + it("matches the small pending action size without changing the standalone size", () => { + expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); + expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); + expect(renderStandaloneStop()).not.toContain("sm:size-7"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index cc211f318f1..52d2556bbf9 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -75,9 +75,27 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : undefined; const isSendDisabled = sendDisabledReason !== null; + const renderStopGenerationButton = (insidePendingAction: boolean) => ( + + ); + if (pendingAction) { return (
    + {isRunning ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( - ); + return renderStopGenerationButton(false); } if (showPlanFollowUpPrompt) { From 31891a1a0c208a6b244b64937741066c61fefd9c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 20:17:44 -0400 Subject: [PATCH 10/81] feat(web): fold plan mode and token-by-token output into Legacy features (#5664) Co-authored-by: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 4 +- .../Layers/ProviderRuntimeIngestion.ts | 4 +- .../components/settings/BetaSettingsPanel.tsx | 12 -- .../components/settings/SettingsPanels.tsx | 131 +++++++++++++----- .../src/components/settings/settingsSearch.ts | 20 +-- packages/contracts/src/settings.ts | 9 +- 6 files changed, 120 insertions(+), 60 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index dfc47320768..b4468bd4c6d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2199,7 +2199,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new streaming assistant message segment after approval", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; const resumedAt = "2026-03-28T07:00:02.000Z"; @@ -2306,7 +2306,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a86adea5232..40307cd9f25 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1655,7 +1655,7 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1691,7 +1691,7 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 4b96fb15398..740d3048f0e 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -60,7 +60,6 @@ export function BetaSettingsPanel() { const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); - const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); return ( @@ -115,17 +114,6 @@ export function BetaSettingsPanel() { ) : null} ) : null} - updateSettings({ planModeEnabled: Boolean(checked) })} - aria-label="Restore plan mode (legacy)" - /> - } - /> ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5b9347f0307..407f0e77be4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -79,6 +79,7 @@ import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Dialog, DialogDescription, @@ -476,8 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming - ? ["Assistant output"] + ...(settings.enableLegacyTokenStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming + ? ["Stream token by token"] : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks @@ -521,7 +523,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizePrompt, settings.fontSizeTerminal, settings.glassOpacity, - settings.enableAssistantStreaming, + settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, @@ -601,7 +603,7 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, @@ -1504,6 +1506,96 @@ function FontFamilySettingsRow({ ); } +// Both legacy rows sit behind the fold, so a settings-search jump has to +// expand the section before its target can mount and scroll. +const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ + "legacy-plan-mode", + "legacy-token-streaming", +]); + +/** + * Retired features kept only for users who still depend on them. Collapsed by + * default so they stay out of the everyday settings path; a settings-search + * jump to one of the rows unfolds the section. + */ +function LegacyFeaturesSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const [open, setOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); + // Unfold once per search jump; tracking the handled id lets the user fold + // the section back up without the still-set target immediately reopening it. + const lastExpandedTargetRef = useRef(null); + useEffect(() => { + if (searchTargetId === null) { + // A handled jump clears the target; forgetting it here lets a later + // jump to the same row expand the section again. + lastExpandedTargetRef.current = null; + return; + } + if (!LEGACY_FEATURE_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setOpen(true); + }, [searchTargetId]); + + return ( +
    + + +

    + Legacy features +

    + +
    + +
    + + updateSettings({ planModeEnabled: Boolean(checked) }) + } + aria-label="Plan mode (legacy)" + /> + } + /> + { + if (!checked) { + updateSettings({ enableLegacyTokenStreaming: false }); + return; + } + void (async () => { + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( + [ + "Turn on token-by-token output?", + "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", + ].join("\n"), + ); + if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); + })(); + }} + aria-label="Stream token by token (legacy)" + /> + } + /> +
    +
    +
    +
    + ); +} + export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -1664,33 +1756,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ enableAssistantStreaming: Boolean(checked) }) - } - aria-label="Stream assistant messages" - /> - } - /> - + + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 2dcdd66f136..4e8502442f7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -110,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Hide whitespace changes", to: "/settings/general", }, - { - id: "assistant-output", - title: "Assistant output", - to: "/settings/general", - }, { id: "provider-update-checks", title: "Provider update checks", @@ -156,6 +151,16 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Diagnostics", to: "/settings/general", }, + { + id: "legacy-plan-mode", + title: "Plan mode (legacy)", + to: "/settings/general", + }, + { + id: "legacy-token-streaming", + title: "Stream token by token (legacy)", + to: "/settings/general", + }, { id: "keybindings", title: "Keybindings", @@ -187,11 +192,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/beta", targetId: "sidebar-v2", }, - { - id: "restore-plan-mode", - title: "Restore plan mode (legacy)", - to: "/settings/beta", - }, { id: "archive", title: "Archived threads", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4b477227f26..0ef1a6a8b75 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -538,7 +538,12 @@ export const BackgroundActivitySettings = Schema.Struct({ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Legacy token-by-token assistant output. Deliberately a fresh key (was + // `enableAssistantStreaming`): decoding drops the old key, so everyone, + // including prior opt-ins, resets to the buffered default. + enableLegacyTokenStreaming: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New @@ -701,7 +706,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings - enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ From 0de9540739369301d5056870deeee3f258a03b14 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 21:06:10 -0400 Subject: [PATCH 11/81] feat: sidebar v2 is now the default sidebar (#5672) Co-authored-by: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 3 +- .../features/settings/SettingsRouteScreen.tsx | 23 +- .../src/features/threads/threadListV2.test.ts | 22 +- .../src/features/threads/threadListV2.ts | 12 +- .../threads/use-thread-list-v2-enabled.ts | 6 +- .../src/persistence/mobile-preferences.ts | 17 +- apps/web/src/branding.logic.ts | 45 - apps/web/src/branding.test.ts | 73 - apps/web/src/components/AppSidebarLayout.tsx | 26 +- apps/web/src/components/LegacySidebar.tsx | 3627 +++++++++ apps/web/src/components/Sidebar.logic.test.ts | 52 +- apps/web/src/components/Sidebar.logic.ts | 24 +- apps/web/src/components/Sidebar.tsx | 6595 +++++++++-------- apps/web/src/components/SidebarV2.tsx | 3711 ---------- .../components/settings/BetaSettingsPanel.tsx | 120 - .../components/settings/SettingsPanels.tsx | 108 +- .../settings/SettingsSidebarNav.tsx | 2 - .../src/components/settings/settingsSearch.ts | 23 +- apps/web/src/hooks/useSettings.ts | 31 +- apps/web/src/index.css | 16 +- apps/web/src/routeTree.gen.ts | 21 - apps/web/src/routes/_chat.tsx | 14 +- apps/web/src/routes/settings.beta.tsx | 11 - packages/contracts/src/settings.test.ts | 34 +- packages/contracts/src/settings.ts | 15 +- 25 files changed, 7223 insertions(+), 7408 deletions(-) create mode 100644 apps/web/src/components/LegacySidebar.tsx delete mode 100644 apps/web/src/components/SidebarV2.tsx delete mode 100644 apps/web/src/components/settings/BetaSettingsPanel.tsx delete mode 100644 apps/web/src/routes/settings.beta.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c1cb8588b5e..861f72178a6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -39,8 +39,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, - sidebarV2Enabled: false, - sidebarV2ConfiguredByUser: false, + legacySidebarEnabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 8547859adde..8bfff6a8747 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -131,7 +131,7 @@ function LocalSettingsRouteScreen() { - + @@ -519,7 +519,7 @@ function ConfiguredSettingsRouteScreen() { - + @@ -538,26 +538,27 @@ function GeneralSettingsSection() { } /** - * Device-local beta toggles. Mobile has no client-settings sync, so this is - * the counterpart of web's Settings → Beta backed by mobile preferences. + * Device-local legacy toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → General → Legacy features backed by + * mobile preferences. */ -function BetaSettingsSection() { +function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); return ( - + savePreferences({ threadListV2Enabled: value })} + label="Legacy Thread List" + value={!threadListV2Enabled} + onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} /> - One flat thread list in creation order. Active work renders as cards; settled threads - collapse to compact rows. Switch back any time. + Brings back the original grouped thread list. The default list is flat, in creation order: + active work renders as cards; settled threads collapse to compact rows. ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1316b3480c0..a9ea0138b84 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), + ).toBe(true); }); - it("honors an explicit device opt-out", () => { - expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); - expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); + it("honors an explicit legacy opt-in", () => { + expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( + false, + ); + expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( + true, + ); }); it("holds the default while preferences are still loading so the list does not remount", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ef9216ad96f..eba56ac8de5 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -103,23 +103,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** - * Thread List v2 is on by default on every app variant; the Settings → Beta - * toggle is an opt-out. Preferences persist as sparse patches, so `undefined` - * genuinely means "never chosen". + * The flat Thread List v2 is the default on every app variant; the Settings → + * Legacy toggle opts a device back into the grouped legacy list. Preferences + * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit opt-out lands anyway. + * is where every device without an explicit legacy opt-in lands anyway. */ export function resolveThreadListV2Enabled(input: { - readonly preference: boolean | undefined; + readonly legacyPreference: boolean | undefined; readonly preferencesLoaded: boolean; }): boolean { if (!input.preferencesLoaded) { return true; } - return input.preference ?? true; + return input.legacyPreference !== true; } export function resolveThreadListV2Status( diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index 266bda944ae..2672942c2d3 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -5,15 +5,15 @@ import { mobilePreferencesAtom } from "../../state/preferences"; import { resolveThreadListV2Enabled } from "./threadListV2"; /** - * Resolved Thread List v2 state: the device-local preference if the user has - * set one, otherwise the default (on). Every consumer must read through this + * Resolved Thread List v2 state: on unless the device opted into the legacy + * grouped list (Settings → Legacy). Every consumer must read through this * rather than the raw preference, which is undefined until explicitly chosen. */ export function useThreadListV2Enabled(): boolean { const preferencesResult = useAtomValue(mobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); return resolveThreadListV2Enabled({ - preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, preferencesLoaded: loaded, }); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 9a5ed82b3b8..bf40acb053b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -27,12 +27,13 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; /** - * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no - * client-settings sync, so the flat v2 thread list is opted out of per - * device. Undefined means the user has never chosen, which resolves to on — - * see `resolveThreadListV2Enabled`. + * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has + * no client-settings sync, so the legacy grouped thread list is opted into + * per device. Deliberately a fresh key (was `threadListV2Enabled`, an + * opt-out): sanitizing drops the old key, so every device resets to the + * default flat list — see `resolveThreadListV2Enabled`. */ - readonly threadListV2Enabled?: boolean; + readonly legacyThreadListEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -84,7 +85,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - threadListV2Enabled?: boolean; + legacyThreadListEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -121,8 +122,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.threadListV2Enabled === "boolean") { - preferences.threadListV2Enabled = parsed.threadListV2Enabled; + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } return preferences; } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 06d663ca0b4..056fbb76e6a 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,51 +11,6 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } -/** - * Whether the sidebar v2 beta is on by default for a build stage. - * - * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved - * from the client's own stage label rather than the connected server's version: - * v2 only exists in the client, so a stable client on a nightly server has - * nothing to turn on. - */ -export function resolveSidebarV2Default(stageLabel: string): boolean { - const stage = stageLabel.trim().toLowerCase(); - return stage === "nightly" || stage === "dev"; -} - -/** - * Resolved sidebar v2 state: an explicit choice if the user has made one, - * otherwise the default for this build stage. - * - * A stored `enabled: true` counts as an explicit choice even without the - * companion flag. `true` was never the schema default, so it can only have come - * from the Settings → Beta toggle — settings written before that flag existed - * would otherwise lose the opt-in and drop such users back to v1 on production. - * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored - * `updateChannel: "nightly"` as user-configured. - * - * `settingsHydrated` guards the startup window: client settings load - * asynchronously and the pre-hydration snapshot is just the schema defaults, so - * resolving against it would mount one sidebar and swap it out a tick later, - * remounting the tree. While hydrating, hold v1 — where both paths already - * start. - */ -export function resolveSidebarV2Enabled(input: { - readonly enabled: boolean; - readonly configuredByUser: boolean; - readonly settingsHydrated: boolean; - readonly stageLabel: string; -}): boolean { - if (!input.settingsHydrated) { - return false; - } - - return input.configuredByUser || input.enabled - ? input.enabled - : resolveSidebarV2Default(input.stageLabel); -} - export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e517d40b04f..e1c87bcf059 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, - resolveSidebarV2Default, - resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -116,74 +114,3 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); - -describe("resolveSidebarV2Default", () => { - it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(true); - }); - - it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(false); - }); -}); - -describe("resolveSidebarV2Enabled", () => { - const hydrated = { settingsHydrated: true } as const; - - it.each(["Alpha", "Latest"])( - "keeps a legacy opt-in on %s builds even without the companion flag", - (stageLabel) => { - // `true` was never the schema default, so it can only be an explicit - // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: true, - configuredByUser: false, - stageLabel, - }), - ).toBe(true); - }, - ); - - it("applies the stage default when the beta was never enabled or configured", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Nightly", - }), - ).toBe(true); - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Latest", - }), - ).toBe(false); - }); - - it("honors an explicit opt-out over the stage default", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: true, - stageLabel: "Nightly", - }), - ).toBe(false); - }); - - it("holds v1 until settings hydrate so the sidebar does not remount", () => { - expect( - resolveSidebarV2Enabled({ - enabled: true, - configuredByUser: true, - settingsHydrated: false, - stageLabel: "Nightly", - }), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5c6acd62aea..4888ded7d0f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,9 +14,11 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import ThreadSidebarV2 from "./SidebarV2"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, @@ -118,13 +120,11 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useSidebarV2Enabled(); - // Settings routes render the settings nav, which lives in the v1 component - // and is identical for both sidebars — so v1 stays mounted there. + const legacySidebarEnabled = useLegacySidebarEnabled(); + // Settings routes show the settings nav in place of whichever thread + // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); - const useSidebarV2 = sidebarV2Enabled && !isOnSettings; - const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // Subscribed rather than read once: the clamp must track live window size, @@ -188,7 +188,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"} className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" resizable={{ maxWidth: sidebarMaximumWidth, @@ -200,7 +199,16 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { onResize: setSidebarWidth, }} > - {useSidebarV2 ? : } + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} {children} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx new file mode 100644 index 00000000000..2c1f99ffa0f --- /dev/null +++ b/apps/web/src/components/LegacySidebar.tsx @@ -0,0 +1,3627 @@ +import { + ArchiveIcon, + ArrowUpDownIcon, + ChevronRightIcon, + CloudIcon, + ContainerIcon, + FolderPlusIcon, + Globe2Icon, + LoaderIcon, + SearchIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { + ChangeRequestStatusIcon, + prStatusIndicator, + PrStatusTooltipContent, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, + ThreadWorktreeIndicator, +} from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { useAtomValue } from "@effect/atom-react"; +import { autoAnimate } from "@formkit/auto-animate"; +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; +import { + type ContextMenuItem, + ProjectId, + type ScopedThreadRef, + type ResolvedKeybindingsConfig, + type SidebarProjectGroupingMode, + ThreadId, +} from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + type SidebarProjectSortOrder, + type SidebarThreadPreviewCount, + type SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; +import { isElectron } from "../env"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isMacPlatform } from "../lib/utils"; +import { + readThreadShell, + useProject, + useProjects, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; +import { previewEnvironment } from "../state/preview"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { readLocalApi } from "../localApi"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; + +import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { Kbd } from "./ui/kbd"; +import { + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "./ui/number-field"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "./ui/sidebar"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { openCommandPalette } from "../commandPaletteBus"; +import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, + getSidebarThreadIdsToPrewarm, + resolveAdjacentThreadId, + isContextMenuPointerDown, + isTrailingDoubleClick, + resolveProjectStatusIndicator, + resolveThreadRowClassName, + resolveThreadStatusPill, + orderItemsByPreferredIds, + shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + useThreadJumpHintVisibility, + ThreadStatusPill, +} from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { CommandDialogTrigger } from "./ui/command"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +const SIDEBAR_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", +}; +const SIDEBAR_THREAD_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", +}; +const SIDEBAR_LIST_ANIMATION_OPTIONS = { + duration: 180, + easing: "ease-out", +} as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; +const SIDEBAR_ICON_ACTION_BUTTON_CLASS = + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + +function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { + return Math.min( + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), + ) as SidebarThreadPreviewCount; +} + +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.title; + } + + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; +} + +function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { + return [ + project.projectKey, + ...project.memberProjects.map((member) => member.physicalProjectKey), + ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + +function buildThreadJumpLabelMap(input: { + keybindings: ResolvedKeybindingsConfig; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +interface SidebarThreadRowProps { + thread: SidebarThreadSummary; + projectCwd: string | null; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; + jumpLabel: string | null; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; +} + +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const isMobile = useIsMobile(); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const environment = useEnvironment(thread.environmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = environment?.label ?? null; + // A desktop-local secondary backend (e.g. the WSL backend) shows up as a + // bearer environment whose connection id is prefixed "local:". It runs on the + // user's own machine, so the cloud icon is misleading — label it "Local" and + // suppress the cloud icon (the project header already shows a container icon + // for desktop-local projects, see sidebarProjectGrouping). + const isDesktopLocalThread = + environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProject = useProject( + useMemo( + () => scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) — only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + const hasSelection = useThreadSelectionStore.getState().hasSelection(); + if (hasSelection && isSelected) { + void (async () => { + const result = await settlePromise(() => + handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + return; + } + + if (hasSelection) { + clearSelection(); + } + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
    , []); + + return ( + + +
    + {prStatus && ( + + + + + } + /> + + + + + )} + {threadStatus && } + {renamingThreadKey === threadKey ? ( + + ) : ( + + + {thread.title} + + } + /> + + {thread.title} + + + )} +
    +
    + {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} + + {terminalStatus && ( + + + } + > + + + {terminalStatus.label} + + )} +
    + {isConfirmingArchive ? ( + + ) : !isThreadRunning ? ( + appSettingsConfirmThreadArchive ? ( +
    + +
    + ) : ( + + + +
    + } + /> + Archive + + ) + ) : null} + + + {isRemoteThread && !isDesktopLocalThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} + + +
    +
    + + + ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; +} + +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => +
    + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
    + + + + { + if (!open) { + closeProjectRenameDialog(); + } + }} + > + + + Rename project + + {projectRenameTarget + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` + : "Update the project title."} + + + +
    + Project title + setProjectRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitProjectRename(); + } + }} + /> +
    + {projectRenameTarget?.environmentLabel ? ( +

    + Environment: {projectRenameTarget.environmentLabel} +

    + ) : null} +
    + + + + +
    +
    + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
    + Grouping rule + +
    +

    + {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

    +
    + + + + +
    +
    + + ); +}); + +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); + +function LocalSecondaryStatus() { + const { environments } = useEnvironments(); + // The desktop reports which local secondary backends (e.g. the WSL backend) + // exist; the hook polls because the bridge has no change event. A backend that + // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we + // surface "Connecting" straight from the bootstrap list and clear it once the + // matching environment reports a connected phase. + const secondaries = useDesktopLocalBootstraps(); + + // Connected desktop-local environments keyed by their backend URL so we can + // match a bootstrap (which only knows the URL) to its connection phase. + const localEnvByUrl = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + if ( + isDesktopLocalConnectionTarget(environment.entry.target) && + environment.displayUrl !== null + ) { + map.set(environment.displayUrl, { + phase: environment.connection.phase, + error: environment.connection.error, + }); + } + } + return map; + }, [environments]); + + const connecting: string[] = []; + const failed: Array<{ label: string; error: string | null }> = []; + for (const bootstrap of secondaries) { + const env = + bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; + if (env?.phase === "connected") { + continue; + } + if (env?.phase === "error") { + failed.push({ label: bootstrap.label, error: env.error }); + continue; + } + connecting.push(bootstrap.label); + } + + if (connecting.length === 0 && failed.length === 0) { + return null; + } + + return ( + + {connecting.length > 0 ? ( + + + + Connecting {connecting.join(", ")} + + + ) : null} + {failed.length > 0 ? ( + + + Couldn't connect {failed.map((entry) => entry.label).join(", ")} + + {failed + .map((entry) => entry.error) + .filter(Boolean) + .join("; ") || "The backend didn't respond."} + + + ) : null} + + ); +} + +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + threadPreviewCount, + onProjectSortOrderChange, + onThreadSortOrderChange, + onThreadPreviewCountChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; +}) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + + return ( + + + + } + > + + + Sidebar options + + + +
    + Sort projects +
    + { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
    + +
    + Sort threads +
    + { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
    + +
    + Visible threads +
    +
    + + + + { + event.stopPropagation(); + }} + /> + + + +
    +
    +
    +
    + ); +} + +function SortableProjectItem({ + projectId, + disabled = false, + children, +}: { + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); +} + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + updateSettings: ReturnType; + openAddProject: () => void; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + sortedProjects: readonly SidebarProjectSnapshot[]; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + threadPreviewCount, + updateSettings, + openAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + sortedProjects, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + newThreadShortcutLabel, + commandPaletteShortcutLabel, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); + + return ( + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + + +
    + Projects +
    + + + + } + > + + + Add project + +
    +
    + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && ( +
    No projects yet
    + )} +
    +
    + ); +}); + +export default function LegacySidebar() { + const projects = useProjects(); + const sidebarThreads = useThreadShells(); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const updateSettings = useUpdateClientSettings(); + const handleNewThread = useNewThreadHandler(); + const { archiveThread, deleteThread } = useThreadActions(); + const { isMobile, setOpenMobile } = useSidebar(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const desktopUpdateState = useDesktopUpdateState(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + }, [projectOrder, projects]); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); + + const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }); + }, [ + environmentLabelById, + desktopLocalEnvironmentIds, + orderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }), + [routeTerminalOpen], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); + reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); + }, + [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const visibleThreads = useMemo( + () => sidebarThreads.filter((thread) => thread.archivedAt === null), + [sidebarThreads], + ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, sidebarThreadPreviewCount); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sortedProjects, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const sidebarShortcutContext = { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }; + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + const shortcutContext = getCurrentSidebarShortcutContext(); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + window.addEventListener("keydown", onWindowKeyDown); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + orderedSidebarThreadKeys, + platform, + routeThreadKey, + sidebarThreadByKey, + threadJumpThreadKeys, + ]); + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (!useThreadSelectionStore.getState().hasSelection()) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, [clearSelection]); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); + const handleDesktopUpdateButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + showDesktopUpdateDownloadedToast(bridge, result.state); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + return; + } + + if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + } + }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + + const expandThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (current.has(projectKey)) return current; + const next = new Set(current); + next.add(projectKey); + return next; + }); + }, []); + + const collapseThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (!current.has(projectKey)) return current; + const next = new Set(current); + next.delete(projectKey); + return next; + }); + }, []); + + return ( + <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} + + + + + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d15433e56b9..bfe4162cd20 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,7 +17,7 @@ import { resolveProjectStatusIndicator, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, - resolveSidebarV2Status, + resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, @@ -25,11 +25,11 @@ import { shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebarV2, + sortSettledThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, - sortPinnedThreadsForSidebarV2, - sortThreadsForSidebarV2, + sortPinnedThreadsForSidebar, + sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -627,7 +627,7 @@ describe("isContextMenuPointerDown", () => { }); }); -describe("resolveSidebarV2Status", () => { +describe("resolveSidebarThreadStatus", () => { const session = { threadId: ThreadId.make("thread-1"), status: "running" as const, @@ -642,15 +642,17 @@ describe("resolveSidebarV2Status", () => { const idle = { hasPendingApprovals: false, hasPendingUserInput: false }; it("prioritizes approval over a running session", () => { - expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe( + expect(resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, session })).toBe( "approval", ); }); it("prioritizes awaiting input over a running session, below approval", () => { - expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input"); + expect(resolveSidebarThreadStatus({ ...idle, hasPendingUserInput: true, session })).toBe( + "input", + ); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, hasPendingUserInput: true, @@ -660,9 +662,9 @@ describe("resolveSidebarV2Status", () => { }); it("reports working for running and starting sessions", () => { - expect(resolveSidebarV2Status({ ...idle, session })).toBe("working"); + expect(resolveSidebarThreadStatus({ ...idle, session })).toBe("working"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "starting" as const }, }), @@ -671,19 +673,19 @@ describe("resolveSidebarV2Status", () => { it("reports failed only while the session status is error", () => { expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "error" as const, lastError: "boom" }, }), ).toBe("failed"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "stopped" as const, lastError: "persisted" }, }), ).toBe("ready"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "ready" as const, lastError: "persisted" }, }), @@ -691,7 +693,7 @@ describe("resolveSidebarV2Status", () => { }); it("defaults to ready with no session", () => { - expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready"); + expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready"); }); }); @@ -715,14 +717,14 @@ describe("searchSidebarThreadsByTitle", () => { }); }); -describe("sortThreadsForSidebarV2", () => { +describe("sortThreadsForSidebar", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, createdAt: input.createdAt, }); it("orders by creation time, newest first, ignoring activity", () => { - const sorted = sortThreadsForSidebarV2([ + const sorted = sortThreadsForSidebar([ sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }), sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), @@ -732,7 +734,7 @@ describe("sortThreadsForSidebarV2", () => { }); it("breaks creation-time ties by id so the order is stable", () => { - const sorted = sortThreadsForSidebarV2([ + const sorted = sortThreadsForSidebar([ sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), ]); @@ -838,7 +840,7 @@ describe("planPinnedReorder", () => { }); }); -describe("sortPinnedThreadsForSidebarV2", () => { +describe("sortPinnedThreadsForSidebar", () => { const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ id: input.id, createdAt: input.createdAt, @@ -846,7 +848,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); it("sorts keyed threads by key ahead of keyless threads in creation order", () => { - const sorted = sortPinnedThreadsForSidebarV2([ + const sorted = sortPinnedThreadsForSidebar([ pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), @@ -862,7 +864,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); it("breaks equal keys by id so raced writes render identically everywhere", () => { - const sorted = sortPinnedThreadsForSidebarV2([ + const sorted = sortPinnedThreadsForSidebar([ pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), ]); @@ -871,7 +873,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); }); -describe("sortSettledThreadsForSidebarV2", () => { +describe("sortSettledThreadsForSidebar", () => { const settled = (input: { id: string; settledAt?: string | null; @@ -887,7 +889,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("orders by settle time, most recently settled first", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "settled-first", settledAt: "2026-03-09T10:00:00.000Z", @@ -905,7 +907,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), @@ -917,7 +919,7 @@ describe("sortSettledThreadsForSidebarV2", () => { it("counts a turn completion as activity for auto-settled threads", () => { // The message came in before the other thread's, but its turn finished // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), settled({ id: "completed-later", @@ -930,7 +932,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("breaks timestamp ties by id so the order is stable", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), ]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e516822fd56..cae26f5d6bd 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -423,21 +423,27 @@ export function resolveThreadRowClassName(input: { ); } -// ── Sidebar v2 status model ───────────────────────────────────────── +// ── Sidebar thread status model ───────────────────────────────────── // Five visual states, three colors: color is reserved for "act now" // (approval), "in motion" (working), and "broken" (failed). Ready is the // unlabeled resting state — the agent stopped and is waiting on the user, // whether it finished, asked a question, or proposed a plan. // Unread completion is tracked separately: it describes whether a ready // thread needs attention, not what the thread is currently doing. -export type SidebarV2Status = "approval" | "input" | "working" | "monitoring" | "failed" | "ready"; - -type SidebarV2StatusInput = Pick< +export type SidebarThreadStatus = + | "approval" + | "input" + | "working" + | "monitoring" + | "failed" + | "ready"; + +type SidebarThreadStatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" >; -export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { +export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals) { return "approval"; } @@ -496,11 +502,11 @@ export function firstValidTimestamp( return null; } -// v2 sort: static creation order, newest thread on top. Activity NEVER +// Sidebar sort: static creation order, newest thread on top. Activity NEVER // reorders the list — a row holds its position from open until settled, so // the screen only moves at lifecycle transitions. Status (including pending // approval) is carried by each card's edge strip, not by position. -export function sortThreadsForSidebarV2< +export function sortThreadsForSidebar< T extends { readonly id: string; readonly createdAt: string }, >(threads: readonly T[]): T[] { return [...threads].toSorted( @@ -517,7 +523,7 @@ export { pinOrderKeyBetween, planPinnedReorder, } from "@t3tools/client-runtime/state/thread-sort"; -export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** * Search the already-ordered sidebar thread collection by title only. @@ -566,7 +572,7 @@ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | // Settled rows are history, so they order by when the work ENDED, not when // the thread was created or last touched. -export function sortSettledThreadsForSidebarV2< +export function sortSettledThreadsForSidebar< T extends SettledTimestampInput & { readonly id: string }, >(threads: readonly T[]): T[] { const timestampMs = (thread: T) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 232ea0998ef..75c580402b1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,99 +1,79 @@ -import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - CloudIcon, - ContainerIcon, - FolderPlusIcon, - Globe2Icon, - LoaderIcon, - SearchIcon, - SquarePenIcon, - TerminalIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - PrStatusTooltipContent, - resolveThreadPr, - terminalStatusFromRunningIds, - ThreadStatusLabel, - ThreadWorktreeIndicator, -} from "./ThreadStatusIndicators"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; +import { useAtomValue } from "@effect/atom-react"; import { DndContext, - type DragCancelEvent, - type CollisionDetection, PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, + closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - type ContextMenuItem, - ProjectId, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, - type SidebarProjectGroupingMode, - ThreadId, -} from "@t3tools/contracts"; + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, scopeProjectRef, scopeThreadRef, + scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { + AlarmClockIcon, + AlarmClockOffIcon, + CheckIcon, + ChevronDownIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleDashedIcon, + ClockIcon, + CopyIcon, + FolderIcon, + FolderPlusIcon, + GitBranchIcon, + EllipsisIcon, + MessageSquareIcon, + PinIcon, + PlusIcon, + SearchIcon, + ServerIcon, + SquarePenIcon, + TerminalIcon, + Trash2Icon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; -import { - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - type SidebarProjectSortOrder, - type SidebarThreadPreviewCount, - type SidebarThreadSortOrder, -} from "@t3tools/contracts/settings"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform } from "../lib/utils"; -import { - readThreadShell, - useProject, - useProjects, - useThreadShells, - useThreadShellsForProjectRefs, -} from "../state/entities"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; -import { useAtomCommand } from "../state/use-atom-command"; -import { previewEnvironment } from "../state/preview"; -import { - legacyProjectCwdPreferenceKey, - resolveProjectExpanded, - useUiStateStore, -} from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -102,39 +82,89 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useDesktopUpdateState } from "../state/desktopUpdate"; - +import { + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +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"; +import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; -import { threadEnvironment, useEnvironmentThread } from "../state/threads"; -import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { formatRelativeTimeLabel } from "../timestampFormat"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { Kbd } from "./ui/kbd"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { + buildBulkTitleRegenerationContextMenuItem, + formatWorkingDurationLabel, + firstValidTimestampMs, + hasUnseenCompletion, + isTrailingDoubleClick, + orderItemsByPreferredIds, + planPinnedReorder, + resolveAdjacentThreadId, + resolveSettledTimestamp, + resolveSidebarThreadStatus, + searchSidebarThreadsByTitle, + resolveWorkingStartedAt, + shouldNavigateAfterProjectRemoval, + sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebar, + sortSettledThreadsForSidebar, + sortThreadsForSidebar, +} from "./Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + prStatusIndicator, + resolveThreadPr, + settledPrHoverColorClass, + terminalStatusFromRunningIds, + type TerminalStatusIndicator, +} from "./ThreadStatusIndicators"; import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, -} from "./desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; + resolveSnoozePresets, + snoozeWakeDescription, + snoozeWakeLabel, + type SnoozePreset, +} from "./Sidebar.snooze"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Dialog, @@ -146,1542 +176,1867 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "./ui/number-field"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { - SidebarContent, - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - useSidebar, -} from "./ui/sidebar"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; -import { - archiveSelectedThreadEntries, - buildMultiSelectThreadContextMenuItems, - getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, - isContextMenuPointerDown, - isTrailingDoubleClick, - resolveProjectStatusIndicator, - resolveThreadRowClassName, - resolveThreadStatusPill, - orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useThreadJumpHintVisibility, - ThreadStatusPill, -} from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; +import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useIsMobile } from "~/hooks/useMediaQuery"; -import { CommandDialogTrigger } from "./ui/command"; -import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; -import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", -}; -const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", -}; -const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", -} as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { useComposerDraftStore } from "../composerDraftStore"; + +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. +const SETTLED_TAIL_INITIAL_COUNT = 10; +const SETTLED_TAIL_PAGE_COUNT = 25; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; -const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; -function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { - useEnvironmentThread(threadRef.environmentId, threadRef.threadId); - return null; +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; } -function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { - return Math.min( - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), - ) as SidebarThreadPreviewCount; +function threadTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } -function formatProjectMemberActionLabel( - member: SidebarProjectGroupMember, - groupedProjectCount: number, -): string { - if (groupedProjectCount <= 1) { - return member.title; - } +// Settled rows read "how long ago did this wrap up", matching their sort +// key: both go through resolveSettledTimestamp so label and order can't +// disagree. +function settledTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} - return member.environmentLabel - ? `${member.environmentLabel} — ${member.workspaceRoot}` - : member.workspaceRoot; +// Floats at the row's right edge, vertically centered, while the jump +// modifier is held. An overlay pill instead of an inline slot: the hint +// must neither displace the status/time label (holding ⌘ used to blank +// out "Working") nor shift any layout when it appears. pointer-events-none +// so it never swallows clicks meant for the settle/un-settle buttons it +// can overlap. +function JumpHintBadge(props: { label: string }) { + return ( + + {props.label} + + ); } -function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { - return [ - project.projectKey, - ...project.memberProjects.map((member) => member.physicalProjectKey), - ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), - ]; +// Self-ticking so only this span re-renders each second, not the whole row. +function WorkingDuration(props: { startedAt: string | null }) { + const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; + const [, setTick] = useState(0); + useEffect(() => { + if (Number.isNaN(startedMs)) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); + return () => window.clearInterval(id); + }, [startedMs]); + if (Number.isNaN(startedMs)) return null; + return ( + + {formatWorkingDurationLabel(Date.now() - startedMs)} + + ); } -function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { - switch (mode) { - case "repository": - return "Projects from the same repository share one sidebar row."; - case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; - case "separate": - return "Every project path gets its own sidebar row."; - } +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -function buildThreadJumpLabelMap(input: { - keybindings: ResolvedKeybindingsConfig; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } +function SidebarThreadTooltip({ + thread, + projectTitle, + projectCwd, + environmentLabel, + driverKind, + modelInstanceId, + modelLabel, + branchMismatch, + terminalStatus, + terminalProcessCount, +}: { + thread: SidebarThreadSummary; + projectTitle: string | null; + projectCwd: string | null; + environmentLabel: string | null; + driverKind: ProviderInstanceEntry["driverKind"] | null; + modelInstanceId: string; + modelLabel: string; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; +}) { + return ( + +
    +
    + {thread.title} +
    +
    + {projectTitle ? ( +
    + +
    {projectTitle}
    +
    + ) : null} + {environmentLabel ? ( +
    + +
    {environmentLabel}
    +
    + ) : null} + {thread.branch ? ( +
    + +
    {thread.branch}
    +
    + ) : null} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} + {driverKind ? ( +
    + +
    {modelLabel}
    +
    + ) : null} + {terminalStatus ? ( +
    + +
    + {terminalProcessLabel(terminalProcessCount)} +
    +
    + ) : null} + {thread.session?.lastError ? ( +
    + +
    Error occurred
    +
    + ) : null} +
    +
    +
    + ); +} - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +/** + * 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; + timestampFormat: TimestampFormat; +}) { + const { open, onOpenChange, onSnooze, timestampFormat } = 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(), timestampFormat) : []), + [open, timestampFormat], + ); + 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) => ( + + ))} + + + ); +} + +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } -interface SidebarThreadRowProps { +const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; + variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + 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; + // Renders the pin glyph. Pinned cards keep the full settle/snooze quick + // actions: settling clears the pin server-side, and snoozing hides the + // card until wake with the pin intact underneath. The glyph is also the + // in-row pin state cue (the pinned block has no header), so it always + // shows while pinned; it only becomes a clickable unpin quick-action once + // the pinning capability is confirmed, and stays a passive marker while + // the descriptor is not loaded. Pinning itself lives in the context menu. + pinningSupported: boolean; + isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; + // 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; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + timestampFormat: TimestampFormat; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} - -export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + 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; + onUnpin: (threadRef: ScopedThreadRef) => void; + onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { const { - orderedProjectThreadKeys, - isActive, - jumpLabel, - appSettingsConfirmThreadArchive, - renamingThreadKey, + isRenaming, + onChangeRequestState, + onCancelRename, + onCommitRename, + onContextMenu, + onAcknowledgeWoke, + onRenameTitleChange, + onSettle, + onSnooze, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + onUnsnooze, + onUnpin, renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, thread, + variant, + variantAction, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); const threadKey = scopedThreadKey(threadRef); + const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); - const isMobile = useIsMobile(); - const discoveredPorts = useThreadDiscoveredPorts({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = environment?.label ?? null; - // A desktop-local secondary backend (e.g. the WSL backend) shows up as a - // bearer environment whose connection id is prefixed "local:". It runs on the - // user's own machine, so the cloud icon is misleading — label it "Local" and - // suppress the cloud icon (the project header already shows a container icon - // for desktop-local projects, see sidebarProjectGrouping). - const isDesktopLocalThread = - environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) - : null; - // For grouped projects, the thread may belong to a different environment - // than the representative project. Look up the thread's own project cwd - // so git status (and thus PR detection) queries the correct path. - const threadProject = useProject( - useMemo( - () => scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const terminalProcessCount = runningTerminalIds.length; + + const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); - const isHighlighted = isActive || isSelected; - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open preview", - description: - error instanceof Error ? error.message : "The preview could not be opened.", - }), - ); - })(); - }, - [discoveredPorts, navigateToThread, openPreview, threadRef], - ); - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - const clearConfirmingArchive = useCallback(() => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }, [setConfirmingArchiveThreadKey, threadKey]); - const handleMouseLeave = useCallback(() => { - clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; + const prState = pr?.state ?? null; + + // Same semantics as the legacy sidebar (never-visited counts as read): + // switching sidebars must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarThreadStatus(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 the pill clears only when the user re-engages: + // reading a completion-triggered wake, clicking the pill, sending a + // message, settling, archiving — or finishing the work outright (merged + // or closed PR). Timer wakes survive a mere visit. 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) && + prState !== "merged" && + prState !== "closed"; + // 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, 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 === "monitoring" || status === "approval" || status === "input"; + const shouldRecede = + (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; + // Status hues follow the system-wide convention set by sidebar v1 and the + // mobile Live Activity/widgets (amber approval, indigo input, sky working) + // so a thread reads the same color everywhere it surfaces. + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + // No shimmer: a label that animates forever is noise in a sidebar + // full of them (and repaints every vsync on high-refresh displays). + // Working is a background state, so it rests at the dim end of what + // the old pulse cycled through; only the thread you have open gets + // the label at full strength. + className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), } - clearConfirmingArchive(); - }); + : status === "monitoring" + ? { + // Monitoring is calm background presence, not active progress + // (monitoring-pill D6), so it keeps the label at full strength. + label: "Monitoring", + icon: null, + className: "text-sky-600 dark:text-sky-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "text-indigo-600 dark:text-indigo-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "text-red-700 dark:text-red-300", + } + : isWoke + ? { + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; + const isWokeStatus = topStatus?.icon === "woke"; + + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const detailsTooltip = ( + + ); + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); }, - [clearConfirmingArchive], + [onThreadClick, threadRef], ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], - ); - const handleRowDoubleClick = useCallback( - (event: React.MouseEvent) => { - // Already renaming this row: a double-click on the row chrome (outside the - // input) must not restart and discard the in-progress edit. - if (renamingThreadKey === threadKey) return; - // On mobile the first tap navigates and closes the sidebar sheet, so the - // inline rename can't be shown. Renaming there stays on the context menu. - if (isMobile) return; - // cmd/ctrl/shift double-clicks are multi-select intent, not rename. - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - // Ignore double-clicks bubbling from nested controls (PR status, port, - // archive buttons) — only the row body should enter inline rename. - if ((event.target as HTMLElement).closest("button, a")) return; + const handleAcknowledgeWokeClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); - startThreadRename(threadKey, thread.title); + event.stopPropagation(); + if (props.wokeAt === null) return; + onAcknowledgeWoke(threadRef, props.wokeAt); }, - [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + [onAcknowledgeWoke, props.wokeAt, threadRef], ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); - navigateToThread(threadRef); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); }, - [navigateToThread, threadRef], + [onContextMenu, threadRef], ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void (async () => { - const result = await settlePromise(() => - handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - return; - } - - if (hasSelection) { - clearSelection(); - } - void (async () => { - const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); + onThreadActivate(threadRef); }, - [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], + [onThreadActivate, threadRef], ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - if (!prStatus) return; - openPrLink(event, prStatus.url); - }, - [openPrLink, prStatus], - ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], + [isRenaming, onStartRename, thread.title, threadRef], ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); + renameCommittedRef.current = true; + onCancelRename(); } }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], ); - const handleRenameInputBlur = useCallback(() => { - if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); - // Keep clicks/double-clicks inside the rename input from bubbling to the row. - // Without stopping `dblclick`, double-clicking to select a word would re-fire - // the row's rename handler and reset the in-progress edit back to the title. - const handleRenameInputClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); - const stopPropagationOnPointerDown = useCallback( - (event: React.PointerEvent) => { + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); event.stopPropagation(); + onSettle(threadRef); }, - [], + [onSettle, threadRef], ); - const handleConfirmArchiveClick = useCallback( - (event: React.MouseEvent) => { + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(threadRef); + onUnsettle(threadRef); }, - [attemptArchiveThread, clearConfirmingArchive, threadRef], + [onUnsettle, threadRef], ); - const handleStartArchiveConfirmation = useCallback( - (event: React.MouseEvent) => { + const handleUnsnoozeClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - setConfirmingArchiveThreadKey(threadKey); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); - }); + onUnsnooze(threadRef); }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + [onUnsnooze, threadRef], ); - const handleArchiveImmediateClick = useCallback( - (event: React.MouseEvent) => { + const handleUnpinClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - void attemptArchiveThread(threadRef); + onUnpin(threadRef); }, - [attemptArchiveThread, threadRef], + [onUnpin, threadRef], ); - const rowButtonRender = useMemo(() =>
    , []); - - return ( - { + 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); + }, + [openPrLink, pr], + ); + + // All sidebar rows share one surface model. Live threads used to look + // like elevated cards while settled threads were plain rows, leaving neither + // a useful hierarchy nor a reliable hover cue. Status now lives in the row + // content; surface is reserved for interaction (hover, multi-select, route). + const rowSurfaceClassName = cn( + "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + props.isActive + ? "bg-sidebar-row-active text-sidebar-foreground" + : isSelected + ? "bg-sidebar-row-selected text-sidebar-foreground" + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + isInFlight && + !props.isActive && + !isSelected && + "opacity-70 transition-opacity hover:opacity-100", + ); + + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ) : ( + - + ); + + const prBadge = + prStatus && pr ? ( + - } + #{pr.number} + + ) : null; + const terminalStatusIcon = terminalStatus ? ( + + + + ) : null; + + if (variant === "slim") { + return ( +
  • + + - - - - - )} - {threadStatus && } - {renamingThreadKey === threadKey ? ( - - ) : ( - - - {thread.title} - - } + } + > + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} + + - - {thread.title} - - - )} - -
    - {discoveredPorts.length > 0 && ( - - + {title} + {terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {/* The PR badge stays outside the hover-fading slot: it must + remain visible AND clickable while the row is hovered. Only + the time/jump label yields to the settle affordance. */} + {prBadge} + + + {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. - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
    + aria-label="Dismiss Woke notification" + title="Dismiss Woke notification" + onClick={handleAcknowledgeWokeClick} + className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" + > + + Woke + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( -
    + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + ) : ( - - - -
    - } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && !isDesktopLocalThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - - } - > - {jumpLabel} - - {jumpLabel} - + + )} + + {props.jumpLabel ? : null} + + {detailsTooltip} + +
  • + ); + } + + const diff = latestTurnDiff(thread); + + const sortable = props.sortable; + return ( +
  • + + + } + > +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + {props.isPinned ? ( + props.pinningSupported ? ( + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, + actions on hover/keyboard focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} + + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( - {formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + className={cn( + // focus-visible, not focus-within: a mouse click leaves + // the Settle button focused, and a plain focus-within + // would keep the controls pinned over the status label + // once the pointer moves away (e.g. after a failed + // settle) instead of cross-fading back. + "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100", + snoozeMenuOpen && "pointer-events-auto static opacity-100", )} + > + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} - )} + ) : null} - +
    +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( + {thread.branch} + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    - - - + {props.jumpLabel ? : null} +
    + {detailsTooltip} +
    +
  • ); }); -interface SidebarProjectThreadListProps { - projectKey: string; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; } -const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, -) { - const { - projectKey, - projectExpanded, - hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - projectCwd, - activeRouteThreadKey, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, - } = props; - const showMoreButtonRender = useMemo(() => - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - - - - - { - if (!open) { - closeProjectRenameDialog(); - } - }} - > - - - Rename project - - {projectRenameTarget - ? `Update the title for ${projectRenameTarget.workspaceRoot}.` - : "Update the project title."} - - - -
    - Project title - setProjectRenameTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void submitProjectRename(); - } - }} - /> -
    - {projectRenameTarget?.environmentLabel ? ( -

    - Environment: {projectRenameTarget.environmentLabel} -

    - ) : null} -
    - - - - -
    -
    - - { - if (!open) { - closeProjectGroupingDialog(); - } - }} - > - - - Project grouping - - {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` - : "Choose how this project should be grouped in the sidebar."} - - - -
    - Grouping rule - -
    -

    - {projectGroupingSelection === "inherit" - ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) - : projectGroupingModeDescription(projectGroupingSelection)} -

    -
    - - - - -
    -
    - - ); -}); - -const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - - ); -}); - -function LocalSecondaryStatus() { - const { environments } = useEnvironments(); - // The desktop reports which local secondary backends (e.g. the WSL backend) - // exist; the hook polls because the bridge has no change event. A backend that - // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we - // surface "Connecting" straight from the bootstrap list and clear it once the - // matching environment reports a connected phase. - const secondaries = useDesktopLocalBootstraps(); - - // Connected desktop-local environments keyed by their backend URL so we can - // match a bootstrap (which only knows the URL) to its connection phase. - const localEnvByUrl = useMemo(() => { - const map = new Map(); - for (const environment of environments) { - if ( - isDesktopLocalConnectionTarget(environment.entry.target) && - environment.displayUrl !== null - ) { - map.set(environment.displayUrl, { - phase: environment.connection.phase, - error: environment.connection.error, - }); - } - } - return map; - }, [environments]); - - const connecting: string[] = []; - const failed: Array<{ label: string; error: string | null }> = []; - for (const bootstrap of secondaries) { - const env = - bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; - if (env?.phase === "connected") { - continue; - } - if (env?.phase === "error") { - failed.push({ label: bootstrap.label, error: env.error }); - continue; - } - connecting.push(bootstrap.label); - } - - if (connecting.length === 0 && failed.length === 0) { - return null; - } - - return ( - - {connecting.length > 0 ? ( - - - - Connecting {connecting.join(", ")} - - - ) : null} - {failed.length > 0 ? ( - - - Couldn't connect {failed.map((entry) => entry.label).join(", ")} - - {failed - .map((entry) => entry.error) - .filter(Boolean) - .join("; ") || "The backend didn't respond."} - - - ) : null} - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; - -function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - threadPreviewCount, - onProjectSortOrderChange, - onThreadSortOrderChange, - onThreadPreviewCountChange, -}: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; -}) { - const handleThreadPreviewCountChange = useCallback( - (nextValue: number | null) => { - if (nextValue === null) { - return; - } - - const clampedValue = clampSidebarThreadPreviewCount(nextValue); - if (clampedValue !== threadPreviewCount) { - onThreadPreviewCountChange(clampedValue); - } - }, - [onThreadPreviewCountChange, threadPreviewCount], - ); - - return ( - - - - } - > - - - Sidebar options - - - -
    - Sort projects -
    - { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
    - -
    - Sort threads -
    - { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
    - -
    - Visible threads -
    -
    - - - - { - event.stopPropagation(); - }} - /> - - - -
    -
    -
    -
    - ); -} - -function SortableProjectItem({ - projectId, - disabled = false, - children, -}: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; -}) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); -} - -interface SidebarProjectsContentProps { - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType; - openAddProject: () => void; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - newThreadShortcutLabel: string | null; - commandPaletteShortcutLabel: string | null; - threadJumpLabelByKey: ReadonlyMap; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; -} - -const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, -) { - const { - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - threadPreviewCount, - updateSettings, - openAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - sortedProjects, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - threadJumpLabelByKey, - attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - - const handleProjectSortOrderChange = useCallback( - (sortOrder: SidebarProjectSortOrder) => { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadSortOrderChange = useCallback( - (sortOrder: SidebarThreadSortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadPreviewCountChange = useCallback( - (count: SidebarThreadPreviewCount) => { - updateSettings({ sidebarThreadPreviewCount: count }); - }, - [updateSettings], - ); - - return ( - - - - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - - - - - } - > - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - - -
    - Projects -
    - - - - } - > - - - Add project - -
    -
    - - {isManualProjectSorting ? ( - - - project.projectKey)} - strategy={verticalListSortingStrategy} - > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - ))} - - )} - - {projectsLength === 0 && ( -
    No projects yet
    - )} -
    -
    - ); -}); - -export default function Sidebar() { - const projects = useProjects(); - const sidebarThreads = useThreadShells(); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); - const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - const updateSettings = useUpdateClientSettings(); - const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); - const { isMobile, setOpenMobile } = useSidebar(); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const openAddProjectCommandPalette = useCallback( - () => openCommandPalette({ open: "add-project" }), - [], - ); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const desktopUpdateState = useDesktopUpdateState(); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const platform = navigator.platform; - const shortcutModifiers = useShortcutModifierState(); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const environmentLabelById = useMemo( - () => - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - const desktopLocalEnvironmentIds = useMemo( - () => - new Set( - environments - .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ), - [environments], - ); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - }, [projectOrder, projects]); - - // Build a mapping from physical project key → logical project key for - // cross-environment grouping. Projects that share a repositoryIdentity - // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - return buildPhysicalToLogicalProjectKeyMap({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }); - }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); - const projectPhysicalKeyByScopedRef = useMemo( - () => - new Map( - orderedProjects.map((project) => [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - derivePhysicalProjectKey(project), - ]), - ), - [orderedProjects], - ); - - const sidebarProjects = useMemo(() => { - return buildSidebarProjectSnapshots({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), - }); - }, [ - environmentLabelById, - desktopLocalEnvironmentIds, - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - ]); - - const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], - ); - const sidebarThreadByKey = useMemo( - () => - new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreads], - ); - // Resolve the active route's project key to a logical key so it matches the - // sidebar's grouped project entries. - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; - } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); - - // Group threads by logical project key so all threads from grouped projects - // are displayed together. - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreads) { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }), - [routeTerminalOpen], - ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], - ); - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], - ); - - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); - }, []); - - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjects.map( - (member) => member.physicalProjectKey, - ); - const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); - reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); - }, - [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], - ); - - const handleProjectDragStart = useCallback( - (_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; + })(); }, - [sidebarProjectSortOrder], - ); - - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }, []); - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }, []); - - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); - const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - return sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - }, [ - sidebarProjectSortOrder, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjectByKey, - sidebarProjects, - visibleThreads, - ]); - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), [ - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, + attemptPin, + attemptSettle, + attemptSnooze, + attemptUnpin, + attemptUnsettle, + attemptUnsnooze, + confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + projectCwdByKey, + serverConfigs, + startThreadRename, + updateThreadMetadata, + timestampFormat, ], ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const sidebarShortcutContext = { - terminalFocus: false, - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - const threadJumpLabelByKey = useMemo( - () => - buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: sidebarShortcutContext.terminalOpen, - threadJumpCommandByKey, - }), - [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], - ); - const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { - platform, - context: sidebarShortcutContext, - }, - ); - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - const prewarmedSidebarThreadKeys = useMemo( - () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), - [visibleSidebarThreadKeys], - ); - const prewarmedSidebarThreadRefs = useMemo( - () => - prewarmedSidebarThreadKeys.flatMap((threadKey) => { - const ref = parseScopedThreadKey(threadKey); - return ref ? [ref] : []; - }), - [prewarmedSidebarThreadKeys], + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, ); - - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); - }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); - useEffect(() => { - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - const shortcutContext = getCurrentSidebarShortcutContext(); - - if (event.defaultPrevented || event.repeat) { - return; - } - + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); return; } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); }; - window.addEventListener("keydown", onWindowKeyDown); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - }; + return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ - getCurrentSidebarShortcutContext, keybindings, navigateToThread, - orderedSidebarThreadKeys, - platform, + orderedThreadKeys, + routeTerminalOpen, routeThreadKey, - sidebarThreadByKey, - threadJumpThreadKeys, + threadByKey, ]); - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (!useThreadSelectionStore.getState().hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection]); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const commandPaletteShortcutLabel = shortcutLabelForCommand( + // Same predicate as v1: hints show only while the held modifiers exactly + // match a thread-jump binding. Adding Shift (screenshots) or Alt no + // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. + const shortcutModifiers = useShortcutModifierState(); + const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, keybindings, - "commandPalette.toggle", - newThreadShortcutLabelOptions, - ); - const handleDesktopUpdateButtonClick = useCallback(() => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }); - return; - } - - if (desktopUpdateButtonAction === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform), - ); - if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }); - } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - - const expandThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }, []); + { platform: navigator.platform }, + ); + useEffect(() => { + setShowJumpHints(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow]); - const collapseThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projectGroups.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); + }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + + // The button mirrors chat.new: in multi-project setups both route through + // the command palette's "New thread in..." picker, and in single-project + // setups both create immediately. chat.newLocal always creates directly, so + // it is only a correct label when chat.new is unbound. + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.new") ?? + shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> - {prewarmedSidebarThreadRefs.map((threadRef) => ( - - ))} - - {isOnSettings ? ( - - ) : ( - <> - - - - )} + +
    +
    + + { + setThreadSearchQuery(event.currentTarget.value); + setActiveSearchResultIndex(0); + }} + onKeyDown={handleThreadSearchKeyDown} + placeholder="Search" + aria-label="Search threads" + role="combobox" + aria-autocomplete="list" + aria-expanded={isSearchingThreads && threadSearchResults.length > 0} + aria-controls={ + isSearchingThreads && threadSearchResults.length > 0 + ? "sidebar-thread-search-results" + : undefined + } + aria-activedescendant={ + isSearchingThreads && threadSearchResults[activeSearchResultIndex] + ? `sidebar-thread-search-result-${activeSearchResultIndex}` + : undefined + } + className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" + /> + {isSearchingThreads ? ( + + ) : null} +
    +
    + + + } + > + + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
    +
    + {projectGroups.length > 0 ? ( +
    + + + } + > + {scopedProjectGroup ? ( + + ) : ( + + )} + + {scopedProjectGroup?.displayName ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) + } + > + + + All projects + + {projectGroups.map((project) => { + const scopeKey = project.projectKey; + return ( + + + {project.displayName} + + + ); + })} + + + + + + } + > + + + New project + +
    + ) : null} + + } + > + + {isSearchingThreads ? ( + threadSearchResults.length > 0 ? ( + + + + ) : ( +

    + No threads found +

    + ) + ) : null} + {!isSearchingThreads ? ( + +
      + {(() => { + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + // Pinned block: full cards above the inbox, closed by a + // thin divider (the pin glyphs carry the meaning, so no + // header text). Vanishes entirely at count 0. + // Rows render in the one shared pinned order; only + // reorder-capable rows register as sortable (legacy-server + // pins render in place as plain rows). + const items: ReactNode[] = [ + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} + strategy={verticalListSortingStrategy} + > + {orderedPinnedThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (!reorderablePinnedKeys.has(threadKey)) { + return renderThreadRow(thread, "pinned"); + } + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} + + , + ]; + if (pinnedThreads.length > 0) { + items.push( +
    • , + ); + } + for (const thread of activeThreads) { + items.push(renderThreadRow(thread, "active")); + } + // 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 ? ( +
    • + +
    • + ) : null} +
    +
    + ) : null} + {!isSearchingThreads && + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProjectGroup ? ( + `No threads in ${scopedProjectGroup.displayName} yet` + ) : ( + "No threads yet" + )} +
    + ) : null} +
    +
    + { + if (!open) setProjectActionsTarget(null); + }} + > + + + Project settings + + Manage project names, grouping rules, and environments. + +
    + {projectActionsTarget?.memberProjects.map((member) => ( +
    + + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
    + ))} +
    +
    + +
    + {projectActionsTarget?.memberProjects.map((member) => ( +
    +
    + + +
    + {projectActionsTarget.memberProjects.length > 1 ? ( +
    + +
    + ) : null} +
    + ))} +
    + {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( +
    +
    +

    + Remove this project everywhere +

    +

    + Deletes all grouped entries and their conversation history. +

    +
    + +
    + ) : null} +
    + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + + +
    +
    + ); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx deleted file mode 100644 index 2a34b65cf2a..00000000000 --- a/apps/web/src/components/SidebarV2.tsx +++ /dev/null @@ -1,3711 +0,0 @@ -import { autoAnimate } from "@formkit/auto-animate"; -import { useAtomValue } from "@effect/atom-react"; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { - scopeProjectRef, - scopeThreadRef, - scopedThreadKey, -} from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; -import type { TimestampFormat } from "@t3tools/contracts/settings"; -import { - AlarmClockIcon, - AlarmClockOffIcon, - CheckIcon, - ChevronDownIcon, - CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, - ClockIcon, - CopyIcon, - FolderIcon, - FolderPlusIcon, - GitBranchIcon, - EllipsisIcon, - MessageSquareIcon, - PinIcon, - PlusIcon, - SearchIcon, - ServerIcon, - SquarePenIcon, - TerminalIcon, - Trash2Icon, - Undo2Icon, - XIcon, -} from "lucide-react"; -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - type ReactNode, -} from "react"; -import { useParams, useRouter } from "@tanstack/react-router"; - -import { - isAtomCommandInterrupted, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import { isElectron } from "../env"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHintsForModifiers, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; -import { useShortcutModifierState } from "../shortcutModifierState"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isModelPickerOpen } from "../modelPickerVisibility"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { isMacPlatform } from "~/lib/utils"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { readLocalApi } from "../localApi"; -import { - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; -import { - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useThreadActions } from "../hooks/useThreadActions"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; -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"; -import { vcsEnvironment } from "../state/vcs"; -import { threadEnvironment } from "../state/threads"; -import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { - buildThreadRouteParams, - resolveActiveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; -import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; -import type { SidebarThreadSummary } from "../types"; -import { cn } from "~/lib/utils"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; -import { - buildBulkTitleRegenerationContextMenuItem, - formatWorkingDurationLabel, - firstValidTimestampMs, - hasUnseenCompletion, - isTrailingDoubleClick, - orderItemsByPreferredIds, - planPinnedReorder, - resolveAdjacentThreadId, - resolveSettledTimestamp, - resolveSidebarV2Status, - searchSidebarThreadsByTitle, - resolveWorkingStartedAt, - shouldNavigateAfterProjectRemoval, - sortLogicalProjectsForSidebar, - sortPinnedThreadsForSidebarV2, - sortSettledThreadsForSidebarV2, - sortThreadsForSidebarV2, -} from "./Sidebar.logic"; -import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; -import { - prStatusIndicator, - resolveThreadPr, - settledPrHoverColorClass, - terminalStatusFromRunningIds, - type TerminalStatusIndicator, -} 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"; -import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; -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"; - -// Settled-tail paging: recent history is the common lookup; the deep tail -// stays behind an explicit Show more. -const SETTLED_TAIL_INITIAL_COUNT = 10; -const SETTLED_TAIL_PAGE_COUNT = 25; -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; - -function compactSidebarTimeLabel(label: string): string { - if (label === "just now") return "now"; - return label.endsWith(" ago") ? label.slice(0, -4) : label; -} - -function threadTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; - return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); -} - -// Settled rows read "how long ago did this wrap up", matching their sort -// key: both go through resolveSettledTimestamp so label and order can't -// disagree. -function settledTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = resolveSettledTimestamp(thread); - return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); -} - -// Floats at the row's right edge, vertically centered, while the jump -// modifier is held. An overlay pill instead of an inline slot: the hint -// must neither displace the status/time label (holding ⌘ used to blank -// out "Working") nor shift any layout when it appears. pointer-events-none -// so it never swallows clicks meant for the settle/un-settle buttons it -// can overlap. -function JumpHintBadge(props: { label: string }) { - return ( - - {props.label} - - ); -} - -// Self-ticking so only this span re-renders each second, not the whole row. -function WorkingDuration(props: { startedAt: string | null }) { - const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; - const [, setTick] = useState(0); - useEffect(() => { - if (Number.isNaN(startedMs)) return; - const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); - return () => window.clearInterval(id); - }, [startedMs]); - if (Number.isNaN(startedMs)) return null; - return ( - - {formatWorkingDurationLabel(Date.now() - startedMs)} - - ); -} - -function terminalProcessLabel(count: number): string { - return `${count} terminal ${count === 1 ? "process" : "processes"} running`; -} - -function SidebarV2ThreadTooltip({ - thread, - projectTitle, - projectCwd, - environmentLabel, - driverKind, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - projectTitle: string | null; - projectCwd: string | null; - environmentLabel: string | null; - driverKind: ProviderInstanceEntry["driverKind"] | null; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - return ( - -
    -
    - {thread.title} -
    -
    - {projectTitle ? ( -
    - -
    {projectTitle}
    -
    - ) : null} - {environmentLabel ? ( -
    - -
    {environmentLabel}
    -
    - ) : null} - {thread.branch ? ( -
    - -
    {thread.branch}
    -
    - ) : null} - {branchMismatch ? ( -
    - -
    - You're currently checked out on another branch. -
    -
    - ) : null} - {driverKind ? ( -
    - -
    {modelLabel}
    -
    - ) : null} - {terminalStatus ? ( -
    - -
    - {terminalProcessLabel(terminalProcessCount)} -
    -
    - ) : null} - {thread.session?.lastError ? ( -
    - -
    Error occurred
    -
    - ) : null} -
    -
    -
    - ); -} - -/** - * 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; - timestampFormat: TimestampFormat; -}) { - const { open, onOpenChange, onSnooze, timestampFormat } = 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(), timestampFormat) : []), - [open, timestampFormat], - ); - 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) => ( - - ))} - - - ); -} - -// Subset of useSortable applied to a pinned card's root
  • . Listeners go -// on the whole card (no dedicated handle): the pointer sensor's distance -// constraint keeps plain clicks working, and we skip dnd-kit's aria -// attributes since there is no keyboard sensor and the card body already -// carries its own button semantics. -type SortablePinnedRowBag = Pick< - ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" ->; - -function SortablePinnedThreadRow(props: { - id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; -}) { - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.id, - }); - return props.children({ listeners, setNodeRef, transform, transition, isDragging }); -} - -const SidebarV2Row = memo(function SidebarV2Row(props: { - thread: SidebarThreadSummary; - variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; - // 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; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. - pinningSupported: boolean; - isPinned: boolean; - // Present only on pinned cards whose server supports reordering: dnd-kit - // sortable bag applied to the card root so the whole card drags (the - // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; - // 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; - environmentLabel: string | null; - projectCwd: string | null; - projectTitle: string | null; - providerEntryByInstanceId: ReadonlyMap; - timestampFormat: TimestampFormat; - onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; - onThreadActivate: (threadRef: ScopedThreadRef) => void; - onStartRename: (threadRef: ScopedThreadRef, title: string) => void; - onRenameTitleChange: (title: string) => void; - onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; - onCancelRename: () => void; - isRenaming: boolean; - renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; - onSettle: (threadRef: ScopedThreadRef) => void; - onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; - onUnsnooze: (threadRef: ScopedThreadRef) => void; - onUnpin: (threadRef: ScopedThreadRef) => void; - onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; -}) { - const { - isRenaming, - onChangeRequestState, - onCancelRename, - onCommitRename, - onContextMenu, - onAcknowledgeWoke, - onRenameTitleChange, - onSettle, - onSnooze, - onStartRename, - onThreadActivate, - onThreadClick, - onUnsettle, - onUnsnooze, - onUnpin, - renamingTitle, - thread, - variant, - variantAction, - } = props; - const threadRef = useMemo( - () => scopeThreadRef(thread.environmentId, thread.id), - [thread.environmentId, thread.id], - ); - const threadKey = scopedThreadKey(threadRef); - const isRegeneratingTitle = thread.titleRegeneration != null; - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const openPrLink = useOpenPrLink(); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const terminalProcessCount = runningTerminalIds.length; - - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); - const prState = pr?.state ?? null; - - // Same semantics as v1 (never-visited counts as read): flipping the beta - // 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 the pill clears only when the user re-engages: - // reading a completion-triggered wake, clicking the pill, sending a - // message, settling, archiving — or finishing the work outright (merged - // or closed PR). Timer wakes survive a mere visit. 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) && - prState !== "merged" && - prState !== "closed"; - // 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, 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 === "monitoring" || status === "approval" || status === "input"; - const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. - const topStatus = - status === "working" - ? { - label: "Working", - icon: "working" as const, - // No shimmer: a label that animates forever is noise in a sidebar - // full of them (and repaints every vsync on high-refresh displays). - // Working is a background state, so it rests at the dim end of what - // the old pulse cycled through; only the thread you have open gets - // the label at full strength. - className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), - } - : status === "monitoring" - ? { - // Monitoring is calm background presence, not active progress - // (monitoring-pill D6), so it keeps the label at full strength. - label: "Monitoring", - icon: null, - className: "text-sky-600 dark:text-sky-400", - } - : status === "approval" - ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", - } - : status === "input" - ? { - label: "Input", - icon: null, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "failed" - ? { - label: "Failed", - icon: null, - className: "text-red-700 dark:text-red-300", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } - : null; - const isWokeStatus = topStatus?.icon === "woke"; - - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. - useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); - - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; - - const detailsTooltip = ( - - ); - - const handleClick = useCallback( - (event: ReactMouseEvent) => { - onThreadClick(event, threadRef); - }, - [onThreadClick, threadRef], - ); - const handleAcknowledgeWokeClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (props.wokeAt === null) return; - onAcknowledgeWoke(threadRef, props.wokeAt); - }, - [onAcknowledgeWoke, props.wokeAt, threadRef], - ); - const handleContextMenu = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); - }, - [onContextMenu, threadRef], - ); - const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onThreadActivate(threadRef); - }, - [onThreadActivate, threadRef], - ); - const handleDoubleClick = useCallback( - (event: ReactMouseEvent) => { - if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - if ((event.target as HTMLElement).closest("button, a, input")) return; - event.preventDefault(); - onStartRename(threadRef, thread.title); - }, - [isRenaming, onStartRename, thread.title, threadRef], - ); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } - }, - [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], - ); - const handleRenameBlur = useCallback(() => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }, [onCommitRename, renamingTitle, thread.title, threadRef]); - const handleSettleClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onSettle(threadRef); - }, - [onSettle, threadRef], - ); - const handleUnsettleClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsettle(threadRef); - }, - [onUnsettle, threadRef], - ); - const handleUnsnoozeClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsnooze(threadRef); - }, - [onUnsnooze, threadRef], - ); - const handleUnpinClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnpin(threadRef); - }, - [onUnpin, 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); - }, - [openPrLink, pr], - ); - - // All Sidebar V2 rows share one surface model. Live threads used to look - // like elevated cards while settled threads were plain rows, leaving neither - // a useful hierarchy nor a reliable hover cue. Status now lives in the row - // content; surface is reserved for interaction (hover, multi-select, route). - const rowSurfaceClassName = cn( - "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", - props.isActive - ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected - ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", - ); - - const title = isRenaming ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" - /> - ) : ( - - {thread.title} - - ); - - const prBadge = - prStatus && pr ? ( - - ) : null; - const terminalStatusIcon = terminalStatus ? ( - - - - ) : null; - - if (variant === "slim") { - return ( -
  • - - - } - > - {/* Settled history recedes: dimmed favicon at rest, restored on - hover so the tail stays scannable when you're hunting. */} - - - - {title} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {/* The PR badge stays outside the hover-fading slot: it must - remain visible AND clickable while the row is hovered. Only - the time/jump label yields to the settle affordance. */} - {prBadge} - - - {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. - - ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - - {props.jumpLabel ? : null} - - {detailsTooltip} - -
  • - ); - } - - const diff = latestTurnDiff(thread); - - const sortable = props.sortable; - return ( -
  • - - - } - > -
    -
    - - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - {props.isPinned ? ( - props.pinningSupported ? ( - - ) : ( - - ) - ) : null} - {/* The visible state owns this slot's width: status at rest, - actions on hover/keyboard focus or while the popover is open. Keeping - the hidden state out of flow lets the project label reclaim - space without either state overlapping it. */} - - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} - - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( - - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - ) : null} - - ) : null} - -
    -
    - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    -
    - {/* While working, the current plan step outranks the branch: - it's the one line that says what the thread is doing. */} - {status === "working" && thread.planProgress ? ( - - {thread.planProgress.step} - {/* Completed count, matching the transcript chip's n/m. */} - - {" "} - {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} - - - ) : thread.branch ? ( - {thread.branch} - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - - - ) : null} - -
    -
    - {props.jumpLabel ? : null} -
    - {detailsTooltip} -
    -
  • - ); -}); - -function latestTurnDiff( - thread: SidebarThreadSummary, -): { insertions: number; deletions: number } | null { - // Shells don't carry checkpoint summaries; diff stats render only when the - // shell projection grows them. Kept as a seam so the row layout is ready. - void thread; - return null; -} - -const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { - thread: SidebarThreadSummary; - projectCwd: string | null; - projectTitle: string | null; - environmentLabel: string | null; - providerEntryByInstanceId: ReadonlyMap; - isHighlighted: boolean; - isRouteActive: boolean; - resultId: string; - onHighlight: () => void; - onSelect: () => void; -}) { - const { thread } = props; - // Same details tooltip as the regular rows: a search hit is still a thread, - // and the hover card is how you disambiguate identically-titled results. - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, - }); - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - return ( -
  • - - - } - > - - {thread.title} - - {threadTimeLabel(thread)} - - - - -
  • - ); -}); - -export default function SidebarV2() { - const projects = useProjects(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const threads = useThreadShells(); - const router = useRouter(); - const { isMobile, setOpenMobile } = useSidebar(); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const timestampFormat = useClientSettings((s) => s.timestampFormat); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { - settleThread, - unsettleThread, - snoozeThread, - unsnoozeThread, - pinThread, - unpinThread, - reorderPinnedThread, - deleteThread, - } = useThreadActions(); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { - reportFailure: false, - }); - const updateProject = useAtomCommand(projectEnvironment.update, { - reportFailure: false, - }); - const updateSettings = useUpdateClientSettings(); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: path, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ - target: "branch name", - onCopy: ({ branch }) => { - toastManager.add({ - type: "success", - title: "Branch copied", - description: branch, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy branch", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const [projectActionsTarget, setProjectActionsTarget] = useState( - null, - ); - const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); - const newThreadContext = useHandleNewThread(); - const openAddProjectCommandPalette = useCallback( - () => openCommandPalette({ open: "add-project" }), - [], - ); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); - const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); - const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); - const acknowledgeWoke = useCallback( - (threadRef: ScopedThreadRef, visitedAt: string) => { - markThreadVisited(scopedThreadKey(threadRef), visitedAt); - }, - [markThreadVisited], - ); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTargetRef = useRef(routeTarget); - routeTargetRef.current = routeTarget; - // Post-settle navigation validates against the CURRENT route, not the one - // captured when the settle started: if the user navigated elsewhere while - // the command was in flight, completing it must not yank them away. - const routeThreadKeyRef = useRef(routeThreadKey); - routeThreadKeyRef.current = routeThreadKey; - - const environmentLabelById = useMemo( - () => - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - const orderedProjects = useMemo( - () => - orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }), - [projectOrder, projects], - ); - const unsortedProjectGroups = useMemo( - () => - buildSidebarProjectSnapshots({ - projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }), - [ - environmentLabelById, - orderedProjects, - primaryEnvironmentId, - projectGroupingSettings, - projects, - sidebarProjectSortOrder, - ], - ); - const projectGroups = useMemo( - () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), - [sidebarProjectSortOrder, threads, unsortedProjectGroups], - ); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const providerEntryByInstanceId = useMemo( - () => - new Map( - deriveProviderInstanceEntries(serverProviders).map( - (entry) => [entry.instanceId as string, entry] as const, - ), - ), - [serverProviders], - ); - const projectCwdByKey = useMemo( - () => - new Map( - projects.map((project) => [ - `${project.environmentId}:${project.id}`, - project.workspaceRoot, - ]), - ), - [projects], - ); - const projectDisplayNameByKey = useMemo( - () => - new Map( - projectGroups.flatMap((group) => - group.memberProjects.map( - (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, - ), - ), - ), - [projectGroups], - ); - - // 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. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); - - // Project scope: one menu above the list. Scoping filters the list without - // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); - const scopedProjectGroup = useMemo( - () => - projectScopeKey === null - ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), - [projectGroups, projectScopeKey], - ); - const scopedProjectKeys = useMemo( - () => - scopedProjectGroup === null - ? null - : new Set( - scopedProjectGroup.memberProjectRefs.map( - (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, - ), - ), - [scopedProjectGroup], - ); - useEffect(() => { - if (projectScopeKey !== null && scopedProjectGroup === null) { - setProjectScopeKey(null); - } - }, [projectScopeKey, scopedProjectGroup]); - // Scope flips drop the selection: rows selected under the old scope may be - // hidden now, and bulk actions must never count or touch invisible rows. - useEffect(() => { - clearSelection(); - }, [clearSelection, projectScopeKey]); - - const handleRemoveProjectMembers = useCallback( - async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { - const api = readLocalApi(); - if (!api) return; - - const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); - const projectThreads = threads.filter((thread) => - memberKeys.has(`${thread.environmentId}:${thread.projectId}`), - ); - const isWholeGroup = members.length === projectGroup.memberProjects.length; - const singleMember = members.length === 1 ? members[0]! : null; - const targetLabel = singleMember?.title ?? projectGroup.displayName; - const confirmed = await settlePromise(() => - api.dialogs.confirm( - projectThreads.length > 0 - ? [ - `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - "This permanently clears conversation history for those threads.", - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${targetLabel}"?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - - const draftStore = useComposerDraftStore.getState(); - let shouldNavigate = false; - for (const project of members) { - const memberThreads = projectThreads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ); - const projectRef = scopeProjectRef(project.environmentId, project.id); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ - routeTarget: routeTargetRef.current, - projectThreads: memberThreads, - projectDraftId: projectDraftThread?.draftId ?? null, - }); - - const result = await deleteProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...(memberThreads.length > 0 ? { force: true } : {}), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${project.title}"`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - return; - } - - shouldNavigate ||= memberRemovalNeedsNavigation; - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); - } - draftStore.clearProjectDraftThreadId(projectRef); - } - - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - }, - [deleteProject, router, threads], - ); - - const renameProjectMember = useCallback( - async (member: SidebarProjectGroupMember, nextTitle: string) => { - const title = nextTitle.trim(); - if (!title) { - toastManager.add({ type: "warning", title: "Project title cannot be empty" }); - return; - } - if (title === member.title) return; - const result = await updateProject({ - environmentId: member.environmentId, - input: { projectId: member.id, title }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, - [updateProject], - ); - - const updateProjectGroupingPreference = useCallback( - (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { - const overrideKey = deriveProjectGroupingOverrideKey(member); - const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; - if (selection === "inherit") { - delete nextOverrides[overrideKey]; - } else { - nextOverrides[overrideKey] = selection; - } - updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); - }, - [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], - ); - - const handleProjectActions = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - setProjectScopeMenuOpen(false); - window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); - }, - [], - ); - - // Settled threads stay in the live shell stream (settled ≠ archived), so - // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. - const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { - pinnedThreads, - reorderablePinnedKeys, - activeThreads, - snoozedThreads, - settledThreads, - snoozeNow, - } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const pinned: EnvironmentThreadShell[] = []; - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // and so does its pinOrderKey, so on wake the thread reappears at - // its exact slot in the pinned block. (For unpinned threads - // this is also the snooze-beats-auto-settle rule: the wake time is a - // stronger statement about when the thread matters again.) - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) - } else if (thread.pinnedAt != null) { - pinned.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); - } - } - // One shared rule on every platform (see sortPinnedThreadsByOrderKey): - // user-arranged keys first, keyless threads in creation order below. - // Server capability only gates DRAGGING — it must not influence the - // sort, or mixed-version fleets would render different pinned orders on - // web and mobile from the same data. - return { - pinnedThreads: sortPinnedThreadsForSidebarV2(pinned), - reorderablePinnedKeys: new Set( - pinned - .filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === - true, - ) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); - - const threadSearchInputRef = useRef(null); - const [threadSearchQuery, setThreadSearchQuery] = useState(""); - const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); - const isSearchingThreads = threadSearchQuery.trim().length > 0; - const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], - ); - const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], - ); - const threadSearchResultOrderKey = threadSearchResults - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) - .join("\0"); - - useEffect(() => { - setActiveSearchResultIndex(0); - }, [threadSearchResultOrderKey]); - - useEffect(() => { - if (!isSearchingThreads) return; - document - .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); - - // 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 - // page state. - const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = projectScopeKey ?? "all"; - const lastSettledResetKeyRef = useRef(settledResetKey); - if (lastSettledResetKeyRef.current !== settledResetKey) { - lastSettledResetKeyRef.current = settledResetKey; - setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); - } - 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), - [], - ); - 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( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], - ); - const orderedThreadKeys = useMemo( - () => - orderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - [orderedThreads], - ); - // Rows call back into the click handler without carrying the ordered list as - // a prop — a fresh array identity per shell update would defeat every row's - // memoization. The ref keeps shift-range-select working against the list as - // rendered at click time. - const orderedThreadKeysRef = useRef(orderedThreadKeys); - orderedThreadKeysRef.current = orderedThreadKeys; - const threadByKey = useMemo( - () => - new Map( - orderedThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [orderedThreads], - ); - // Handlers read these through refs: depending on per-update Map/Set - // identities would give every row a fresh callback prop on each shell - // event and defeat row memoization during streaming. - const threadByKeyRef = useRef(threadByKey); - threadByKeyRef.current = threadByKey; - // handleNewThread is inherently unstable (depends on the projects list); - // a ref keeps it out of attemptSettle's dependency array. - const handleNewThreadRef = useRef(newThreadContext.handleNewThread); - handleNewThreadRef.current = newThreadContext.handleNewThread; - const settledThreadKeys = useMemo( - () => - new Set( - settledThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - [settledThreads], - ); - 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(); - for (const [index, threadKey] of orderedThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(index); - if (!jumpCommand) break; - const label = shortcutLabelForCommand(keybindings, jumpCommand); - if (label) mapping.set(threadKey, label); - } - return mapping; - }, [keybindings, orderedThreadKeys]); - const [showJumpHints, setShowJumpHints] = useState(false); - - // Settled threads are live shells, so opening one is plain navigation: - // history stays readable without un-settling, and sending a message or - // starting a session un-settles server-side. - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], - ); - - const clearThreadSearch = useCallback(() => { - setThreadSearchQuery(""); - setActiveSearchResultIndex(0); - }, []); - const selectThreadSearchResult = useCallback( - (thread: EnvironmentThreadShell) => { - clearThreadSearch(); - navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); - }, - [clearThreadSearch, navigateToThread], - ); - const handleThreadSearchKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - // IME composition (Japanese/Chinese input) uses the same keys; committing - // a candidate must not move the highlight or navigate away mid-compose. - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Escape" && isSearchingThreads) { - event.preventDefault(); - event.stopPropagation(); - clearThreadSearch(); - return; - } - if (threadSearchResults.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveSearchResultIndex( - (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length, - ); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - const result = threadSearchResults[activeSearchResultIndex]; - if (result) selectThreadSearchResult(result); - } - }, - [ - activeSearchResultIndex, - clearThreadSearch, - isSearchingThreads, - selectThreadSearchResult, - threadSearchResults, - ], - ); - - const [renamingThreadKey, setRenamingThreadKey] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, []); - const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); - const commitThreadRename = useCallback( - (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { - void (async () => { - const trimmed = title.trim(); - setRenamingThreadKey(null); - if (trimmed.length === 0) { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - return; - } - if (trimmed === originalTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, title: trimmed }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [updateThreadMetadata], - ); - - const handleThreadClick = useCallback( - (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const threadKey = scopedThreadKey(threadRef); - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - if (event.shiftKey) { - event.preventDefault(); - rangeSelectTo(threadKey, orderedThreadKeysRef.current); - return; - } - if (isTrailingDoubleClick(event.detail)) { - return; - } - navigateToThread(threadRef); - }, - [navigateToThread, rangeSelectTo, toggleThreadSelection], - ); - - // 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 () => { - const threadKey = scopedThreadKey(threadRef); - if (settlingThreadKeysRef.current.has(threadKey)) return; - settlingThreadKeysRef.current.add(threadKey); - try { - const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Only move forward if the user is still on the settled thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSettle?.(); - } - } finally { - settlingThreadKeysRef.current.delete(threadKey); - } - })(); - }, - [planForwardNavigation, settleThread], - ); - const attemptUnsettle = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - const result = await unsettleThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [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], - ); - // Drag-to-reorder for the pinned block. A drop computes ONE fractional key - // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case). The - // optimistic order keeps the card where it was dropped until the - // confirming event round-trips; canonical order matching it releases the - // override, and a failed write clears it (the card snaps back) with a toast. - // ANY membership change (new pin, unpin, snooze/wake) also releases it: - // the override can't say where members it never saw belong, and holding it - // would misplace them and launder the stale order into later drags. - const pinnedDndSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ - readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop, so ANY landed write (ours - confirming, or a concurrent one from another client) releases the - override rather than fighting canonical state. */ - readonly keysAtDrop: ReadonlyMap; - } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); - useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - // The override represents one drop against one snapshot of the world. - // Release it as soon as the world moves on in any way: membership - // changed (pin/unpin/snooze/wake — the override can't say where members - // it never saw belong), a key changed (our write confirming, or a - // concurrent client's reorder that must win), or canonical already - // matches. Holding it longer would misplace newcomers and launder the - // stale order into later drags. - const membershipChanged = - canonicalKeys.length !== optimisticPinnedOrder.order.length || - canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const anyKeyLanded = canonical.some( - (thread, index) => - optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== - (thread.pinOrderKey ?? null), - ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || anyKeyLanded || orderConfirmed) { - setOptimisticPinnedOrder(null); - } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); - const attemptPin = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - // Fresh pins take the top of the arranged run: pinThread computes a - // key before the smallest key across ALL pinned shells — including - // snoozed pins hidden from this list, whose keys are still part of - // the run — so the new pin can't land beneath a hidden head. - const result = await pinThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to pin thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [pinThread], - ); - const attemptUnpin = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - const result = await unpinThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unpin thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [unpinThread], - ); - - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, - }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); - void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) — unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setOptimisticPinnedOrder(null); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - } - })(); - }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], - ); - // One snooze per thread at a time — same double-dispatch guard as settle. - const snoozingThreadKeysRef = useRef(new Set()); - const performSnooze = useCallback( - async ( - threadRef: ScopedThreadRef, - preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) { - return { status: "skipped" } as const; - } - 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. - return isAtomCommandInterrupted(result) - ? ({ status: "interrupted" } as const) - : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); - } - // 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?.(); - } - return { status: "success" } as const; - } finally { - snoozingThreadKeysRef.current.delete(threadKey); - } - }, - [planForwardNavigation, snoozeThread], - ); - const attemptSnooze = useCallback( - ( - threadRef: ScopedThreadRef, - preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - void (async () => { - const outcome = await performSnooze(threadRef, preset, opts); - if (outcome.status === "failure") { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: - outcome.error instanceof Error ? outcome.error.message : "An error occurred.", - }), - ); - return; - } - if (outcome.status !== "success") 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(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); - })(); - }, - [attemptUnsnooze, performSnooze, timestampFormat], - ); - - const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - // One exact actionable set: keys whose rows are actually rendered - // right now. Selections can outlive their rows (settled-tail paging, - // thread deletion elsewhere) and the menu labels must count only what - // the actions will touch. - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( - (threadKey) => threadByKeyRef.current.has(threadKey), - ); - 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(); - const selectedThreads = threadKeys.flatMap((threadKey) => { - const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; - }); - const canSnoozeSelection = selectedThreads.every( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow.toISOString() }), - ); - const titleRegenerationThreads = selectedThreads.filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true, - ); - const regeneratableTitleThreads = titleRegenerationThreads.filter( - (thread) => thread.titleRegeneration == null, - ); - const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ - supportedCount: titleRegenerationThreads.length, - actionableCount: regeneratableTitleThreads.length, - }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); - 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})`, - })), - }, - ] - : []), - ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ), - ); - 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); - clearSelection(); - const outcomes = await Promise.all( - selectedThreads.map(async (thread) => { - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); - return { outcome, threadRef }; - }), - ); - const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => - outcome.status === "success" ? [threadRef] : [], - ); - const failures = outcomes.flatMap(({ outcome }) => - outcome.status === "failure" ? [outcome.error] : [], - ); - - if (snoozedThreadRefs.length > 0) { - const snoozedCount = snoozedThreadRefs.length; - const failedCount = failures.length; - toastManager.add( - stackedThreadToast({ - type: failedCount > 0 ? "warning" : "success", - title: - failedCount > 0 - ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` - : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, - description: - failedCount > 0 - ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` - : undefined, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); - }, - }, - }), - ); - } else if (failures.length > 0) { - const firstError = failures[0]; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze threads", - description: - firstError instanceof Error ? firstError.message : "An error occurred.", - }), - ); - } - } - return; - } - if (clicked.value === "regenerate-title") { - for (const thread of regeneratableTitleThreads) { - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, - }); - if (result._tag === "Success") continue; - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread titles", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - clearSelection(); - return; - } - if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. Pinned rows ARE included: the decider - // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } - clearSelection(); - return; - } - if (clicked.value === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } - if (clicked.value !== "delete") return; - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - // Grown as deletions actually land, never seeded with the whole batch: - // orphaned-worktree detection must only discount threads that are - // really gone, or the first delete would treat still-alive batch mates - // as deleted and remove a worktree they still point at. - const deletedThreadKeys = new Set(); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - deletedThreadKeys.add(threadKey); - } - removeFromSelection(threadKeys); - }, - [ - attemptSettle, - attemptSnooze, - clearSelection, - confirmThreadDelete, - deleteThread, - markThreadUnread, - performSnooze, - removeFromSelection, - serverConfigs, - attemptUnsnooze, - updateThreadMetadata, - timestampFormat, - ], - ); - - const handleThreadContextMenu = useCallback( - (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - void (async () => { - const api = readLocalApi(); - if (!api) return; - const threadKey = scopedThreadKey(threadRef); - const selectionState = useThreadSelectionStore.getState(); - if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { - await handleMultiSelectContextMenu(position); - return; - } - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) return; - const threadWorkspacePath = - thread.worktreePath ?? - projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? - null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without - // the settlement capability get no lifecycle items at all. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const supportsPinning = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; - const supportsTitleRegeneration = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true; - const isRegeneratingTitle = thread.titleRegeneration != null; - const isSettled = settledThreadKeysRef.current.has(threadKey); - const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); - const isPinned = thread.pinnedAt != null; - // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); - const clicked = await settlePromise(() => - api.contextMenu.show( - buildThreadActionMenuItems({ - branch: thread.branch ?? null, - isPinned, - isSettled, - isSnoozed, - canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), - isRegeneratingTitle, - supports: { - settlement: supportsSettlement, - snooze: supportsSnooze, - pinning: supportsPinning, - titleRegeneration: supportsTitleRegeneration, - }, - snoozePresets, - }), - position, - ), - ); - 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 - // 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; - case "unsettle": - attemptUnsettle(threadRef); - return; - case "unsnooze": - attemptUnsnooze(threadRef); - return; - case "pin": - attemptPin(threadRef); - return; - case "unpin": - attemptUnpin(threadRef); - return; - case "rename": - startThreadRename(threadRef, thread.title); - return; - case "regenerate-title": { - if (isRegeneratingTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread title", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - case "mark-unread": - markThreadUnread(threadKey, thread.latestTurn?.completedAt); - return; - case "copy-path": - if (!threadWorkspacePath) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }), - ); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); - return; - case "copy-branch": - if (thread.branch) { - copyBranchToClipboard(thread.branch, { branch: thread.branch }); - } - return; - case "delete": { - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - return; - } - default: - return; - } - })(); - }, - [ - attemptPin, - attemptSettle, - attemptSnooze, - attemptUnpin, - attemptUnsettle, - attemptUnsnooze, - confirmThreadDelete, - copyBranchToClipboard, - copyPathToClipboard, - deleteThread, - handleMultiSelectContextMenu, - markThreadUnread, - projectCwdByKey, - serverConfigs, - startThreadRename, - updateThreadMetadata, - timestampFormat, - ], - ); - - // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as - // v1 — the keybinding layer is shared, only the ordered list differs. - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) return; - const command = resolveShortcutCommand(event, keybindings, { - platform: navigator.platform, - context: { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }, - }); - const navigateToThreadKey = (targetThreadKey: string | null) => { - if (!targetThreadKey) return false; - const targetThread = threadByKey.get(targetThreadKey); - if (!targetThread) return false; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return true; - }; - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }), - ); - return; - } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) return; - navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); - }; - window.addEventListener("keydown", onWindowKeyDown); - return () => window.removeEventListener("keydown", onWindowKeyDown); - }, [ - keybindings, - navigateToThread, - orderedThreadKeys, - routeTerminalOpen, - routeThreadKey, - threadByKey, - ]); - - // Same predicate as v1: hints show only while the held modifiers exactly - // match a thread-jump binding. Adding Shift (screenshots) or Alt no - // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. - const shortcutModifiers = useShortcutModifierState(); - const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { platform: navigator.platform }, - ); - useEffect(() => { - setShowJumpHints(shouldShowJumpHintsNow); - }, [shouldShowJumpHintsNow]); - - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); - - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. - const handleNewThreadClick = useCallback(() => { - // One project: nothing to pick, create immediately. - if (projectGroups.length <= 1) { - if (isMobile) setOpenMobile(false); - void startNewThreadFromContext({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); - return; - } - if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); - - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. chat.newLocal always creates directly, so - // it is only a correct label when chat.new is unbound. - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - shortcutLabelForCommand(keybindings, "chat.newLocal"); - return ( - <> - - -
    -
    - - { - setThreadSearchQuery(event.currentTarget.value); - setActiveSearchResultIndex(0); - }} - onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" - aria-label="Search threads" - role="combobox" - aria-autocomplete="list" - aria-expanded={isSearchingThreads && threadSearchResults.length > 0} - aria-controls={ - isSearchingThreads && threadSearchResults.length > 0 - ? "sidebar-thread-search-results" - : undefined - } - aria-activedescendant={ - isSearchingThreads && threadSearchResults[activeSearchResultIndex] - ? `sidebar-thread-search-result-${activeSearchResultIndex}` - : undefined - } - className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" - /> - {isSearchingThreads ? ( - - ) : null} -
    -
    - - - } - > - - - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    -
    - {projectGroups.length > 0 ? ( -
    - - - } - > - {scopedProjectGroup ? ( - - ) : ( - - )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - - - - - setProjectScopeKey(value === "all" ? null : (value as string)) - } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - - - {project.displayName} - - - ); - })} - - - - - - } - > - - - New project - -
    - ) : null} - - } - > - - {isSearchingThreads ? ( - threadSearchResults.length > 0 ? ( - - - - ) : ( -

    - No threads found -

    - ) - ) : null} - {!isSearchingThreads ? ( - -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Pinned block: full cards above the inbox, closed by a - // thin divider (the pin glyphs carry the meaning, so no - // header text). Vanishes entirely at count 0. - // Rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} - - , - ]; - if (pinnedThreads.length > 0) { - items.push( -
    • , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // 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 ? ( -
    • - -
    • - ) : null} -
    -
    - ) : null} - {!isSearchingThreads && - pinnedThreads.length + - activeThreads.length + - snoozedThreads.length + - settledThreads.length === - 0 ? ( -
    - {projects.length === 0 ? ( - <> - No projects yet - - - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` - ) : ( - "No threads yet" - )} -
    - ) : null} -
    -
    - { - if (!open) setProjectActionsTarget(null); - }} - > - - - Project settings - - Manage project names, grouping rules, and environments. - -
    - {projectActionsTarget?.memberProjects.map((member) => ( -
    - - - {member.workspaceRoot} - - - - - - {member.environmentLabel ?? "Current environment"} - - -
    - ))} -
    -
    - -
    - {projectActionsTarget?.memberProjects.map((member) => ( -
    -
    - - -
    - {projectActionsTarget.memberProjects.length > 1 ? ( -
    - -
    - ) : null} -
    - ))} -
    - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( -
    -
    -

    - Remove this project everywhere -

    -

    - Deletes all grouped entries and their conversation history. -

    -
    - -
    - ) : null} -
    - - {projectActionsTarget?.memberProjects.length === 1 ? ( - - ) : null} - - -
    -
    - - - ); -} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx deleted file mode 100644 index 740d3048f0e..00000000000 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from "react"; - -import { - useClientSettings, - useSidebarV2Enabled, - useUpdateClientSettings, -} from "../../hooks/useSettings"; -import { Input } from "../ui/input"; -import { Switch } from "../ui/switch"; -import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; - -const AUTO_SETTLE_MIN_DAYS = 1; -const AUTO_SETTLE_MAX_DAYS = 90; -const AUTO_SETTLE_DEFAULT_DAYS = 3; - -function AutoSettleDaysInput({ - value, - onCommit, -}: { - value: number; - onCommit: (days: number) => void; -}) { - // Local draft so the field can be emptied mid-edit; the setting only moves - // on valid input and snaps back to the persisted value on blur. - const [draft, setDraft] = useState(String(value)); - useEffect(() => { - setDraft(String(value)); - }, [value]); - - return ( - { - setDraft(event.target.value); - // Number(), not parseInt: "3.5" must be rejected (not truncated to a - // committed 3 while the field shows 3.5) — commit only when the - // persisted value matches the displayed one. - const parsed = Number(event.target.value); - if ( - Number.isInteger(parsed) && - parsed >= AUTO_SETTLE_MIN_DAYS && - parsed <= AUTO_SETTLE_MAX_DAYS - ) { - onCommit(parsed); - } - }} - onBlur={() => setDraft(String(value))} - aria-label="Days of inactivity before auto-settle" - /> - ); -} - -export function BetaSettingsPanel() { - const sidebarV2Enabled = useSidebarV2Enabled(); - const sidebarAutoSettleAfterDays = useClientSettings( - (settings) => settings.sidebarAutoSettleAfterDays, - ); - const updateSettings = useUpdateClientSettings(); - - return ( - - - - updateSettings({ - sidebarV2Enabled: Boolean(checked), - sidebarV2ConfiguredByUser: true, - }) - } - aria-label="Enable the sidebar v2 beta" - /> - } - /> - {sidebarV2Enabled ? ( - <> - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> - } - /> - {sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} - /> - } - /> - ) : null} - - ) : null} - - - ); -} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 407f0e77be4..bdfb830732e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -24,11 +24,13 @@ import { MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -463,6 +465,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarAutoSettleAfterDays !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays + ? ["Auto-settle inactive threads"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans ? ["Interface font"] @@ -525,6 +531,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, + settings.sidebarAutoSettleAfterDays, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -603,6 +610,7 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1506,11 +1514,55 @@ function FontFamilySettingsRow({ ); } -// Both legacy rows sit behind the fold, so a settings-search jump has to +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; + +function AutoSettleDaysInput({ + value, + onCommit, +}: { + value: number; + onCommit: (days: number) => void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + // Number(), not parseInt: "3.5" must be rejected (not truncated to a + // committed 3 while the field shows 3.5) — commit only when the + // persisted value matches the displayed one. + const parsed = Number(event.target.value); + if ( + Number.isInteger(parsed) && + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +// The legacy rows sit behind the fold, so a settings-search jump has to // expand the section before its target can mount and scroll. const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-token-streaming", + "legacy-sidebar", ]); /** @@ -1589,6 +1641,19 @@ function LegacyFeaturesSection() { /> } /> + + updateSettings({ legacySidebarEnabled: Boolean(checked) }) + } + aria-label="Sidebar (legacy)" + /> + } + /> @@ -1688,6 +1753,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + > = { "/settings/providers": "Providers", "/settings/source-control": "Source Control", "/settings/connections": "Connections", - "/settings/beta": "Beta", "/settings/archived": "Archive", }; @@ -100,6 +98,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Project grouping", to: "/settings/general", }, + { + id: "auto-settle-inactive-threads", + title: "Auto-settle inactive threads", + to: "/settings/general", + }, { id: "time-format", title: "Time format", @@ -161,6 +164,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Stream token by token (legacy)", to: "/settings/general", }, + { + id: "legacy-sidebar", + title: "Sidebar (legacy)", + to: "/settings/general", + }, { id: "keybindings", title: "Keybindings", @@ -181,17 +189,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remote environments", to: "/settings/connections", }, - { - id: "sidebar-v2", - title: "Sidebar v2", - to: "/settings/beta", - }, - { - id: "auto-settle-inactive-threads", - title: "Auto-settle inactive threads", - to: "/settings/beta", - targetId: "sidebar-v2", - }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index f4797bb775d..bf273879dc4 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -25,8 +25,6 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import { APP_STAGE_LABEL } from "~/branding"; -import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import { getThemeDefinition, @@ -266,29 +264,18 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod } /** - * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user - * has made one, otherwise the default for this build stage (on for nightly and - * dev, off for production). Every consumer must read through this rather than - * `settings.sidebarV2Enabled`, which is only meaningful alongside - * `sidebarV2ConfiguredByUser`. + * Whether the legacy sidebar (Settings → General → Legacy features) replaces + * the default one. * - * Held at v1 until client settings hydrate. The pre-hydration snapshot is just - * the schema defaults, so resolving against it would mount one sidebar and then - * swap it out once persisted settings land — remounting the whole tree. + * Held at the default sidebar until client settings hydrate: the pre-hydration + * snapshot is just the schema defaults, so resolving against it could mount one + * sidebar and then swap it out once persisted settings land — remounting the + * whole tree for everyone instead of only for legacy opt-ins. */ -export function useSidebarV2Enabled(): boolean { +export function useLegacySidebarEnabled(): boolean { const settingsHydrated = useClientSettingsHydrated(); - const settings = useClientSettingsValue(); - return useMemo( - () => - resolveSidebarV2Enabled({ - enabled: settings.sidebarV2Enabled, - configuredByUser: settings.sidebarV2ConfiguredByUser, - settingsHydrated, - stageLabel: APP_STAGE_LABEL, - }), - [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated], - ); + const legacySidebarEnabled = useClientSettingsValue().legacySidebarEnabled; + return settingsHydrated && legacySidebarEnabled; } /** Read current settings for one environment, merged with client-local preferences. */ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5b0ee6ec834..0b1cae8c5c7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -995,12 +995,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -/* Keep both navigation implementations on the same quiet zinc hierarchy: - zinc-50 navigation, zinc-25 hover, and white selected/raised surfaces. - The version attribute remains useful for layout-specific styling without - changing the color system when the beta is toggled. */ -[data-sidebar-version="v1"], -[data-sidebar-version="v2"] { +/* Keep both sidebar implementations (default and legacy) on the same quiet + zinc hierarchy: zinc-50 navigation, zinc-25 hover, and white selected/raised + surfaces. */ +[data-app-sidebar] { --background: var(--color-zinc-25); --foreground: var(--color-zinc-800); --card: var(--color-white); @@ -1023,8 +1021,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background-color: var(--sidebar); } -.dark [data-sidebar-version="v1"], -.dark [data-sidebar-version="v2"] { +.dark [data-app-sidebar] { --background: #000; --foreground: #f1f3f7; --card: #000; @@ -1321,8 +1318,7 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="tr color: var(--code-foreground); } -html[data-theme-id] [data-sidebar-version="v1"], -html[data-theme-id] [data-sidebar-version="v2"] { +html[data-theme-id] [data-app-sidebar] { --background: var(--app-theme-canvas); --foreground: var(--app-theme-text); --card: var(--app-theme-surface); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714..3da96820ab9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,7 +20,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' -import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' @@ -81,11 +80,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) -const SettingsBetaRoute = SettingsBetaRouteImport.update({ - id: '/beta', - path: '/beta', - getParentRoute: () => SettingsRoute, -} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -121,7 +115,6 @@ export interface FileRoutesByFullPath { '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -138,7 +131,6 @@ export interface FileRoutesByTo { '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -158,7 +150,6 @@ export interface FileRoutesById { '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -179,7 +170,6 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -196,7 +186,6 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -215,7 +204,6 @@ export interface FileRouteTypes { | '/connect_/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -314,13 +302,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } - '/settings/beta': { - id: '/settings/beta' - path: '/beta' - fullPath: '/settings/beta' - preLoaderRoute: typeof SettingsBetaRouteImport - parentRoute: typeof SettingsRoute - } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -376,7 +357,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsArchivedRoute: typeof SettingsArchivedRoute - SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -388,7 +368,6 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsArchivedRoute: SettingsArchivedRoute, - SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 75c517dc33f..e084e22c2cb 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const sidebarV2Enabled = useSidebarV2Enabled(); + const legacySidebarEnabled = useLegacySidebarEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -92,10 +92,10 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - // Sidebar v2 routes creation through the command palette whenever - // there is a real choice to make; v1 (and single-project setups) - // keep the immediate contextual create. - if (sidebarV2Enabled && projectGroupCount > 1) { + // The default sidebar routes creation through the command palette + // whenever there is a real choice to make; the legacy sidebar (and + // single-project setups) keep the immediate contextual create. + if (!legacySidebarEnabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -167,7 +167,7 @@ function ChatRouteGlobalShortcuts() { projectGroupCount, routeThreadRef, selectedThreadKeysSize, - sidebarV2Enabled, + legacySidebarEnabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx deleted file mode 100644 index a1e78f2dff7..00000000000 --- a/apps/web/src/routes/settings.beta.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; - -function SettingsBetaRoute() { - return ; -} - -export const Route = createFileRoute("/settings/beta")({ - component: SettingsBetaRoute, -}); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5bd22e95f20..46705837afa 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -67,36 +67,28 @@ describe("ClientSettings environment identification", () => { }); }); -describe("ClientSettings sidebar v2", () => { - it("defaults the beta off with a three-day auto-settle threshold", () => { +describe("ClientSettings sidebar", () => { + it("defaults to the current sidebar with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); - expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.legacySidebarEnabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); - it("treats settings written before the beta had a per-channel default as unconfigured", () => { - // The stored blob always carries `sidebarV2Enabled`, so only the companion - // flag can distinguish "user opted out" from "never touched it". - expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false); - expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false); - }); - - it("preserves an explicit beta choice", () => { - const settings = decodeClientSettings({ + it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { + const decoded = decodeClientSettings({ sidebarV2Enabled: false, sidebarV2ConfiguredByUser: true, }); - expect(settings.sidebarV2Enabled).toBe(false); - expect(settings.sidebarV2ConfiguredByUser).toBe(true); + expect(decoded.legacySidebarEnabled).toBe(false); + expect(decoded).not.toHaveProperty("sidebarV2Enabled"); + expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); - it("carries an explicit beta opt-out through the patch the beta toggle writes", () => { - const patch = decodeClientSettingsPatch({ - sidebarV2Enabled: false, - sidebarV2ConfiguredByUser: true, - }); - expect(patch.sidebarV2Enabled).toBe(false); - expect(patch.sidebarV2ConfiguredByUser).toBe(true); + it("preserves an explicit legacy sidebar opt-in", () => { + expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true); + expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe( + true, + ); }); it("allows auto-settle by inactivity to be disabled", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0ef1a6a8b75..17ae0e08683 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -171,6 +171,11 @@ export const ClientSettingsSchema = Schema.Struct({ // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Legacy sidebar (the original per-project tree). Deliberately a fresh key + // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the + // old keys, so everyone, including prior beta opt-outs, resets to the new + // default sidebar. + legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -190,13 +195,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), - sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta. - // Client settings persist as a whole blob, so every user who has ever touched - // any setting already has `sidebarV2Enabled: false` stored — without this bit - // there is no way to tell that apart from "left alone", and a channel-derived - // default could never reach them. Mirrors `updateChannelConfiguredByUser`. - sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -791,6 +789,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), ), planModeEnabled: Schema.optionalKey(Schema.Boolean), + legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( @@ -799,8 +798,6 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), - sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), - sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From 4eaf5ef8bb47b870397d5c61cd216b1a6bdd1510 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 21:09:46 -0400 Subject: [PATCH 12/81] fix(server): stop PR status lookups amplifying GitHub rate limits (#5673) Co-authored-by: Claude Opus 5 (1M context) --- apps/server/src/git/GitManager.test.ts | 68 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 98 +++++++++++++++++++++++--- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f64..5d95ea5f62f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -948,6 +949,73 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]); + + const { manager, ghCalls } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/never-pushed"); + expect(status.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]); + // No `-u`, so the remote-tracking ref exists but branch..merge does + // not. Most terminal and agent pushes land this way, and they can still + // have a PR, so the skip must not trigger here. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 214, + title: "Pushed without upstream", + url: "https://github.com/pingdotgg/t3code/pull/214", + baseRefName: "main", + headRefName: "feature/pushed-no-upstream", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.number).toBe(214); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + + it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { + expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); + // The point of the backoff: by the third retry a failing branch must not be + // asking more often than a healthy one, which refreshes every 2 minutes. + expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); + }); + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index da002df5e6c..553eda7bb9c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -110,8 +110,25 @@ const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); -const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; + +/** + * How long a failed PR lookup is cached, given the number of consecutive + * failures for that branch. + * + * A hosting provider rejects a throttled request immediately, so caching every + * failure for a flat 20s made a rate-limited poller re-ask *faster* than a + * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 + * into sustained pressure. Backing off per branch keeps the retry rate below + * the healthy rate once a branch has failed more than a couple of times. + */ +export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); + return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); +} type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -889,6 +906,23 @@ export const make = Effect.gen(function* () { // back to a null upstreamRef. const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + // Consecutive failures per cache key, so a branch that keeps failing waits + // longer before the next attempt. Cleared as soon as a lookup succeeds. + const prLookupFailureStreakByKey = new Map(); + const nextPrLookupFailureTtl = (key: string) => { + if ( + !prLookupFailureStreakByKey.has(key) && + prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = prLookupFailureStreakByKey.keys().next().value; + if (oldestKey !== undefined) { + prLookupFailureStreakByKey.delete(oldestKey); + } + } + const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; + prLookupFailureStreakByKey.set(key, streak); + return prLookupFailureTtl(streak); + }; const prLookupCache = yield* Cache.makeWith( (key: string) => { const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); @@ -896,17 +930,26 @@ export const make = Effect.gen(function* () { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, }; - return resolveBranchHeadContext(cwd, details).pipe( - Effect.flatMap((headContext) => - findLatestPrForHeadContext(cwd, headContext).pipe( - Effect.map((latest) => ({ latest, headContext })), - ), - ), - ); + return Effect.gen(function* () { + const headContext = yield* resolveBranchHeadContext(cwd, details); + // Only skip when the branch is untracked as well: anything carrying an + // upstream keeps the old behaviour. + if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + return { latest: null, headContext }; + } + const latest = yield* findLatestPrForHeadContext(cwd, headContext); + return { latest, headContext }; + }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + timeToLive: (exit, key) => { + if (Exit.isSuccess(exit)) { + prLookupFailureStreakByKey.delete(key); + return PR_LOOKUP_CACHE_TTL; + } + return nextPrLookupFailureTtl(key); + }, }, ); // A transient lookup failure (rate limit, network blip) must not clear an @@ -1169,6 +1212,43 @@ export const make = Effect.gen(function* () { } satisfies BranchHeadContext; }); + /** + * Whether git has no record of this branch on any remote, so a change request + * cannot exist for it and asking the provider is a guaranteed-empty API call. + * + * `git push` writes the remote-tracking ref even without `-u` (how most + * terminal and agent pushes land), which makes this a safer "did it ever + * reach the host" test than looking for upstream config, and the glob spans + * every remote so a fork branch still counts. A repository that tracks no + * remotes at all cannot answer the question, because then every branch looks + * unpublished; it, and any failed probe, keeps the lookup. + */ + const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( + cwd: string, + headContext: Pick, + ) { + if (headContext.headBranch.length === 0) { + return false; + } + const matchesRef = (pattern: string) => + gitCore + .execute({ + operation: "GitManager.isUnpublishedBranch", + cwd, + args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], + timeoutMs: 5_000, + }) + .pipe(Effect.map((result) => result.stdout.trim().length > 0)); + + return yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), + Effect.orElseSucceed(() => false), + ); + }); + const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, headContext: Pick< From ed886fe1814890da30ae73c77f9e894ddc9bd481 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:18 -0400 Subject: [PATCH 13/81] fix(web): delay transient reconnect warnings (#5670) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../web/src/components/ChatView.logic.test.ts | 41 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 13 ++++++ apps/web/src/components/ChatView.tsx | 25 ++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..5c026c94a13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { @@ -19,13 +19,16 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, @@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("environment reconnect warning grace", () => { + afterEach(() => vi.useRealTimers()); + + it("shows a persistent reconnect after the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + scheduleEnvironmentReconnectWarning(showWarning); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); + expect(showWarning).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(showWarning).toHaveBeenCalledOnce(); + }); + + it("cancels the warning when the connection recovers during the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + const cancel = scheduleEnvironmentReconnectWarning(showWarning); + cancel(); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + + expect(showWarning).not.toHaveBeenCalled(); + }); + + it("does not reuse elapsed grace from another environment", () => { + const anotherEnvironmentId = EnvironmentId.make("environment-remote"); + + expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); + expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( + false, + ); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..60df1cd966f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -25,9 +25,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { + const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + return () => globalThis.clearTimeout(timeoutId); +} + +export function hasEnvironmentReconnectWarningGraceElapsed( + activeEnvironmentId: EnvironmentId | null, + elapsedEnvironmentId: EnvironmentId | null, +): boolean { + return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbe1ac8d96..8b510d457fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -283,6 +283,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -1733,6 +1735,24 @@ function ChatViewContent(props: ChatViewProps) { const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" + ? (activeEnvironment?.environmentId ?? null) + : null; + const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = + useState(null); + const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + useEffect(() => { + setReconnectWarningGraceElapsedEnvironmentId(null); + if (activeReconnectingEnvironmentId === null) return; + return scheduleEnvironmentReconnectWarning(() => + setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), + ); + }, [activeReconnectingEnvironmentId]); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1965,7 +1985,9 @@ function ChatViewContent(props: ChatViewProps) { // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; + const suppressUnavailableBanner = + environmentReconnecting && + (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ @@ -2093,6 +2115,7 @@ function ChatViewContent(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey, From daf8ee0b2f684cda82e8721abe1138b219b1ef12 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 8 Aug 2026 04:31:47 +0100 Subject: [PATCH 14/81] fix(web): inherit terminal size in simple typography (#5628) --- apps/web/src/appearanceFonts.test.ts | 11 +++++++++++ apps/web/src/appearanceFonts.ts | 9 +++++++++ apps/web/src/components/ThreadTerminalDrawer.tsx | 14 ++++++++++++-- .../web/src/components/settings/SettingsPanels.tsx | 7 ++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 5c642e33f0f..31a2f1d779c 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -11,6 +11,7 @@ import { cssFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, + resolveTerminalFontSizePreference, } from "./appearanceFonts"; describe("areFontAdvancesMonospace", () => { @@ -97,6 +98,16 @@ describe("resolveTerminalFontPreference", () => { }); }); +describe("resolveTerminalFontSizePreference", () => { + it("inherits the code font size in simple mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: false, code: 15, terminal: 12 })).toBe(15); + }); + + it("keeps code and terminal font sizes independent in advanced mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: true, code: 15, terminal: 12 })).toBe(12); + }); +}); + describe("font size clamping", () => { it("keeps sizes inside the ranges the UI can absorb", () => { expect(clampInterfaceFontSize(16)).toBe(16); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 60801ef0118..6053e5fb0dd 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -41,6 +41,15 @@ export function resolveTerminalFontPreference(input: { return input.code; } +export function resolveTerminalFontSizePreference(input: { + readonly advanced: boolean; + readonly code: number; + readonly terminal: number; +}): number { + if (input.advanced) return input.terminal; + return input.code; +} + function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 25b4abb3fbe..c59f682c415 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -66,7 +66,11 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; -import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; +import { + resolveTerminalFontPreference, + resolveTerminalFontSizePreference, + TYPOGRAPHY_ADVANCED_STORAGE_KEY, +} from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -341,7 +345,13 @@ export function TerminalViewport({ terminal: settings.fontFamilyTerminal, }), ); - const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontSize = useClientSettings((settings) => + resolveTerminalFontSizePreference({ + advanced: advancedTypography, + code: settings.fontSizeCode, + terminal: settings.fontSizeTerminal, + }), + ); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bdfb830732e..f9046f44106 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -100,6 +100,7 @@ import { isMonospaceFamily, resolveDefaultFamilyLabel, resolveTerminalFontPreference, + resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; @@ -1272,7 +1273,11 @@ function SimpleFontRows() { code: settings.fontFamilyCode, terminal: settings.fontFamilyTerminal, })} - size={settings.fontSizeTerminal} + size={resolveTerminalFontSizePreference({ + advanced: false, + code: settings.fontSizeCode, + terminal: settings.fontSizeTerminal, + })} /> } From 2c7267ad43a05cf3e30343400c76fd9ac47698e7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 00:42:14 -0400 Subject: [PATCH 15/81] fix(server): stop the reaper from silently killing live background subagents (#5677) Co-authored-by: Claude Fable 5 --- .../Layers/ProviderSessionReaper.test.ts | 82 +++++++++++++++---- .../provider/Layers/ProviderSessionReaper.ts | 13 +++ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index f3f4ca39d47..1281b2f70fe 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -68,6 +68,7 @@ function makeReadModel( readonly lastError: string | null; readonly updatedAt: string; } | null; + readonly backgroundLiveness?: "working" | "monitoring" | null; }>, ) { const now = "2026-01-01T00:00:00.000Z"; @@ -109,6 +110,7 @@ function makeReadModel( latestTurn: null, messages: [], session: thread.session, + backgroundLiveness: thread.backgroundLiveness ?? null, activities: [], proposedPlans: [], checkpoints: [], @@ -135,6 +137,14 @@ describe("ProviderSessionReaper", () => { runtime = null; }); + // Shared start sequence so each test adds no manual Effect runners + // (no-manual-effect-runtime-in-tests tracks this file's legacy count). + async function startReaper() { + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + } + async function createHarness(input: { readonly readModel: ReturnType; readonly stopSessionImplementation?: (input: { @@ -261,9 +271,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 1); @@ -311,9 +319,55 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips stale sessions while background work is still live", async () => { + const threadId = ThreadId.make("thread-reaper-background-work"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + backgroundLiveness: "working", + }, + ]), + }); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-background-work", + }, + runtimePayload: null, + }), + ); + + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -360,9 +414,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -409,9 +461,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -495,9 +545,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); @@ -578,9 +626,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 8eccd52fb2c..15d4f925c39 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -71,6 +71,19 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } + // The turn can settle while background work runs on (subagent + // fleets, workflow runs, Monitor watch loops). Those live inside the + // provider process, so stopping the session would kill them silently, + // and nothing bumps lastSeenAt between turns. + if (thread?.backgroundLiveness != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-background-work", { + threadId: binding.threadId, + backgroundLiveness: thread.backgroundLiveness, + idleDurationMs, + }); + continue; + } + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( Effect.tap(() => Effect.logInfo("provider.session.reaped", { From 06404107261cae24394d6abe39984fb445c5aff5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 05:24:38 -0400 Subject: [PATCH 16/81] fix(desktop): zoom shortcuts no longer die when the preview browser has focus (#5691) Co-authored-by: Claude Fable 5 --- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 80 ++++++++++++++----- .../src/window/DesktopApplicationMenu.ts | 29 ++++++- apps/desktop/src/window/DesktopWindow.ts | 20 +++++ 5 files changed, 108 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index be9d7f3451f..45e1c82460c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => { handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, syncAppearance: Effect.void, }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 523e8764697..98bd4065fbe 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,6 +91,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + zoomMain: () => Effect.die("unexpected zoom"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 22a24b908b6..0c826e36dd9 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + zoomMain: (direction) => + Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); @@ -94,6 +96,30 @@ const makeElectronMenuLayer = ( showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); +const configureMenu = ( + selectedAction: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, +) => + Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { @@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => { const applicationMenuTemplate = yield* Deferred.make(); - yield* Effect.gen(function* () { - const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - yield* menu.configure; - }).pipe( - Effect.provide( - DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), - Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), - Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(electronDialogLayer), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - ), - ), - ), - ), - ); + yield* configureMenu(selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); const fileMenu = template.find((item) => item.label === "File"); @@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + // Zoom must route through DesktopWindow.zoomMain instead of the Electron + // zoom roles: the roles zoom whichever webContents has focus, which breaks + // app zoom while an embedded preview WebContentsView holds focus. + it.effect("routes View menu zoom to the main window instead of zoom roles", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + assert.isDefined(viewMenu); + if (!Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + + assert.isUndefined( + viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + ); + + const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + assert.isDefined(zoomIn); + assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + if (typeof zoomIn.click !== "function") { + throw new Error("Expected Zoom In menu item to have a click handler."); + } + + zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0..66244534deb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); +const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( + direction: DesktopWindow.MainWindowZoomDirection, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.zoomMain(direction); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { + runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + /* + Not the zoom roles: those act on the focused webContents, so with + an embedded preview WebContentsView focused they zoom the guest + page and the app UI appears stuck. These always zoom the main + window (see DesktopWindow.zoomMain). + */ + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, + { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: zoomClick("in"), + }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..bf8c681448f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -61,6 +61,8 @@ export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; +export type MainWindowZoomDirection = "in" | "out" | "reset"; + export class DesktopWindow extends Context.Service< DesktopWindow, { @@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` + // menu roles act on whichever webContents has keyboard focus, so with an + // embedded preview WebContentsView (or DevTools) focused they zoom the + // guest page instead of the app UI. The menu routes here to always target + // the main window. + readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -836,6 +844,18 @@ export const make = Effect.gen(function* () { send(); }), + zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { + yield* Effect.annotateCurrentSpan({ direction }); + const window = yield* focusedMainWindow; + if (Option.isNone(window) || window.value.isDestroyed()) { + return; + } + const webContents = window.value.webContents; + // Same step size as the Electron zoomIn/zoomOut menu roles. + webContents.setZoomLevel( + direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), + ); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => From 30164cb1ba8ea05fdd6be69215dba2cc2f0e2aa8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 06:51:20 -0400 Subject: [PATCH 17/81] feat(mobile): one sheet for model and thread settings (#5625) Co-authored-by: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 173 ++--- .../src/features/threads/ThreadComposer.tsx | 181 ++--- .../features/threads/ThreadSettingsSheet.tsx | 678 ++++++++++++++++++ .../threads/new-task-flow-provider.tsx | 22 +- .../thread-settings-sheet-state.test.ts | 64 ++ .../threads/thread-settings-sheet-state.ts | 13 + .../use-thread-settings-sheet-presentation.ts | 98 +++ apps/mobile/src/lib/modelOptions.test.ts | 89 ++- apps/mobile/src/lib/modelOptions.ts | 71 +- apps/mobile/src/lib/providerOptions.test.ts | 52 +- apps/mobile/src/lib/providerOptions.ts | 107 +-- 11 files changed, 1079 insertions(+), 469 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadSettingsSheet.tsx create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-state.ts create mode 100644 apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6b121d85108..bf2dfa8f4d4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ComposerSurface } from "./ThreadComposer"; +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -43,7 +40,7 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: promptInputRef, + isEditorFocused: isComposerFocused, + }); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { let focusFrame: ReturnType | null = null; const interaction = InteractionManager.runAfterInteractions(() => { - focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus()); + focusFrame = requestAnimationFrame(() => { + // The delayed focus can land after the settings sheet opened, which + // would pop the keyboard underneath its modal. + if (!settingsSheetPresentation.isActiveRef.current) { + promptInputRef.current?.focus(); + } else { + settingsSheetPresentation.restoreFocusAfterSave(); + } + }); }); return () => { @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { cancelAnimationFrame(focusFrame); } }; - }, [selectedProject]); + }, [ + selectedProject, + settingsSheetPresentation.isActiveRef, + settingsSheetPresentation.restoreFocusAfterSave, + ]); const environmentMenuActions = useMemo( () => @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), - [flow.providerGroups, flow.selectedModel], - ); const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], ); - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - flow.runtimeMode === "approval-required" - ? "Approve actions" - : flow.runtimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : flow.runtimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: flow.runtimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: flow.interactionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: flow.interactionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], - ); - const workspaceMenuActions = useMemo(() => { const branchActions = flow.availableBranches.length === 0 @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: flow.selectedModelOption?.label ?? "Model", + optionDescriptors: providerOptionDescriptors, + runtimeMode: flow.runtimeMode, + interactionMode: flow.interactionMode, + }); const workspaceLabel = useMemo( () => formatWorkspaceLabel({ @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { }), [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); - function handleModelMenuAction(event: string) { - if (isIncomingShareTransferPending || !event.startsWith("model:")) { - return; - } - flow.setSelectedModelKey(event.slice("model:".length)); - } - function handleEnvironmentMenuAction(event: string) { if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } - function handleOptionsMenuAction(event: string) { - if (isIncomingShareTransferPending) { - return; - } - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - flow.setSelectedModelOptions(providerOptions); - return; - } - if (event.startsWith("options:runtime:")) { - flow.setRuntimeMode( - event.slice("options:runtime:".length) as Parameters[0], - ); - return; - } - if (event.startsWith("options:interaction:")) { - flow.setInteractionMode( - event.slice("options:interaction:".length) as Parameters[0], - ); - } - } - function handleWorkspaceMenuAction(event: string) { if (isIncomingShareTransferPending) { return; @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { const isDarkMode = colorScheme === "dark"; // Android expansion follows native editor focus so relayout cannot race // the touch gesture that opens the keyboard. - const isExpanded = !isAndroid || isComposerFocused; + // The settings sheet dismisses the keyboard, so its flag keeps the Android + // draft composer expanded through the blur (mirrors ThreadComposer). + const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { showChevron={false} disabled={isIncomingShareTransferPending} /> - handleModelMenuAction(nativeEvent.event)} - > - } - label={flow.selectedModelOption?.label ?? "Model"} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> handleEnvironmentMenuAction(nativeEvent.event)} @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { ); + const settingsSheet = ( + flow.setSelectedModelKey(option.key, option.selection.options)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={flow.setSelectedModelOptions} + runtimeMode={flow.runtimeMode} + onUpdateRuntimeMode={flow.setRuntimeMode} + /> + ); + const startButton = ( + {settingsSheet} ); } @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { + {settingsSheet} ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1b026964c92..c846dca287a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -51,10 +51,10 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; +import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -62,14 +62,11 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -273,15 +270,28 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: inputRef, + isEditorFocused: isFocused, + }); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - const isExpanded = isFocused; + // Opening and closing count as active so the composer stays expanded while + // focus moves between its native editor and the settings modal. + const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; + // Notify the parent from the derived value, not focus events: the parent + // sizes the feed inset from this, and blur-during-sheet would otherwise + // report collapsed while the composer still renders expanded. + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleFocus = useCallback(() => { setIsFocused(true); - onExpandedChange?.(true); - }, [onExpandedChange]); + }, []); const handleBlur = useCallback(() => { setIsFocused(false); - onExpandedChange?.(false); - }, [onExpandedChange]); + }, []); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); + // An existing thread is bound to its harness: sessions can't move between + // provider instances, so the picker only offers the thread's own group. + const threadProviderGroups = useMemo( + () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), + [providerGroups, currentModelSelection.instanceId], + ); const currentModelOption = modelOptions.find( (option) => @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(providerGroups, currentModelSelection), - [providerGroups, currentModelSelection], - ); - - // ── Options menu ───────────────────────────────────────── - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - currentRuntimeMode === "approval-required" - ? "Approve actions" - : currentRuntimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : currentRuntimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: currentRuntimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: currentInteractionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: currentInteractionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], - ); - - // ── Menu handlers ──────────────────────────────────────── - function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { - return; - } - const modelKey = event.slice("model:".length); - const option = modelOptions.find((o) => o.key === modelKey); - if (option) { - props.onUpdateModelSelection(option.selection); - } - } - - function handleOptionsMenuAction(event: string) { - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - props.onUpdateModelSelection({ - ...currentModelSelection, - options: providerOptions, - }); - return; - } - if (event.startsWith("options:runtime:")) { - const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; - props.onUpdateRuntimeMode(runtimeMode); - return; - } - if (event.startsWith("options:interaction:")) { - const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; - props.onUpdateInteractionMode(interactionMode); - } - } + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: currentModelOption?.label ?? currentModelSelection.model, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + interactionMode: currentInteractionMode, + }); return ( void props.onPickDraftImages()} showChevron={false} /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> {showStopAction ? ( + props.onUpdateModelSelection(option.selection)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={(options) => + props.onUpdateModelSelection({ ...currentModelSelection, options }) + } + runtimeMode={currentRuntimeMode} + onUpdateRuntimeMode={props.onUpdateRuntimeMode} + /> + = new Set(["claudeAgent", "codex"]); + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +/** + * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, + * covering model, provider options, runtime mode, and plan mode in one label. + */ +export function threadSettingsSummaryLabel(input: { + readonly modelLabel: string; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +}): string { + const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); + return [ + input.modelLabel, + ...providerOptionValueLabels(input.optionDescriptors), + ...(runtime ? [runtime.shortLabel] : []), + ...(input.interactionMode === "plan" ? ["Plan"] : []), + ].join(" · "); +} + +function selectableChoices(descriptor: Extract) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +function ModelRow(props: { + readonly option: ModelOption; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + + {props.selected ? ( + + ) : null} + + ); +} + +/** + * Provider section header with the harness logo. Secondary providers render + * as a tappable fold (count + chevron while collapsed); primary providers + * and the group holding the current selection are static headers. + */ +function ProviderHeader(props: { + readonly driver: string | undefined; + readonly label: string; + readonly collapsible: boolean; + readonly collapsed: boolean; + readonly modelCount: number; + readonly onToggle: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + + + {props.label} + + {props.collapsible ? ( + <> + + {props.collapsed ? ( + + {props.modelCount} + + ) : null} + + + ) : null} + + ); +} + +/** Compact row that opens a single-choice submenu panel. */ +function DisclosureRow(props: { + readonly label: string; + readonly value: string | undefined; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + {props.label} + + {props.value ? ( + + {props.value} + + ) : null} + + + ); +} + +/** Single option inside a submenu panel. */ +function ChoiceRow(props: { + readonly label: string; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.label} + + + {props.selected ? ( + + ) : null} + + ); +} + +function SwitchRow(props: { + readonly label: string; + readonly value: boolean; + readonly disabled?: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + return ( + + {props.label} + + + ); +} + +type SubmenuPage = + | { readonly kind: "descriptor"; readonly id: string } + | { readonly kind: "runtime" }; + +/** + * Unified thread settings: the sheet is the provider-grouped model list + * (primary harnesses expanded, other providers folded, legacy behind the + * top-right pill) with a Save button, plus compact disclosure rows whose + * single-choice submenus stack in a small panel over the sheet so it never + * changes size. Model changes stage until Save — while staged, the settings + * rows edit the staged model's options and Save applies everything together. + * + * Callers control which harnesses are offered via providerGroups: an + * existing thread must pass only its own provider's group, since a session + * can't switch harness mid-thread. + * + * Rendered through an RN Modal (not the root OverlayPortal) so it also + * presents above natively-presented form sheets like the new-task draft. + * Callers must dismiss the keyboard when opening — the iOS keyboard window + * would otherwise cover the lower half of the sheet. + */ +export function ThreadSettingsSheet(props: { + readonly visible: boolean; + /** + * "save" = the Save/Done button (the user is finished configuring); + * "dismiss" = backdrop, grabber, or system back. Hosts only restore the + * keyboard for "save" so a stray tap outside a control never pops it. + */ + readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; + readonly onDismissed: () => void; + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly onSelectModel: (option: ModelOption) => void; + readonly optionDescriptors: ReadonlyArray; + readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; + readonly runtimeMode: RuntimeMode; + readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; +}) { + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const [showLegacyToggle, setShowLegacyToggle] = useState(false); + const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); + const [pendingModel, setPendingModel] = useState(null); + const [submenu, setSubmenu] = useState(null); + const wasPresentedRef = useRef(false); + const notifyDismissed = useCallback(() => { + if (!wasPresentedRef.current) { + return; + } + wasPresentedRef.current = false; + props.onDismissed(); + }, [props.onDismissed]); + + // Every open starts fresh: no staged model, no submenu, legacy hidden, + // secondary providers folded. The sheet stays mounted between opens, so + // state would otherwise stick around. + useEffect(() => { + if (props.visible) { + wasPresentedRef.current = true; + setShowLegacyToggle(false); + setExpandedProviders(new Set()); + setPendingModel(null); + setSubmenu(null); + } else if (Platform.OS === "android" && wasPresentedRef.current) { + // React Native only emits Modal.onDismiss on iOS. Android uses no exit + // animation below, so the post-commit effect is its dismissal boundary. + notifyDismissed(); + } + }, [notifyDismissed, props.visible]); + + const isApplied = (option: ModelOption) => + option.selection.instanceId === props.selectedModel?.instanceId && + option.selection.model === props.selectedModel.model; + // The list highlights the staged pick; Save turns it into the applied one. + const isDisplayed = (option: ModelOption) => + pendingModel ? option.key === pendingModel.key : isApplied(option); + + // While a model is staged, the settings rows describe and edit the staged + // model's options (kept on its pending selection); Save applies model and + // options together. Otherwise they edit the applied selection directly. + const displayedDescriptors = pendingModel + ? pendingModel.capabilities + ? getProviderOptionDescriptors({ + caps: pendingModel.capabilities, + selections: pendingModel.selection.options, + }) + : [] + : props.optionDescriptors; + + const hasLegacyModels = props.providerGroups.some((group) => + group.models.some((model) => model.isLegacy), + ); + // Legacy stays hidden unless the pill is toggled this open; a highlighted + // legacy model is exempted from the filter instead of forcing the whole + // legacy list visible. + const showLegacy = showLegacyToggle; + + // Stable settings rows: the union of descriptors across the primary + // harnesses' current models (plus whatever the displayed model advertises) + // always renders, with unsupported rows disabled instead of vanishing when + // the selection changes. Keyed by label, not id — Claude and Codex use + // different ids for the same "Reasoning" concept. + const descriptorTemplate = (() => { + const seen = new Map(); + for (const group of props.providerGroups) { + const driver = group.models[0]?.providerDriver; + if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { + continue; + } + for (const model of group.models) { + if (model.isLegacy) { + continue; + } + for (const descriptor of model.capabilities?.optionDescriptors ?? []) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + } + } + for (const descriptor of displayedDescriptors) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); + })(); + + const handleSave = () => { + if (pendingModel) { + void Haptics.selectionAsync(); + props.onSelectModel(pendingModel); + } + props.onClose("save"); + }; + + const handleOptionChange = (id: string, value: string | boolean) => { + const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + if (!next) { + return; + } + if (pendingModel) { + setPendingModel({ + ...pendingModel, + selection: { ...pendingModel.selection, options: next }, + }); + } else { + props.onUpdateOptionSelections(next); + } + }; + + const toggleProvider = (providerKey: string) => { + setExpandedProviders((current) => { + const next = new Set(current); + if (!next.delete(providerKey)) { + next.add(providerKey); + } + return next; + }); + }; + + const activeDescriptor = + submenu?.kind === "descriptor" + ? displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + ) + : undefined; + + const submenuContent = + submenu?.kind === "runtime" + ? { + title: "Runtime", + rows: RUNTIME_MODE_CHOICES.map((choice) => ({ + id: choice.mode, + label: choice.label, + selected: choice.mode === props.runtimeMode, + onPress: () => { + void Haptics.selectionAsync(); + props.onUpdateRuntimeMode(choice.mode); + setSubmenu(null); + }, + })), + } + : activeDescriptor?.type === "select" + ? { + title: activeDescriptor.label, + rows: selectableChoices(activeDescriptor).map((choice) => ({ + id: choice.id, + label: choice.label, + selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), + onPress: () => { + void Haptics.selectionAsync(); + handleOptionChange(activeDescriptor.id, choice.id); + setSubmenu(null); + }, + })), + } + : null; + + return ( + setSubmenu(null) : () => props.onClose("dismiss")} + > + + props.onClose("dismiss")} + /> + + {/* The grabber doubles as the accessible close control: the dim + backdrop above a tall sheet is a sliver, and VoiceOver can't + reach it at all. */} + props.onClose("dismiss")} + className="items-center pb-1 pt-2.5" + > + + + {hasLegacyModels ? ( + + { + void Haptics.selectionAsync(); + setShowLegacyToggle(!showLegacy); + }} + className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" + > + + {showLegacy ? "Hide legacy models" : "Show legacy models"} + + + + ) : null} + {/* Only the model list scrolls. Provider catalogs can run to + hundreds of models (OpenRouter), so the rows below stay pinned + and reachable instead of living at the end of that scroll. */} + + {props.providerGroups.map((group) => { + const driver = group.models[0]?.providerDriver; + const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); + const visibleModels = showLegacy + ? group.models + : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); + if (visibleModels.length === 0) { + return null; + } + const containsSelection = group.models.some(isDisplayed); + const collapsible = !isPrimary && !containsSelection; + const collapsed = collapsible && !expandedProviders.has(group.providerKey); + return ( + + toggleProvider(group.providerKey)} + /> + {collapsed + ? null + : visibleModels.map((option) => ( + { + void Haptics.selectionAsync(); + // Re-tapping the applied model cancels staging. + setPendingModel((current) => + pendingModelAfterPress({ + current, + pressed: option, + pressedIsApplied: isApplied(option), + }), + ); + }} + /> + ))} + + ); + })} + + + + + + {descriptorTemplate.map((entry) => { + const live = displayedDescriptors.find( + (descriptor) => descriptor.label === entry.label, + ); + if ((live?.type ?? entry.type) === "select") { + return ( + { + if (live) { + setSubmenu({ kind: "descriptor", id: live.id }); + } + }} + /> + ); + } + return ( + { + if (live) { + handleOptionChange(live.id, value); + } + }} + /> + ); + })} + choice.mode === props.runtimeMode)?.label + } + onPress={() => setSubmenu({ kind: "runtime" })} + /> + + + {pendingModel ? "Save" : "Done"} + + + + + + {/* Submenus stack over the sheet instead of replacing its content, + so the main sheet keeps its size while drilling in and out. */} + {submenuContent ? ( + + setSubmenu(null)} + /> + + setSubmenu(null)} + className="items-center pb-1 pt-2.5" + > + + + + {submenuContent.title} + + + {submenuContent.rows.map((row) => ( + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 18bacd12577..e3170eef000 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -25,6 +25,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; - readonly setSelectedModelKey: (key: string | null) => void; + readonly setSelectedModelKey: ( + key: string | null, + options?: ReadonlyArray, + ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; @@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - // Stored selections (draft and project default) only count while their - // provider is usable on the server; otherwise the server's default model - // wins instead of silently targeting a disabled provider. + // Stored selections only count while their provider is usable on the + // server; otherwise the server's default model wins instead of silently + // targeting a disabled provider. The draft selection is an explicit pick + // and passes through as-is; the project default (last used, possibly from + // desktop) is implicit and additionally never resolves to a legacy model. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, ); - const projectDefaultModelSelection = resolveSelectableModelSelection( + const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); @@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( - (key: string | null) => { + // Options ride along in the same write: a follow-up setSelectedModelOptions + // call would rebuild the selection from the stale pre-switch model. + (key: string | null, options?: ReadonlyArray) => { if (!key || !selectedProjectDraftKey) { return; } @@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: option.selection, + modelSelection: options ? { ...option.selection, options } : option.selection, }); }, [modelOptions, selectedProjectDraftKey], diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts new file mode 100644 index 00000000000..1264c75cd33 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; + +import type { ModelOption } from "../../lib/modelOptions"; +import { pendingModelAfterPress } from "./thread-settings-sheet-state"; + +function modelOption( + model: string, + options: ReadonlyArray = [], +): ModelOption { + return { + key: `codex:${model}`, + label: model, + subtitle: "Codex", + providerKey: "codex", + providerLabel: "Codex", + providerDriver: "codex", + isDefault: false, + isLegacy: false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make("codex"), + model, + options, + }, + }; +} + +describe("thread settings sheet state", () => { + it("clears staging when the applied model is pressed", () => { + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed: modelOption("gpt-current"), + pressedIsApplied: true, + }), + ).toBeNull(); + }); + + it("preserves staged options when the highlighted model is pressed again", () => { + const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]); + + expect( + pendingModelAfterPress({ + current: pending, + pressed: modelOption("gpt-next"), + pressedIsApplied: false, + }), + ).toBe(pending); + }); + + it("stages a different model", () => { + const pressed = modelOption("gpt-other"); + + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed, + pressedIsApplied: false, + }), + ).toBe(pressed); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts new file mode 100644 index 00000000000..f0540dc5a97 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -0,0 +1,13 @@ +import type { ModelOption } from "../../lib/modelOptions"; + +/** Preserve staged provider options when the highlighted model is tapped again. */ +export function pendingModelAfterPress(input: { + readonly current: ModelOption | null; + readonly pressed: ModelOption; + readonly pressedIsApplied: boolean; +}): ModelOption | null { + if (input.pressedIsApplied) { + return null; + } + return input.current?.key === input.pressed.key ? input.current : input.pressed; +} diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts new file mode 100644 index 00000000000..3cc2ed18468 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { KeyboardController } from "react-native-keyboard-controller"; + +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; + +export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; + +type PresentationPhase = "closed" | "opening" | "visible" | "closing"; + +/** + * Keeps the custom native composer and the settings modal from owning focus at + * the same time. Opening waits for the keyboard dismissal to finish, while + * focus restoration waits for the modal's dismissal callback. + */ +export function useThreadSettingsSheetPresentation(input: { + readonly editorRef: RefObject; + readonly isEditorFocused: boolean; +}) { + const [phase, setPhase] = useState("closed"); + const isActiveRef = useRef(false); + const isMountedRef = useRef(true); + const openingIdRef = useRef(0); + const restoreFocusOnSaveRef = useRef(false); + const shouldRestoreAfterDismissRef = useRef(false); + + useEffect( + () => () => { + isMountedRef.current = false; + isActiveRef.current = false; + openingIdRef.current += 1; + }, + [], + ); + + const open = useCallback(() => { + if (isActiveRef.current) { + return; + } + + isActiveRef.current = true; + restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); + shouldRestoreAfterDismissRef.current = false; + setPhase("opening"); + + const openingId = openingIdRef.current + 1; + openingIdRef.current = openingId; + + // Keyboard.dismiss() only tracks React Native TextInputs. The composer is + // a custom native text view, so explicitly resign its first responder too. + input.editorRef.current?.blur(); + void KeyboardController.dismiss().then(() => { + if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { + return; + } + setPhase("visible"); + }); + }, [input.editorRef, input.isEditorFocused]); + + const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { + if (!isActiveRef.current) { + return; + } + + openingIdRef.current += 1; + shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; + setPhase("closing"); + }, []); + + const onDismissed = useCallback(() => { + const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; + shouldRestoreAfterDismissRef.current = false; + restoreFocusOnSaveRef.current = false; + isActiveRef.current = false; + setPhase("closed"); + + if (shouldRestoreFocus) { + input.editorRef.current?.focus(); + } + }, [input.editorRef]); + + // The new-task screen can have an autofocus queued before the sheet opens. + // Preserve that intent for Save without allowing it to focus under the modal. + const restoreFocusAfterSave = useCallback(() => { + if (isActiveRef.current) { + restoreFocusOnSaveRef.current = true; + } + }, []); + + return { + isActive: phase !== "closed", + isActiveRef, + isVisible: phase === "visible", + open, + close, + onDismissed, + restoreFocusAfterSave, + } as const; +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 2ec8566b4e4..8a9dabbe034 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; import { - buildModelMenuActions, buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "./modelOptions"; describe("mobile model options", () => { - it("folds legacy models into a provider-scoped menu", () => { + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ { @@ -39,51 +39,14 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); - - expect(actions).toMatchObject([ - { - title: "Codex", - subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], - }, + expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], - }, - ]); - }); - - it("omits an empty provider menu when every model is legacy", () => { - const config = { - providers: [ - { - instanceId: "codex", - driver: "codex", - displayName: "Codex", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - isLegacy: true, - capabilities: null, - }, - ], - }, - ], - } as unknown as ServerConfig; - - expect( - buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null), - ).toMatchObject([ - { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4" }], + providerKey: "codex", + providerLabel: "Codex", + models: [ + { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, + { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, + ], }, ]); }); @@ -174,4 +137,38 @@ describe("mobile model options", () => { // No config (environment offline) — nothing to validate against. expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + + it("keeps legacy models out of implicit defaults", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + + expect(resolveDefaultableModelSelection(config, current)).toBe(current); + // A legacy last-used selection falls through to the provider default. + expect(resolveDefaultableModelSelection(config, legacy)).toBeNull(); + // Offline: nothing to validate against, selection passes through. + expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 951b74f7d51..cb7a8c4198e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,7 +3,6 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( : null; } +/** + * Like resolveSelectableModelSelection, but additionally rejects legacy + * models. Used for implicit defaults (stored draft, project last-used): a + * new thread should never quietly start on a legacy model, so those fall + * through to the provider's default instead. Explicit picks in the settings + * sheet are unaffected. + */ +export function resolveDefaultableModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, +): ModelSelection | null { + const usable = resolveSelectableModelSelection(config, selection); + if (!usable || !config) { + return usable; + } + const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === usable.model); + return model?.isLegacy === true ? null : usable; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr models: group.models, })); } - -function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction { - return { - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === selectedModel?.instanceId && - option.selection.model === selectedModel.model - ? "on" - : undefined, - }; -} - -export function buildModelMenuActions( - groups: ReadonlyArray, - selectedModel: ModelSelection | null, -): MenuAction[] { - return groups.flatMap((group) => { - const currentModels = group.models.filter((model) => !model.isLegacy); - const legacyModels = group.models.filter((model) => model.isLegacy); - const selected = group.models.find( - (model) => - model.selection.instanceId === selectedModel?.instanceId && - model.selection.model === selectedModel.model, - ); - - return [ - ...(currentModels.length > 0 - ? [ - { - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: selected && !selected.isLegacy ? selected.label : undefined, - subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ...(legacyModels.length > 0 - ? [ - { - id: `legacy-models:${group.providerKey}`, - title: `${group.providerLabel} legacy models`, - subtitle: selected?.isLegacy ? selected.label : undefined, - subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ]; - }); -} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d7f99a3dab7..d87df6baaf1 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, + applyProviderOptionSelection, + providerOptionValueLabels, resolveProviderOptionDescriptors, } from "./providerOptions"; @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("renders the option descriptors advertised by the selected model", () => { + it("summarizes the option values currently in effect", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Reasoning", - subtitle: "Medium", - subactions: [ - { title: "Medium (default)", state: "on" }, - { title: "High", state: undefined }, - ], - }, - { - title: "Service Tier", - subtitle: "Standard", - subactions: [ - { title: "Standard (default)", state: "on" }, - { title: "Fast", state: undefined }, - ], - }, - ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); }); it("updates generic select options without knowing provider-specific ids", () => { @@ -67,14 +48,18 @@ describe("mobile provider options", () => { capabilities: CODEX_CAPABILITIES, selections: undefined, }); - const actions = buildProviderOptionMenuActions(descriptors); - const fastEvent = actions[1]?.subactions?.[1]?.id; - expect(fastEvent).toBeDefined(); - expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), + ).toEqual([ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "priority" }, ]); + // Choices the model doesn't advertise are rejected, not stored. + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "turbo" }), + ).toBeNull(); + expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); it("treats an unspecified boolean capability as off", () => { @@ -85,16 +70,9 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Fast Mode", - subtitle: "Off", - subactions: [ - { title: "Off", state: "on" }, - { title: "On", state: undefined }, - ], - }, + expect(providerOptionValueLabels(descriptors)).toEqual([]); + expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ + { id: "fastMode", value: true }, ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); }); }); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index ae195498962..593f5a37442 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -3,48 +3,12 @@ import type { ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, - getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; - -function providerOptionEvent(id: string, value: string | boolean): string { - return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; -} - -function parseProviderOptionEvent( - event: string, -): { readonly id: string; readonly value: string | boolean } | null { - if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { - return null; - } - - try { - const parsed: unknown = JSON.parse( - decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), - ); - if ( - typeof parsed === "object" && - parsed !== null && - "id" in parsed && - typeof parsed.id === "string" && - "value" in parsed && - (typeof parsed.value === "string" || typeof parsed.value === "boolean") - ) { - return { id: parsed.id, value: parsed.value }; - } - } catch { - return null; - } - - return null; -} - export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { }); } -export function buildProviderOptionMenuActions( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.map((descriptor) => { - const currentValue = - descriptor.type === "boolean" - ? (descriptor.currentValue ?? false) - : getProviderOptionCurrentValue(descriptor); - const choices = - descriptor.type === "select" - ? descriptor.options.map((option) => ({ - id: providerOptionEvent(descriptor.id, option.id), - title: `${option.label}${option.isDefault ? " (default)" : ""}`, - state: currentValue === option.id ? ("on" as const) : undefined, - })) - : ([false, true] as const).map((value) => ({ - id: providerOptionEvent(descriptor.id, value), - title: value ? "On" : "Off", - state: currentValue === value ? ("on" as const) : undefined, - })); - - return { - id: `provider-option-menu:${descriptor.id}`, - title: descriptor.label, - subtitle: - descriptor.type === "boolean" - ? currentValue - ? "On" - : "Off" - : getProviderOptionCurrentLabel(descriptor), - subactions: choices, - }; - }); -} - -export function providerOptionsConfigurationLabel( +/** + * Labels for the option values currently in effect (select values plus + * enabled booleans), used to summarize the thread configuration in the + * composer trigger pill. + */ +export function providerOptionValueLabels( descriptors: ReadonlyArray, -): string { - const labels = descriptors.flatMap((descriptor) => { +): ReadonlyArray { + return descriptors.flatMap((descriptor) => { if (descriptor.type === "boolean") { return descriptor.currentValue ? [descriptor.label] : []; } const label = getProviderOptionCurrentLabel(descriptor); return label ? [label] : []; }); - return labels.length > 0 ? labels.join(" · ") : "Configuration"; } -export function applyProviderOptionMenuEvent( +/** + * Applies one option change (by descriptor id) and returns the full selection + * list to store on the model selection, or null when the change doesn't match + * an advertised descriptor / choice. + */ +export function applyProviderOptionSelection( descriptors: ReadonlyArray, - event: string, + change: ProviderOptionSelection, ): ReadonlyArray | null { - const selection = parseProviderOptionEvent(event); - if (!selection) { - return null; - } - - const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + const descriptor = descriptors.find((candidate) => candidate.id === change.id); if (!descriptor) { return null; } if ( - (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "boolean" && typeof change.value !== "boolean") || (descriptor.type === "select" && - (typeof selection.value !== "string" || - !descriptor.options.some((option) => option.id === selection.value))) + (typeof change.value !== "string" || + !descriptor.options.some((option) => option.id === change.value))) ) { return null; } @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( candidate.id === descriptor.id ? { ...candidate, - currentValue: selection.value, + currentValue: change.value, } : candidate, ) as ReadonlyArray; From 8101cd044911c7dc2a2adf7c7a9ba7962abf57b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 06:59:04 -0400 Subject: [PATCH 18/81] feat(usage): usage page reading provider transcripts across environments (#5684) Co-authored-by: Claude Opus 5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 4 + apps/server/src/usage/UsageService.ts | 420 ++++++++++++++++ .../server/src/usage/usageAggregation.test.ts | 132 +++++ apps/server/src/usage/usageAggregation.ts | Bin 0 -> 6019 bytes apps/server/src/usage/usagePricing.ts | 148 ++++++ apps/server/src/usage/usageScanCache.test.ts | 206 ++++++++ apps/server/src/usage/usageScanCache.ts | 253 ++++++++++ .../server/src/usage/usageTranscriptReader.ts | 141 ++++++ .../server/src/usage/usageTranscripts.test.ts | 151 ++++++ apps/server/src/usage/usageTranscripts.ts | 246 ++++++++++ apps/server/src/ws.ts | 6 + .../src/components/sidebar/SidebarChrome.tsx | 15 +- apps/web/src/components/usage/UsagePage.tsx | 454 ++++++++++++++++++ .../usage/UsageProviderChart.test.ts | 91 ++++ .../components/usage/UsageProviderChart.tsx | 411 ++++++++++++++++ .../src/components/usage/usageProviders.ts | 32 ++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/usage.tsx | 7 + apps/web/src/state/usage.ts | 126 +++++ apps/web/src/usage/usageFormat.ts | 107 +++++ apps/web/src/usage/usageMerge.test.ts | 258 ++++++++++ apps/web/src/usage/usageMerge.ts | 353 ++++++++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/usage.ts | 194 ++++++++ 28 files changed, 3795 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/usage/UsageService.ts create mode 100644 apps/server/src/usage/usageAggregation.test.ts create mode 100644 apps/server/src/usage/usageAggregation.ts create mode 100644 apps/server/src/usage/usagePricing.ts create mode 100644 apps/server/src/usage/usageScanCache.test.ts create mode 100644 apps/server/src/usage/usageScanCache.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.ts create mode 100644 apps/server/src/usage/usageTranscripts.test.ts create mode 100644 apps/server/src/usage/usageTranscripts.ts create mode 100644 apps/web/src/components/usage/UsagePage.tsx create mode 100644 apps/web/src/components/usage/UsageProviderChart.test.ts create mode 100644 apps/web/src/components/usage/UsageProviderChart.tsx create mode 100644 apps/web/src/components/usage/usageProviders.ts create mode 100644 apps/web/src/routes/usage.tsx create mode 100644 apps/web/src/state/usage.ts create mode 100644 apps/web/src/usage/usageFormat.ts create mode 100644 apps/web/src/usage/usageMerge.test.ts create mode 100644 apps/web/src/usage/usageMerge.ts create mode 100644 packages/contracts/src/usage.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..4ad28691a4f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -45,6 +45,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ddb01e09dd..d982c2e192c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -149,6 +149,7 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -825,6 +826,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..ff21c07a861 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -101,6 +101,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -158,6 +159,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); +const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); + const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -410,6 +413,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), + Layer.provideMerge(UsageLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 00000000000..2ad2a729ecb --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,420 @@ +/** + * UsageService - scans provider transcripts and returns priced daily usage. + * + * The scan reads the provider CLIs' own session files rather than T3 Code's + * orchestration projections, so usage covers turns driven outside T3 Code too. + * This is the approach `ccusage` takes. + * + * Transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm + * scans only reparse files that changed. + * + * @module UsageService + */ +import * as NodeOS from "node:os"; + +import { + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSource, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { UsageAggregator } from "./usageAggregation.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; + +/** Longest window the UI offers, plus slack. Older entries are pruned. */ +const CACHE_RETENTION_DAYS = 90; + +/** On-disk shape of the rate snapshot. */ +const RatesCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + document: Schema.Unknown, +}); +const decodeRatesCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); +const encodeRatesCache = Schema.encodeEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); + +/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ +const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); +const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); +const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); + +export class UsageService extends Context.Service< + UsageService, + { + readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + } +>()("t3/usage/UsageService") {} + +/** Empty summary, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), +); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + + const fileCache: ScanCache = new Map(); + let cacheDirty = false; + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. + */ + const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const now = yield* Clock.currentTimeMillis; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap((raw) => decodeRatesCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(10_000), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + }); + + /** + * Claude's config dir is the home itself when overridden, but a default + * install nests transcripts under `~/.claude/projects`. Probe both. + */ + const resolveClaudeTranscriptDir = (homePath: string) => + Effect.gen(function* () { + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); + }); + + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + // A settings failure must surface as an error: swallowing it here would + // present "zero usage from every provider" as a valid answer. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + // Bounded description; the squashed failure travels as the cause. + // Squashed, not the Cause tree: a full tree in a Defect field is + // the unbounded wire payload the bounded detail exists to avoid. + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + + return [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + ]; + }); + + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + }), + ); + + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { + if (!cacheDirty) return; + // Cleared only after the write lands, so a failed persist is retried on + // the next scan instead of leaving disk permanently stale. + yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), + Effect.map(() => { + cacheDirty = false; + }), + // A cache we cannot write is a slower next start, not a failed read. + Effect.catchCause(() => Effect.void), + ); + }); + + /** Parses one transcript, reusing the cached result when it is unchanged. */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ): Effect.Effect => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached.records; + } + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return []; + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. + const records = dedupeWithinFile(parsed); + + fileCache.set(filePath, { size, mtimeMs, provider, records }); + cacheDirty = true; + return records; + }); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rates, + }); + + const sources: UsageSource[] = []; + const livePaths = new Set(); + const walkedRoots: string[] = []; + + for (const { provider, dir } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + + if (!exists) { + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "No transcript directory on this environment.", + }); + continue; + } + + walkedRoots.push(dir); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + let scannedFiles = 0; + let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); + + for (const file of files) { + livePaths.add(file.path); + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + for (const record of records) { + // Only sessions that contributed in-window count: the mtime slack + // admits boundary files whose records fall outside the range. + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + } + + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "ok", + scannedFiles, + skippedFiles, + malformedRecords: 0, + distinctSessions: sessionIds.size, + message: null, + }); + } + + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: { + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + return { readSummary } as const; +}); + +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts new file mode 100644 index 00000000000..9117e216f12 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + // 2026-08-07T04:05Z is still Aug 6 in Los Angeles. + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + it("keeps only the first record for a repeated dedupe key", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + ]); + + expect(result.duplicatesDropped).toBe(2); + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("still sums records that carry no dedupe key", () => { + const result = aggregate([record(), record()]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.buckets[0]?.totals.outputTokens).toBe(100); + }); + + it("buckets by the day in the requested time zone", () => { + const utc = aggregate([record()], "UTC"); + const losAngeles = aggregate([record()], "America/Los_Angeles"); + + expect(utc.buckets[0]?.day).toBe("2026-08-07"); + expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); + }); + + it("prices against the rate table", () => { + const result = aggregate([record()]); + + // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); + expect(result.buckets[0]?.costSource).toBe("modelPriced"); + }); + + it("counts tokens but not cost for a model with no rate", () => { + const result = aggregate([record({ model: "kimi-k3" })]); + + expect(result.buckets[0]?.costUsd).toBe(0); + expect(result.buckets[0]?.costSource).toBe("unpriced"); + expect(result.buckets[0]?.unpricedRecords).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("prefers a reported cost over the rate table", () => { + const result = aggregate([record({ reportedCostUsd: 1.25 })]); + + expect(result.buckets[0]?.costUsd).toBe(1.25); + expect(result.buckets[0]?.costSource).toBe("providerReported"); + }); + + it("drops records outside the window", () => { + const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); + + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); + }); + + it("reports whether a record contributed", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); + }); + + it("separates providers and models into their own buckets", () => { + const result = aggregate([ + record(), + record({ provider: "codex", model: "gpt-5.6-sol" }), + record({ model: "claude-opus-5" }), + ]); + + expect(result.buckets).toHaveLength(3); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f04a318c529c8498a200c3f21a5caed2330ee3e GIT binary patch literal 6019 zcmb_gZEqX75x&p<6;tJaN~yd`+i%L*h|lf;M(ZZ99TeBVsOVivT6ncoNp5sHpZ|NG z8It=V`K}+@d`Kd4b~rE3%sk|5Mj!NYsdIl?lxkHQ=gZvDsxptNx>8=x&2o7%d;9i; z-qH`IDjaQe#xPVEVrt#+pRs!3s?s!sFDx;i%(9fpUQ>+(Mdh+T?JAAv~BNrLnua)QHc55idkyh>E9xKaD z?YwPrUz(bwaG=@2>$SG}J=Mmq%X)=_@hNuUVgoy^R)_*C6WfxfpBf7@oROUn1^$@r zD)ed5Y^yR?a*%}o@NNoFmH9fM z3*ZAr8ZH`!50Higa?Vu1t#f+wSTQ6xu?%Pla{wx>wD;ON8UqIo;D-YI$DacIs**yP zuwAng{gdQ$+0+5G8vq`843z~H5Y#z0p(AjyEpyv~! zN_$#LdCuvTntcxukBVbaih^`TwcfoZJswHyM$;&dZ)0?)@;%XBD?6tVY|+TvC@pUQ zwKdlNgg&(@eY=iu9j(4h*r*qv#v=>Cu-S|*YHI4I+U#oS4CGK)71!X(ssbfv==1$$ zKRdKkWhF`3tqtni<*G)k|0g$LEKX_7|NnUlyR7z&tp`b!WQOcz;h!;QhI?Q?_Y*|y z1tnYqCrg!Ux@n~Sgr*=N^wI<7M_qwKodb|4%Y-0V8dhw7D~^l)Qd3Jo7ojh-P-I1E zklbp9w|-5;Y3I(*VO-my{)=h1+BKCQS{8@ryfa6dU&zmw2R0N#nqvH@ux7hOzj@TT zLgV2+hq9_lw4D%r)P}VsB)H``wL#y}m_(YM^giQm(sMjbDDAF-+J5Hxr=m)57AlgK zx+gGPe`cV%6e-QgLK{kZ&xY<1d!$jCe{^1RtJ6~{3;fNq^z<1l+w zjq`XKruKX_9IEKOfzG5a*yXOEr##JRi^#B&IbBw&fzg*>ErAGZOW_UZlRPGW-x+{< z+9?Z<)*946+D?C^rSJcoJo^gqP+;={;S?E&|uNfO3Wh-(xZ?!5GQKrXfqcJ{r0n&k$e@bem{53gN;aobWU)6#Sc4JoWT%Iaump9(+;q%4qQ(1cWBWu2iOxg#^l)H3ry1wfY? zu*p3ek1_n%peTeLVw_hP^lT3JCn^?<>M(YPqV(lO-|y0Z z4`=kpkWtQR9EeMhSDjX0#H^%2PV0Q>Y?KUxL~xEH(4@1SMH~rKl8qd;$fDwR><>4> z?}o6;(h#9vei3!&2m5J`biPK|cKwn1J|25Guo&GOa9(`-3|n5l`Sv+vEK4)BwOM(4GOFqlAmmB<3ee zE4T3pqpPjoP;&^OPTk?o>uO^zBg(FetkU($uj!oLwT2}KX1(Ureh@-E_{pJ2A<9 zFOM2uMoDD!(0w*2rLHSQ;0}wb*On5n`*kHFVu*CiK{-1+fO~g7mrDRAvwC^j;$jU; zu@EPncm#T@H~#Jf4xro$9;|mCB#5<=X#rE}XPxB2Wtt};dx>lGU3i$17Y}~4c+z^y zuY&u^@bgzuE|>BS!|z+JR$E-N@zR9(2(AuZfKQf96|T`CL@sl<*`*#hRsb8b$2Pwz zL;f>)@6eZKgC`a}e&n?Yrf5$pCHY7+Z5+r80FH!ZHO3vd6#cQ#5+I}5DMCjqWFAZ8 zXd`{mXwuCFo?ks32|NdU;Wu?1elud7=^)9|Cw{*yu>NxLAN~=u9smFU literal 0 HcmV?d00001 diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 00000000000..f0e59a87439 --- /dev/null +++ b/apps/server/src/usage/usagePricing.ts @@ -0,0 +1,148 @@ +/** + * Model rate lookup and cost arithmetic. + * + * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same + * table `ccusage` prices against. Everything here is pure: fetching and caching + * the table lives in `UsageService`. + * + * @module usagePricing + */ +import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; + +/** + * The subset of a LiteLLM entry we price against. All values are USD per token. + * + * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, + * `*_priority`, `*_batches`). We deliberately price at the base tier: the + * transcripts don't record which tier served a request, so anything else would + * be a guess dressed up as precision. + */ +export interface ModelRate { + readonly inputCostPerToken: number; + readonly outputCostPerToken: number; + readonly cacheReadCostPerToken: number; + readonly cacheCreationCostPerToken: number; +} + +export type RateTable = ReadonlyMap; + +/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ +interface LiteLlmEntry { + readonly input_cost_per_token?: unknown; + readonly output_cost_per_token?: unknown; + readonly cache_read_input_token_cost?: unknown; + readonly cache_creation_input_token_cost?: unknown; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Projects the LiteLLM document into a rate table. + * + * Entries without both an input and an output rate are dropped: a half-priced + * model would silently under-report cost, which is worse than reporting the + * model as unpriced. + */ +export function parseRateTable(document: unknown): RateTable { + const table = new Map(); + if (typeof document !== "object" || document === null) return table; + + for (const [name, raw] of Object.entries(document as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as LiteLlmEntry; + const input = finiteNumber(entry.input_cost_per_token); + const output = finiteNumber(entry.output_cost_per_token); + if (input === null || output === null) continue; + + table.set(normalizeModelName(name), { + inputCostPerToken: input, + outputCostPerToken: output, + // Anthropic bills cache reads at a discount and cache writes at a + // premium. When a model omits them, cached input is priced as plain + // input rather than as free. + cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, + cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + }); + } + return table; +} + +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and + * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent + * about casing. + */ +export function normalizeModelName(model: string): string { + const trimmed = model.trim().toLowerCase(); + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +/** + * Models we never price, regardless of the table. + * + * `` marks locally generated messages that were never billed. Bare + * family names ("opus", "sonnet") are genuinely ambiguous across generations, + * so we report them as unpriced instead of guessing a generation. + */ +const UNPRICEABLE_MODELS = new Set([ + "", + "synthetic", + "opus", + "sonnet", + "haiku", + "fable", +]); + +export function lookupRate(table: RateTable, model: string): ModelRate | null { + const normalized = normalizeModelName(model); + if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; + return table.get(normalized) ?? null; +} + +export interface PricedUsage { + readonly costUsd: number; + readonly costSource: UsageCostSource; +} + +/** + * Prices a bucket's tokens. + * + * `reasoningTokens` is intentionally not charged separately: it is already + * counted inside `outputTokens`. + */ +export function priceUsage( + table: RateTable, + model: string, + totals: UsageTokenTotals, + reportedCostUsd: number | null, +): PricedUsage { + if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + return { costUsd: reportedCostUsd, costSource: "providerReported" }; + } + + const rate = lookupRate(table, model); + if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + + const costUsd = + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.cachedInputTokens * rate.cacheReadCostPerToken + + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + totals.outputTokens * rate.outputCostPerToken; + + return { costUsd, costSource: "modelPriced" }; +} + +/** + * What the cached input would have cost at full input rates, minus what it + * actually cost. Drives the "cache savings" figure. + */ +export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts new file mode 100644 index 00000000000..64673e96c09 --- /dev/null +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: 1_786_000_000_000, + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: "msg_1:", + ...overrides, + }; +} + +function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { + const cache: ScanCache = new Map(); + for (const [path, mtimeMs, records] of entries) { + cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + } + return cache; +} + +describe("scan cache round trip", () => { + it("restores records unchanged", () => { + const original = cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], + ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.size).toBe(2); + expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); + expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + }); + + it("interns repeated model and session strings", () => { + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), + ); + + expect(encoded.models).toEqual(["claude-fable-5"]); + expect(encoded.sessions).toEqual(["session-a"]); + }); + + it("treats a corrupt or foreign document as an empty cache", () => { + // A bad cache should cost one cold scan, never a broken page. + expect(decodeScanCache(null).size).toBe(0); + expect(decodeScanCache("nonsense").size).toBe(0); + expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + }); + + it("skips malformed file entries but keeps good ones", () => { + const encoded = encodeScanCache(cacheWith([["/good.jsonl", 100, [record()]]])); + const withJunk = { + ...encoded, + files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); + expect([...restored.keys()]).toEqual(["/good.jsonl"]); + }); + + it("rejects the whole cache when an intern table holds a non-string", () => { + // models: [1] would pass the undefined guard, put a number in a record's + // model, and crash normalizeModelName at aggregate time. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { ...encoded, models: [1] }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + }); + + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { + // Keeping the surviving rows under the original (size, mtime) would read + // as a valid warm hit and the file would never be re-parsed. + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), + ); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect(restored.has("/a.jsonl")).toBe(false); + }); +}); + +describe("pruneScanCache", () => { + const retentionCutoffMs = 1000; + + it("drops entries older than retention", () => { + const cache = cacheWith([["/old.jsonl", 500, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 400, + retentionCutoffMs, + }); + + expect(removed).toBe(1); + expect(cache.size).toBe(0); + }); + + it("drops in-window entries whose file has disappeared", () => { + const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(0); + }); + + it("keeps entries outside the walked window that are still within retention", () => { + // Viewing 7 days must not evict the 30-day entries, which that walk never + // looked for and so cannot prove are gone. + const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); + + it("keeps entries the walk saw", () => { + const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(["/live.jsonl"]), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(1); + }); +}); + +describe("pruneScanCache with an unwalked root", () => { + it("keeps in-window entries for a provider whose directory was not walked", () => { + // A missing provider root or failed settings read leaves livePaths without + // that provider's files. Its warm entries must survive the pass. + const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); +}); + +describe("dedupeWithinFile", () => { + it("keeps the first record per dedupe key", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ totals: { ...record().totals, outputTokens: 999 } }), + record({ dedupeKey: "msg_2:" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.totals.outputTokens).toBe(1); + }); + + it("keeps every record that has no dedupe key", () => { + expect( + dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), + ).toHaveLength(2); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts new file mode 100644 index 00000000000..0dafa7a6daf --- /dev/null +++ b/apps/server/src/usage/usageScanCache.ts @@ -0,0 +1,253 @@ +/** + * Durable per-file scan cache. + * + * Transcripts are append-only and a file that has not changed can never yield + * different usage, so parsed records are keyed by `(size, mtime)` and reused. + * Without this every server restart re-parses the whole window: roughly 3.5s + * for a 30-day scan here, against ~11ms to reload this cache. + * + * Caching *per file* rather than per day is deliberate. It is timezone + * independent, so changing the reporting zone does not invalidate anything, and + * it keeps cross-file de-duplication exact: cached entries are de-duplicated + * within their own file only, and the aggregator still applies the global + * dedupe pass over the small surviving key set. + * + * @module usageScanCache + */ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +export const USAGE_SCAN_CACHE_VERSION = 1 as const; + +export interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly records: readonly UsageRecord[]; +} + +export type ScanCache = Map; + +/** + * Row layout for the serialised form. Positional and interned rather than + * object-per-record: on a 30-day window that is the difference between a file + * measured in tens of megabytes and one under six. + */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reasoningTokens: number, + dedupeKey: string | null, + reportedCostUsd: number | null, +]; + +interface SerializedFile { + readonly s: number; + readonly m: number; + readonly p: UsageProviderKind; + readonly r: readonly SerializedRecord[]; +} + +interface SerializedCache { + readonly version: number; + readonly models: readonly string[]; + readonly sessions: readonly string[]; + readonly files: Readonly>; +} + +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const models: string[] = []; + const sessions: string[] = []; + const modelIndex = new Map(); + const sessionIndex = new Map(); + + const intern = (table: string[], index: Map, value: string): number => { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; + }; + + const files: Record = {}; + for (const [path, entry] of cache) { + files[path] = { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map((record) => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]), + }; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +} + +function isRecordArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +/** + * Rebuilds the cache from a parsed document. + * + * Anything malformed yields an empty cache rather than an error: a corrupt + * cache should cost one cold scan, never a broken page. + */ +export function decodeScanCache(document: unknown): ScanCache { + const cache: ScanCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + + const root = document as Partial; + if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (typeof root.files !== "object" || root.files === null) return cache; + + // The intern tables must be all strings: a numeric entry would pass the + // undefined guard below, land in a record's model, and crash the aggregate + // at normalizeModelName. A corrupt table rejects the whole cache. + if (!root.models.every((value) => typeof value === "string")) return cache; + if (!root.sessions.every((value) => typeof value === "string")) return cache; + const models = root.models as readonly string[]; + const sessions = root.sessions as readonly string[]; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; + if (!isRecordArray(entry.r)) continue; + + const provider: UsageProviderKind = entry.p; + const records: UsageRecord[] = []; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + let corrupt = false; + for (const row of entry.r) { + if (!isRecordArray(row) || row.length < 10) { + corrupt = true; + break; + } + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + cacheCreation, + output, + reasoning, + dedupeKey, + reportedCostUsd, + ] = row as SerializedRecord; + + const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + model === undefined || + !Number.isFinite(uncached) || + !Number.isFinite(cached) || + !Number.isFinite(cacheCreation) || + !Number.isFinite(output) || + !Number.isFinite(reasoning) + ) { + corrupt = true; + break; + } + + records.push({ + provider, + timestampMs, + model, + sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning, + }, + reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + }); + } + + if (corrupt) continue; + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + } + + return cache; +} + +export interface PruneOptions { + /** Files the walk just saw. Only meaningful inside the walked window. */ + readonly livePaths: ReadonlySet; + /** + * Roots the walk actually completed. Absence from `livePaths` only proves a + * file is gone when its root was walked: a provider whose directory failed to + * resolve this pass must not have its warm entries purged. + */ + readonly walkedRoots: readonly string[]; + /** Start of the walked window; entries older than this were not looked for. */ + readonly windowStartMs: number; + /** Entries older than this are dropped regardless. */ + readonly retentionCutoffMs: number; +} + +/** + * Drops aged-out entries, and entries for files that have disappeared. + * + * The walk only covers the requested window, so absence from `livePaths` only + * proves deletion for entries *inside* that window. Pruning everything the walk + * missed would evict the 30-day entries every time someone looked at 7 days. + * + * Replaces an earlier record cap that cleared the whole cache once exceeded, + * which meant a large enough window never warmed up at all. + */ +export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { + let removed = 0; + for (const [path, entry] of cache) { + const agedOut = entry.mtimeMs < options.retentionCutoffMs; + const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const deleted = + underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + if (agedOut || deleted) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + +/** Within-file de-duplication, applied before an entry is cached. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const seen = new Set(); + const kept: UsageRecord[] = []; + for (const record of records) { + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) continue; + seen.add(record.dedupeKey); + } + kept.push(record); + } + return kept; +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts new file mode 100644 index 00000000000..c72f0c24db6 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -0,0 +1,141 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Raw filesystem access for transcript scanning. + * + * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. + * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB + * across ~1,500 files, and `readline` over a read stream is roughly an order of + * magnitude cheaper than materialising each file. The equivalent Effect stream + * pipeline is idiomatic but not fast enough to sit behind a page load. + * + * @module usageTranscriptReader + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { + initialCodexScanState, + mightCarryUsage, + parseClaudeLine, + parseCodexLine, + type UsageRecord, +} from "./usageTranscripts.ts"; + +export interface TranscriptFile { + readonly path: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. + * + * Errors on individual entries are swallowed: session files rotate and get + * removed while the walk is in flight, and a partial listing is far better than + * failing the page. + */ +export async function listTranscriptFiles( + root: string, + sinceMs: number, +): Promise { + const found: TranscriptFile[] = []; + + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = NodePath.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".jsonl")) continue; + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Vanished between readdir and stat. + } + } + }; + + await walk(root); + return found; +} + +/** + * Filesystem identity of a directory, as `device:inode`. + * + * Used to tell "two servers reading the same transcript directory" apart from + * "two machines whose hostname and home path happen to match". Returns an empty + * string when the directory cannot be stat'd. + */ +export async function readDirectoryVolumeId(path: string): Promise { + try { + const stats = await NodeFSP.stat(path); + return `${stats.dev}:${stats.ino}`; + } catch { + return ""; + } +} + +/** + * Streams one transcript and returns the usage records it contains, or `null` + * when the file could not be read. + * + * The distinction matters to the caller's cache: a genuinely empty transcript + * is a stable fact worth memoising, while a transient read failure memoised + * under the same `(size, mtime)` key would silently drop that file's usage + * until the file next changes. + * + * Codex carries the active model on `turn_context` lines that hold no usage of + * their own, so those still have to pass through the reducer to keep model + * attribution correct. + */ +export async function readTranscriptRecords( + filePath: string, + provider: UsageProviderKind, +): Promise { + const records: UsageRecord[] = []; + const codexState = initialCodexScanState(); + + try { + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of lines) { + if (provider === "codex") { + if ( + !mightCarryUsage(line, provider) && + !line.includes('"turn_context"') && + !line.includes('"session_meta"') + ) { + continue; + } + const record = parseCodexLine(line, codexState); + if (record !== null) records.push(record); + continue; + } + + if (!mightCarryUsage(line, provider)) continue; + const record = parseClaudeLine(line); + if (record !== null) records.push(record); + } + } catch { + return null; + } + + return records; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts new file mode 100644 index 00000000000..1fec9d28d9b --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + initialCodexScanState, + parseClaudeLine, + parseCodexLine, + totalTokens, +} from "./usageTranscripts.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + model?: string; + outputTokens?: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + cwd: "/home/theo/project", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 66818, + cache_read_input_tokens: 1000, + output_tokens: overrides.outputTokens ?? 286, + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record).not.toBeNull(); + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 66818, + outputTokens: 286, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // T3 Code writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount ~2.4x on real data. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.289Z", + payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + }); + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { type: "turn_context", model: "gpt-5.6-sol" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(116); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("drops usage that arrives before any model is known", () => { + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); +}); + +describe("totalTokens", () => { + it("does not add reasoning on top of output", () => { + expect( + totalTokens({ + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 25, + }), + ).toBe(100); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts new file mode 100644 index 00000000000..338713d8b1b --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.ts @@ -0,0 +1,246 @@ +/** + * Pure parsers for the provider CLIs' on-disk session transcripts. + * + * Both parsers are line-at-a-time reducers so callers can stream large files + * without materialising them. Neither touches the filesystem. + * + * @module usageTranscripts + */ +import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; + +export interface UsageRecord { + readonly provider: UsageProviderKind; + readonly timestampMs: number; + readonly model: string; + readonly sessionId: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; + /** + * Key for cross-file de-duplication, or `null` when the record is inherently + * unique and needs no dedup. + */ + readonly dedupeKey: string | null; +} + +const EMPTY_TOTALS: UsageTokenTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, +}; + +function int(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + return { + uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, + cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, + cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + outputTokens: a.outputTokens + b.outputTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + }; +} + +export function totalTokens(totals: UsageTokenTotals): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} + +/** + * Cheap substring gate applied before `JSON.parse`. + * + * Transcripts are mostly tool output; only a minority of lines carry usage. On + * a 30-day window this skips roughly half the lines outright and is worth about + * an order of magnitude. + */ +export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { + return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); +} + +/* -------------------------------------------------------------------------- */ +/* Claude Code */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line of a Claude Code transcript. + * + * T3 Code writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts by roughly 2.4x on a real workload, so the + * caller must drop repeats by `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: int(usageRecord["input_tokens"]), + cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), + cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), + outputTokens: int(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Codex */ +/* -------------------------------------------------------------------------- */ + +/** + * Rolling state for a single Codex rollout file. + * + * Codex `token_count` events carry no model, so the model is carried forward + * from the most recent `turn_context`. Sessions that switch models mid-run + * attribute correctly from the switch onward. + */ +export interface CodexScanState { + model: string; + sessionId: string; + lastUsageSignature: string | null; +} + +export function initialCodexScanState(): CodexScanState { + return { model: "", sessionId: "", lastUsageSignature: null }; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided + * consecutive duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + return null; + } + + if (record["type"] === "turn_context") { + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + const inputTokens = int(lastRecord["input_tokens"]); + const cachedInputTokens = int(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); + const outputTokens = int(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + // Rollout files are unique per session, so events need no global dedup. + dedupeKey: null, + }; +} + +export { EMPTY_TOTALS }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6b155c296f..6d518fe16cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -412,6 +413,7 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1529,6 +1531,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetUsageSummary]: (input) => + observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index df06c431fd2..a8d3ef41416 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { SettingsIcon } from "lucide-react"; +import { ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -118,11 +118,24 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { void navigate({ to: "/settings" }); }, [isMobile, navigate, setOpenMobile]); + const handleUsageClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/usage" }); + }, [isMobile, navigate, setOpenMobile]); + return ( + + + + Usage + + diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx new file mode 100644 index 00000000000..2f3ab4b574c --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -0,0 +1,454 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { RefreshCwIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { useUsage } from "../../state/usage"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "../../usage/usageFormat"; +import { ScrollArea } from "../ui/scroll-area"; +import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +export function UsagePage() { + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("cost"); + const [breakdown, setBreakdown] = useState<"model" | "day">("model"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]); + + // Ranked by whatever the toggle is showing, so the bars always descend. + const orderedProviders = useMemo( + () => + merged.providers.toSorted((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ), + [merged.providers, metric], + ); + + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + +
    +
    +
    +

    Usage

    +

    + {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} +

    +
    +
    +
    + {WINDOW_OPTIONS.map((option) => ( + + ))} +
    + +
    +
    + + + + {isPending ? ( +

    + Scanning provider transcripts… +

    + ) : ( + <> + {/* Cost first: the financial answer, then the provider split. */} +
    + {/* The summary follows the chart toggle, so the headline and the + series are always reading the same units. */} +
    +
    + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" + ? `${formatUsd(merged.costUsd)}*` + : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + +
    + + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( +
    +
    + + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + +
    +
    +
    +
    + + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + +
    + ); + })} +
    + +
    +
    +

    + Daily {metric === "tokens" ? "processed tokens" : "cost"} +

    +
    +
    + {(["cost", "tokens"] as const).map((option) => ( + + ))} +
    + +
    +
    + +
    +
    + +
    + + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + /> +
    + +
    +
    +
    +

    Breakdown

    +
    + {(["model", "day"] as const).map((option) => ( + + ))} +
    +
    + + {breakdown === "model" ? ( + + + + + + + + + + + {merged.models.length === 0 ? ( + + + + ) : ( + merged.models.map((model) => ( + + + + + + + )) + )} + +
    ModelCostShareTokens
    + No activity in this window. +
    + + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
    + ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + + + ) : ( + recentDays.map((day) => ( + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + )) + )} + +
    Day + {PROVIDER_LABEL[provider]} + TotalTokens
    + No activity in this window. +
    {formatDayShort(day.day)} + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)} +
    + )} +
    + +
    +

    Cost quality

    +
    + + + + +
    +
    +
    + + )} +
    +
    + ); +} + +/** Brand mark for the harness a row belongs to. */ +function ProviderMark({ + provider, + className, +}: { + readonly provider: UsageProviderKind; + readonly className: string; +}) { + const Mark = PROVIDER_MARK[provider]; + return ; +} + +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( +
    + {label} + {value} + {detail} +
    + ); +} + +function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice({ + environments, + duplicateSources, + staleEnvironments, + isPartial, +}: { + readonly environments: readonly { + environmentId: string; + label: string; + error: string | null; + isPending: boolean; + }[]; + readonly duplicateSources: readonly string[]; + readonly staleEnvironments: readonly string[]; + readonly isPartial: boolean; +}) { + const failed = environments.filter((environment) => environment.error !== null); + const stale = environments.filter((environment) => + staleEnvironments.includes(environment.environmentId), + ); + if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) { + return null; + } + + return ( +
    + {isPartial ? Some environments are still reporting. Totals are partial. : null} + {failed.map((environment) => ( + {environment.label} could not report usage. + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} +
    + ); +} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts new file mode 100644 index 00000000000..2b647153f20 --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildDayColumns, niceScale } from "./UsageProviderChart"; + +describe("niceScale", () => { + it("never puts the peak above the top of the scale", () => { + // Regression: an earlier version stopped at the last step below the peak, + // so the tallest day was drawn past the plot and clipped. + for (const peak of [1122.71, 999, 1, 0.04, 1_400_000_000, 37.5, 5000, 100.001]) { + const { max } = niceScale(peak, 4); + expect(max, `peak ${peak}`).toBeGreaterThanOrEqual(peak); + } + }); + + it("starts at zero and ends at the maximum", () => { + const { max, ticks } = niceScale(1122.71, 4); + + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBeCloseTo(max, 6); + }); + + it("uses evenly spaced 1/2/5 steps", () => { + const { ticks } = niceScale(1122.71, 4); + const steps = ticks.slice(1).map((tick, index) => tick - (ticks[index] ?? 0)); + + for (const step of steps) expect(step).toBeCloseTo(steps[0] ?? 0, 6); + const [first = 0] = steps; + const normalized = first / 10 ** Math.floor(Math.log10(first)); + expect([1, 2, 5, 10]).toContain(Math.round(normalized)); + }); + + it("keeps the tick count near the requested resolution", () => { + const { ticks } = niceScale(1122.71, 4); + expect(ticks.length).toBeGreaterThanOrEqual(3); + expect(ticks.length).toBeLessThanOrEqual(7); + }); + + it("degrades to a single zero tick with no data", () => { + expect(niceScale(0, 4)).toEqual({ max: 0, ticks: [0] }); + }); +}); + +describe("buildDayColumns", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03"]; + const byDay = new Map([ + [ + "2026-08-01", + { + day: "2026-08-01", + costUsd: 30, + totalTokens: 300, + byProvider: new Map([ + ["codex" as const, { costUsd: 10, totalTokens: 100 }], + ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ]), + }, + ], + // 2026-08-02 is deliberately absent: a day with no activity. + [ + "2026-08-03", + { + day: "2026-08-03", + costUsd: 5, + totalTokens: 50, + byProvider: new Map([["claude" as const, { costUsd: 5, totalTokens: 50 }]]), + }, + ], + ]); + + it("plots each day on its own", () => { + expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + }); + + it("reads the requested metric", () => { + expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ + 300, 0, 50, + ]); + }); + + it("keeps the bands contiguous so the areas stay additive", () => { + for (const column of buildDayColumns(days, byDay, "cost")) { + let expectedBase = 0; + for (const band of column.bands) { + expect(band.base).toBeCloseTo(expectedBase, 9); + expect(band.top).toBeCloseTo(band.base + band.value, 9); + expectedBase = band.top; + } + expect(column.total).toBeCloseTo(expectedBase, 9); + } + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx new file mode 100644 index 00000000000..d1ffce25e65 --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -0,0 +1,411 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import type { DailyTotals } from "../../usage/usageMerge"; +import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; + +const VIEW_WIDTH = 960; +const VIEW_HEIGHT = 260; +const TICK_COUNT = 4; +const PLOT_TOP = 8; + +export type UsageChartMetric = "tokens" | "cost"; + +interface UsageProviderChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; +} + +/** One day's stacked bands, shared by the paths and the hover readout. */ +export interface DayColumn { + readonly bands: readonly { + readonly provider: UsageProviderKind; + readonly value: number; + readonly base: number; + readonly top: number; + }[]; + readonly total: number; +} + +interface Point { + readonly x: number; + readonly y: number; +} + +function valueFor( + daily: DailyTotals | undefined, + provider: UsageProviderKind, + metric: UsageChartMetric, +): number { + const entry = daily?.byProvider.get(provider); + if (entry === undefined) return 0; + return metric === "tokens" ? entry.totalTokens : entry.costUsd; +} + +/** + * Monotone cubic tangents (Fritsch-Carlson). + * + * Plain cubic smoothing overshoots on spiky daily data and would dip the area + * below zero between points, which reads as negative spend. This variant is + * shape-preserving, so a smoothed series never leaves the range of its samples. + */ +function monotoneTangents(points: readonly Point[]): readonly number[] { + const count = points.length; + if (count < 2) return [0]; + + const slopes: number[] = []; + for (let index = 0; index < count - 1; index += 1) { + const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0); + const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0); + slopes.push(dx === 0 ? 0 : dy / dx); + } + + const tangents: number[] = Array.from({ length: count }, () => 0); + tangents[0] = slopes[0] ?? 0; + tangents[count - 1] = slopes[count - 2] ?? 0; + for (let index = 1; index < count - 1; index += 1) { + const previous = slopes[index - 1] ?? 0; + const next = slopes[index] ?? 0; + tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2; + } + + for (let index = 0; index < count - 1; index += 1) { + const slope = slopes[index] ?? 0; + if (slope === 0) { + tangents[index] = 0; + tangents[index + 1] = 0; + continue; + } + const a = (tangents[index] ?? 0) / slope; + const b = (tangents[index + 1] ?? 0) / slope; + const magnitude = a * a + b * b; + if (magnitude > 9) { + const scale = 3 / Math.sqrt(magnitude); + tangents[index] = scale * a * slope; + tangents[index + 1] = scale * b * slope; + } + } + + return tangents; +} + +/** One cubic segment of a smoothed boundary. */ +interface CurveSegment { + readonly from: Point; + readonly c1: Point; + readonly c2: Point; + readonly to: Point; +} + +/** Smoothed polyline through `points`, as explicit cubic control points. */ +function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { + if (points.length < 2) return []; + const tangents = monotoneTangents(points); + const segments: CurveSegment[] = []; + + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]; + const to = points[index + 1]; + if (from === undefined || to === undefined) continue; + const dx = to.x - from.x; + segments.push({ + from, + c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 }, + c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 }, + to, + }); + } + return segments; +} + +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const first = segments[0]; + if (first === undefined) return ""; + let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + for (const segment of segments) { + path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; + } + return path; +} + +/** + * The same curve walked end to start. A cubic reverses exactly by swapping its + * control points, so this traces the identical geometry. + * + * Bands must use this rather than re-smoothing their base points in reverse: + * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is + * not perfectly symmetric under reversal, and independently smoothed edges of + * adjacent bands could hairline-gap or overlap. Sharing one curve per stack + * boundary makes that geometrically impossible. + */ +function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const last = segments[segments.length - 1]; + if (last === undefined) return ""; + let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (segment === undefined) continue; + path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`; + } + return path; +} + +/** + * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the + * peak. + * + * Rounding the maximum *up* is the point: stopping at the last step below the + * peak leaves the tallest day drawn past the top of the plot, where it is + * clipped. + */ +export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } { + if (peak <= 0) return { max: 0, ticks: [0] }; + + const rawStep = peak / count; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude; + + const max = Math.ceil(peak / step) * step; + const ticks: number[] = []; + for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value); + return { max, ticks }; +} + +/** + * Turns the merged daily totals into stacked bands, one column per day. + * + * The chart paths and the hover readout both consume this, so the number under + * the cursor is by construction the number that was plotted rather than a + * second derivation that can drift from it. + */ +export function buildDayColumns( + days: readonly string[], + byDay: ReadonlyMap, + metric: UsageChartMetric, +): readonly DayColumn[] { + return days.map((day) => { + const entry = byDay.get(day); + let stackTop = 0; + const bands = PROVIDER_ORDER.map((provider) => { + const value = valueFor(entry, provider, metric); + const base = stackTop; + stackTop += value; + return { provider, value, base, top: stackTop }; + }); + return { bands, total: stackTop }; + }); +} + +export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { + const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]); + const [hoverIndex, setHoverIndex] = useState(null); + const plotRef = useRef(null); + + const { paths, ticks, stepX, toY, series } = useMemo(() => { + if (days.length === 0) { + return { + paths: [], + ticks: [0] as readonly number[], + stepX: 0, + toY: () => VIEW_HEIGHT, + series: [] as readonly DayColumn[], + }; + } + + const stacked = buildDayColumns(days, byDay, metric); + + const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0); + const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); + const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + // Reserve a sliver above the top gridline so the series stroke, which is + // drawn at constant screen width, is not shaved off at a peak. + const toY = (value: number) => + max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); + + // One smoothed curve per stack boundary (baseline, then each provider's + // cumulative top). Band k is the region between boundary k and k+1, both + // drawn from these shared control points. + const boundaries = [ + stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), + ...PROVIDER_ORDER.map((_, providerIndex) => + stacked.map((column, dayIndex) => ({ + x: dayIndex * step, + y: toY(column.bands[providerIndex]?.top ?? 0), + })), + ), + ].map(smoothCurve); + + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const top = boundaries[providerIndex + 1] ?? []; + const base = boundaries[providerIndex] ?? []; + return { + provider, + area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, + line: curvePath(top, "M"), + }; + }); + + return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked }; + }, [byDay, days, metric]); + + const format = metric === "tokens" ? formatTokens : formatUsd; + + const handleMove = useCallback( + (event: React.MouseEvent) => { + const bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || days.length === 0) return; + const fraction = (event.clientX - bounds.left) / bounds.width; + const index = Math.round(fraction * (days.length - 1)); + setHoverIndex(Math.min(days.length - 1, Math.max(0, index))); + }, + [days.length], + ); + + const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex]; + const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; + const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100; + + return ( +
    +
    + {/* Axis labels sit outside the plot so they stay aligned to gridlines. */} +
    + {ticks.map((tick) => ( + + {tick === 0 ? "0" : format(tick)} + + ))} +
    + +
    setHoverIndex(null)} + > + + {ticks.map((tick) => { + const y = toY(tick); + return ( + + ); + })} + + {paths.map(({ provider, area, line }) => ( + + + + + ))} + + {hoverIndex === null ? null : ( + + )} + + + {hoveredDay === undefined ? null : ( +
    60 ? "translateX(-100%)" : "translateX(0)", + }} + > +
    {formatDayShort(hoveredDay)}
    + {PROVIDER_ORDER.map((provider) => { + const Mark = PROVIDER_MARK[provider]; + return ( +
    + + + {PROVIDER_LABEL[provider]} + + + {format( + hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, + )} + +
    + ); + })} +
    + Total + + {format(hoveredColumn?.total ?? 0)} + +
    +
    + )} +
    +
    + +
    + {days[0] === undefined ? "" : formatDayShort(days[0])} + + {days[Math.floor(days.length / 2)] === undefined + ? "" + : formatDayShort(days[Math.floor(days.length / 2)] ?? "")} + + + {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")} + +
    +
    + ); +} + +export function UsageChartLegend() { + return ( +
    + {PROVIDER_ORDER.map((provider) => { + // The marks carry the same fills as the bands, so they key the chart + // just as a colour swatch would. + const Mark = PROVIDER_MARK[provider]; + return ( + + + {PROVIDER_LABEL[provider]} + + ); + })} +
    + ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts new file mode 100644 index 00000000000..5356f96edc7 --- /dev/null +++ b/apps/web/src/components/usage/usageProviders.ts @@ -0,0 +1,32 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { ClaudeAI, type Icon, OpenAI } from "../Icons"; + +/** + * Stacking and table order. Codex sits under Claude Code so the larger band + * reads as the top surface, matching the reference layout. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** Claude's brand orange against a neutral white for Codex. */ +export const PROVIDER_COLOR: Record = { + claude: "#d97757", + codex: "#e6e6e6", +}; + +/** + * Brand marks, reused from the provider picker. + * + * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), + * which are the same colours as the chart bands, so swapping a colour dot for a + * mark keeps the series association intact rather than trading it away. + */ +export const PROVIDER_MARK: Record = { + claude: ClaudeAI, + codex: OpenAI, +}; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3da96820ab9..eb31a8de91c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' @@ -26,6 +27,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -167,6 +176,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -183,6 +193,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -201,6 +212,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect_/callback' | '/settings/appearance' | '/settings/archived' @@ -220,11 +232,19 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -385,6 +405,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx new file mode 100644 index 00000000000..c617e434b2e --- /dev/null +++ b/apps/web/src/routes/usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsagePage } from "../components/usage/UsagePage"; + +export const Route = createFileRoute("/usage")({ + component: UsagePage, +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts new file mode 100644 index 00000000000..57114ade152 --- /dev/null +++ b/apps/web/src/state/usage.ts @@ -0,0 +1,126 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * @module state/usage + */ +import { useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so the button always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/apps/web/src/usage/usageFormat.ts b/apps/web/src/usage/usageFormat.ts new file mode 100644 index 00000000000..c7c21605837 --- /dev/null +++ b/apps/web/src/usage/usageFormat.ts @@ -0,0 +1,107 @@ +/** + * Display formatting for the usage page. + * + * @module usageFormat + */ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const CURRENCY = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const INTEGER = new Intl.NumberFormat("en-US"); + +export function formatUsd(value: number): string { + return CURRENCY.format(value); +} + +export function formatCount(value: number): string { + return INTEGER.format(Math.round(value)); +} + +/** + * Compacts a token count to three significant figures with a unit suffix, so + * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). + */ +export function formatTokens(value: number): string { + const abs = Math.abs(value); + if (abs >= 1e12) return `${trim(value / 1e12)}T`; + if (abs >= 1e9) return `${trim(value / 1e9)}B`; + if (abs >= 1e6) return `${trim(value / 1e6)}M`; + if (abs >= 1e3) return `${trim(value / 1e3)}K`; + return INTEGER.format(Math.round(value)); +} + +function trim(value: number): string { + const abs = Math.abs(value); + const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; + return value.toFixed(digits).replace(/\.0+$/, ""); +} + +export function formatPercent(share: number, digits = 1): string { + return `${(share * 100).toFixed(digits)}%`; +} + +/** `2026-08-07` to `Aug 7`. */ +export function formatDayShort(day: string): string { + const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); + if (year === undefined || month === undefined || dayOfMonth === undefined) return day; + const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; +} + +/** Inclusive day list between two `YYYY-MM-DD` bounds. */ +export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { + const days: string[] = []; + const start = Date.parse(`${sinceDay}T00:00:00Z`); + const end = Date.parse(`${untilDay}T00:00:00Z`); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; + + for (let cursor = start; cursor <= end; cursor += 86_400_000) { + days.push(new Date(cursor).toISOString().slice(0, 10)); + } + return days; +} + +/** + * The window the page requests, expressed in the viewer's own time zone so days + * line up with what they actually experienced. + */ +export function makeWindow(days: number, now = new Date()): UsageSummaryInput { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const untilDay = format.format(now); + // Subtracting fixed milliseconds from `now` lands on the wrong calendar day + // around a DST transition. Only "today" needs the zone; the window start is + // pure calendar arithmetic on that day, done in UTC where days are uniform. + const [year = 0, month = 1, dayOfMonth = 1] = untilDay + .split("-") + .map((part) => Number.parseInt(part, 10)); + const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); + return { + sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), + untilDay: UsageDay.make(untilDay), + timeZone, + }; +} diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts new file mode 100644 index 00000000000..7e44631cf5d --- /dev/null +++ b/apps/web/src/usage/usageMerge.test.ts @@ -0,0 +1,258 @@ +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsage, type EnvironmentUsage } from "./usageMerge"; + +function bucket(overrides: Partial = {}): UsageBucket { + return { + day: "2026-08-07" as UsageDay, + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "modelPriced", + records: 5, + unpricedRecords: 0, + sessions: 1, + ...overrides, + }; +} + +function summary( + buckets: readonly UsageBucket[], + sources: readonly { + provider: UsageProviderKind; + hostId: string; + homePath: string; + volumeId?: string; + distinctSessions?: number; + }[], + contractVersion: number = USAGE_CONTRACT_VERSION, +): UsageSummary { + return { + contractVersion, + readAt: "2026-08-07T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-31" as UsageDay, + buckets, + sources: sources.map((source) => ({ + fingerprint: { + hostId: source.hostId, + provider: source.provider, + resolvedHomePath: source.homePath, + volumeId: source.volumeId ?? `vol-${source.hostId}`, + }, + status: "ok" as const, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: source.distinctSessions ?? 1, + message: null, + })), + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + scanDurationMs: 1, + }; +} + +function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { + return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; +} + +describe("mergeUsage", () => { + it("sums environments that read different transcript directories", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]), + ), + environment( + "env-b", + summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.records).toBe(10); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("counts a shared transcript directory once", () => { + // Two worktree servers on one machine resolve the same provider home. + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [shared])), + environment("env-b", summary([bucket()], [shared])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.duplicateSources).toHaveLength(1); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + }); + + it("drops only the duplicated provider, keeping the environment's other one", () => { + const sharedClaude = { + provider: "claude" as const, + hostId: "mac", + homePath: "/home/theo/.claude", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [sharedClaude])), + environment( + "env-b", + summary( + [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], + [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + // env-b's claude bucket is dropped, its codex bucket survives. + expect(merged.costUsd).toBe(14); + expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ + "claude", + "codex", + ]); + }); + + it("excludes an environment reporting an older contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]), + ), + environment( + "env-b", + summary( + [bucket()], + [{ provider: "claude", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); + }); + + it("derives provider shares and cost quality", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]?.provider).toBe("claude"); + expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); + expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); + expect(merged.costQuality.cacheSavingsUsd).toBe(4); + }); + + it("keeps two machines apart when hostname and home path collide", () => { + // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make + // one machine's usage vanish. Filesystem identity separates them. + const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])), + environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("still collapses two servers reading the same directory", () => { + const same = { + provider: "claude" as const, + hostId: "mac", + homePath: "/Users/theo/.claude", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [same])), + environment("env-b", summary([bucket()], [same])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.duplicateSources).toHaveLength(1); + }); + + it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { + // One session that spans two days appears in two buckets. Summing bucket + // sessions would say 2; the source's distinct count says 1. + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })], + [ + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + distinctSessions: 1, + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.sessions).toBe(1); + }); + + it("returns empty totals with no environments", () => { + const merged = mergeUsage([], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.daily).toHaveLength(0); + }); +}); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts new file mode 100644 index 00000000000..fd73c0a31c0 --- /dev/null +++ b/apps/web/src/usage/usageMerge.ts @@ -0,0 +1,353 @@ +/** + * Merges per-environment usage summaries into the single view the page renders. + * + * Pure, so the de-duplication and derivation rules can be tested without a + * connected environment. + * + * @module usageMerge + */ +import type { + EnvironmentId, + UsageBucket, + UsageProviderKind, + UsageSourceFingerprint, + UsageSummary, +} from "@t3tools/contracts"; + +export interface EnvironmentUsage { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly summary: UsageSummary; +} + +export interface ProviderTotals { + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; + readonly tokenShare: number; +} + +export interface ModelTotals { + readonly model: string; + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; +} + +export interface DailyTotals { + readonly day: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly byProvider: ReadonlyMap; +} + +export interface CostQuality { + readonly providerReportedShare: number; + readonly modelPricedShare: number; + readonly unpricedShare: number; + readonly cacheSavingsUsd: number; +} + +export interface MergedUsage { + readonly costUsd: number; + readonly uncachedInputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + readonly totalTokens: number; + readonly records: number; + readonly sessions: number; + readonly providers: readonly ProviderTotals[]; + readonly models: readonly ModelTotals[]; + readonly daily: readonly DailyTotals[]; + readonly costQuality: CostQuality; + /** Environments whose data was dropped as a duplicate of another's. */ + readonly duplicateSources: readonly string[]; + readonly contributingEnvironments: readonly EnvironmentId[]; + readonly staleEnvironments: readonly EnvironmentId[]; +} + +/** + * Two sources are the same physical transcript directory only when host, + * provider, path and filesystem identity all agree. + * + * `volumeId` is what stops two machines that happen to share a hostname and a + * home path, which is every Mac in a fleet, from collapsing into one source and + * having one of them silently dropped. + */ +function fingerprintKey(fingerprint: UsageSourceFingerprint): string { + return [ + fingerprint.hostId, + fingerprint.provider, + fingerprint.resolvedHomePath, + fingerprint.volumeId, + ].join(" "); +} + +/** + * Decides which environment owns each physical transcript directory. + * + * Several environments on one machine (worktree servers, for instance) resolve + * the same provider home and would otherwise double count every token. The + * first environment in a stable order claims a fingerprint; the rest have that + * provider's buckets dropped. Environments are sorted by id so the winner does + * not change between renders. + */ +function claimSources(environments: readonly EnvironmentUsage[]): { + readonly ownerByFingerprint: ReadonlyMap; + readonly duplicates: readonly string[]; +} { + const ownerByFingerprint = new Map(); + const duplicates: string[] = []; + + const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); + } + } + + return { ownerByFingerprint, duplicates }; +} + +/** Sources this environment owns after fingerprint claims, plus their buckets. */ +function ownedContribution( + environment: EnvironmentUsage, + ownerByFingerprint: ReadonlyMap, +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { + const ownedProviders = new Set(); + let sessions = 0; + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.get(key) === environment.environmentId) { + ownedProviders.add(source.fingerprint.provider); + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessions += source.distinctSessions; + } + } + return { + buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + sessions, + }; +} + +function bucketTokens(bucket: UsageBucket): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + ); +} + +const EMPTY_MERGED: MergedUsage = { + costUsd: 0, + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + records: 0, + sessions: 0, + providers: [], + models: [], + daily: [], + costQuality: { + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0, + }, + duplicateSources: [], + contributingEnvironments: [], + staleEnvironments: [], +}; + +/** + * Merges every connected environment's summary. + * + * `expectedContractVersion` guards against an environment running older server + * code: rather than blocking the page, its data is excluded and its id is + * reported so the UI can say coverage is partial. + */ +export function mergeUsage( + environments: readonly EnvironmentUsage[], + expectedContractVersion: number, +): MergedUsage { + if (environments.length === 0) return EMPTY_MERGED; + + const current: EnvironmentUsage[] = []; + const staleEnvironments: EnvironmentId[] = []; + for (const environment of environments) { + if (environment.summary.contractVersion === expectedContractVersion) { + current.push(environment); + } else { + staleEnvironments.push(environment.environmentId); + } + } + + const { ownerByFingerprint, duplicates } = claimSources(current); + + let costUsd = 0; + let uncachedInputTokens = 0; + let cachedInputTokens = 0; + let cacheCreationTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let records = 0; + let sessions = 0; + let cacheSavingsUsd = 0; + let providerReportedRecords = 0; + let unpricedRecords = 0; + + const providerAccumulator = new Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number } + >(); + const modelAccumulator = new Map< + string, + { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + >(); + const dailyAccumulator = new Map< + string, + { + costUsd: number; + totalTokens: number; + byProvider: Map; + } + >(); + const contributingEnvironments: EnvironmentId[] = []; + + for (const environment of current) { + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); + if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + sessions += environmentSessions; + + for (const bucket of buckets) { + const tokens = bucketTokens(bucket); + + costUsd += bucket.costUsd; + cacheSavingsUsd += bucket.cacheSavingsUsd; + uncachedInputTokens += bucket.totals.uncachedInputTokens; + cachedInputTokens += bucket.totals.cachedInputTokens; + cacheCreationTokens += bucket.totals.cacheCreationTokens; + outputTokens += bucket.totals.outputTokens; + reasoningTokens += bucket.totals.reasoningTokens; + records += bucket.records; + unpricedRecords += bucket.unpricedRecords; + if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + + const provider = providerAccumulator.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + }; + provider.costUsd += bucket.costUsd; + provider.totalTokens += tokens; + provider.records += bucket.records; + providerAccumulator.set(bucket.provider, provider); + + const modelKey = `${bucket.provider} ${bucket.model}`; + const model = modelAccumulator.get(modelKey) ?? { + provider: bucket.provider, + costUsd: 0, + totalTokens: 0, + records: 0, + }; + model.costUsd += bucket.costUsd; + model.totalTokens += tokens; + model.records += bucket.records; + modelAccumulator.set(modelKey, model); + + const day = dailyAccumulator.get(bucket.day) ?? { + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; + day.costUsd += bucket.costUsd; + day.totalTokens += tokens; + const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + dayProvider.costUsd += bucket.costUsd; + dayProvider.totalTokens += tokens; + day.byProvider.set(bucket.provider, dayProvider); + dailyAccumulator.set(bucket.day, day); + } + } + + const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; + + const providers: ProviderTotals[] = [...providerAccumulator.entries()] + .map(([provider, totals]) => ({ + provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, + })) + .sort((a, b) => b.costUsd - a.costUsd); + + const models: ModelTotals[] = [...modelAccumulator.entries()] + .map(([key, totals]) => ({ + model: key.slice(key.indexOf(" ") + 1), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + + const daily: DailyTotals[] = [...dailyAccumulator.entries()] + .map(([day, totals]) => ({ + day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider, + })) + .sort((a, b) => a.day.localeCompare(b.day)); + + return { + costUsd, + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + totalTokens, + records, + sessions, + providers, + models, + daily, + costQuality: { + providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, + unpricedShare: records === 0 ? 0 : unpricedRecords / records, + modelPricedShare: + records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, + cacheSavingsUsd, + }, + duplicateSources: duplicates, + contributingEnvironments, + staleEnvironments, + }; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8c61a939e9e..f579453c27f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -707,6 +707,13 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetResourceTelemetryHistory, staleTimeMs: 5_000, }), + // A cold transcript scan is measured in seconds, so keep the result around + // long enough that switching windows or re-rendering does not rescan. + usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:usage-summary", + tag: WS_METHODS.serverGetUsageSummary, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..6181391eca3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,4 +28,5 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./usage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index db40b10fed9..59255639995 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,6 +152,7 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; +import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -244,6 +245,7 @@ export const WS_METHODS = { serverReportClientActivity: "server.reportClientActivity", serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", + serverGetUsageSummary: "server.getUsageSummary", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -381,6 +383,12 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry error: EnvironmentAuthorizationError, }); +export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { + payload: UsageSummaryInput, + success: UsageSummary, + error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), +}); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -819,6 +827,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, + WsServerGetUsageSummaryRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts new file mode 100644 index 00000000000..1aa639fe4a0 --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,194 @@ +/** + * Usage reporting contract. + * + * Each environment scans the provider CLIs' own on-disk session transcripts + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than + * relying on T3 Code's own orchestration projections, so usage stays complete + * even for turns that were never driven through T3 Code. This mirrors the + * approach `ccusage` takes. + * + * Environments return pre-aggregated `(day, provider, model)` buckets. Raw + * transcript records never cross the wire. + * + * @module usage + */ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The + * client renders partial coverage when an environment reports an older version + * rather than failing the whole page. + */ +export const USAGE_CONTRACT_VERSION = 3 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export type UsageProviderKind = typeof UsageProviderKind.Type; + +/** + * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. + * + * Days are bucketed server-side so that a turn always lands on the day the user + * experienced it, not the UTC day. + */ +const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( + Schema.brand("UsageDay"), +); +export type UsageDay = typeof UsageDay.Type; + +/** + * Why a bucket's cost is what it is. + * + * - `providerReported` - the transcript carried an explicit cost figure. + * - `modelPriced` - we matched the model against the LiteLLM rate table. + * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded + * from cost. + */ +export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); +export type UsageCostSource = typeof UsageCostSource.Type; + +/** + * Token counts for a bucket. + * + * `cachedInputTokens` and `cacheCreationTokens` are disjoint from + * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens` + * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic + * folds thinking into output), so it must never be added on top. + */ +export const UsageTokenTotals = Schema.Struct({ + uncachedInputTokens: NonNegativeInt, + cachedInputTokens: NonNegativeInt, + cacheCreationTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + reasoningTokens: NonNegativeInt, +}); +export type UsageTokenTotals = typeof UsageTokenTotals.Type; + +/** + * One `(day, provider, model)` cell. + * + * `costUsd` is the raw API-equivalent cost of these tokens. It is not money + * spent: subscription plans bill separately. `unpricedRecords` counts records + * whose tokens are included in the token totals but which contributed nothing + * to `costUsd`. + */ +export const UsageBucket = Schema.Struct({ + day: UsageDay, + provider: UsageProviderKind, + model: TrimmedNonEmptyString, + totals: UsageTokenTotals, + costUsd: Schema.Number, + /** + * What the cached input would have cost at full input rates minus what it + * actually cost. Requires the rate table, so it is computed alongside cost + * rather than derived on the client. + */ + cacheSavingsUsd: Schema.Number, + costSource: UsageCostSource, + /** Distinct assistant responses, after de-duplication. */ + records: NonNegativeInt, + unpricedRecords: NonNegativeInt, + /** Distinct transcript sessions that contributed to this cell. */ + sessions: NonNegativeInt, +}); +export type UsageBucket = typeof UsageBucket.Type; + +/** + * Identifies the physical transcript directory a source read from. + * + * Two environments on the same machine (worktree servers, for example) resolve + * the same provider home and would otherwise double count. The client drops + * duplicate fingerprints before merging. + */ +export const UsageSourceFingerprint = Schema.Struct({ + hostId: TrimmedNonEmptyString, + provider: UsageProviderKind, + resolvedHomePath: TrimmedNonEmptyString, + /** + * Filesystem identity of the transcript directory, as `device:inode`. + * + * Hostname and path alone are not enough: every Mac in a fleet resolves + * `/Users//.claude`, so two machines that happen to share a hostname + * would look like one source and have their usage silently dropped. The + * device/inode pair is stable for two servers reading the same directory and + * effectively never collides across machines. Empty when it cannot be read. + */ + volumeId: Schema.String, +}); +export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; + +export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]); +export type UsageSourceStatus = typeof UsageSourceStatus.Type; + +export const UsageSource = Schema.Struct({ + fingerprint: UsageSourceFingerprint, + status: UsageSourceStatus, + scannedFiles: NonNegativeInt, + skippedFiles: NonNegativeInt, + /** Records that parsed but carried no recognisable usage payload. */ + malformedRecords: NonNegativeInt, + /** + * Distinct transcript sessions seen under this directory. Buckets also carry + * per-bucket session counts, but a session spans days and models, so summing + * those overcounts; this is the figure clients should total. + */ + distinctSessions: NonNegativeInt, + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type UsageSource = typeof UsageSource.Type; + +export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); +export type UsagePricingStatus = typeof UsagePricingStatus.Type; + +/** + * Provenance for the rate table, so the UI can be honest about how good the + * cost figures are. + */ +export const UsagePricing = Schema.Struct({ + status: UsagePricingStatus, + source: TrimmedNonEmptyString, + fetchedAt: Schema.NullOr(Schema.String), + knownModels: NonNegativeInt, +}); +export type UsagePricing = typeof UsagePricing.Type; + +export const UsageSummaryInput = Schema.Struct({ + /** Inclusive first day of the window, in `timeZone`. */ + sinceDay: UsageDay, + /** Inclusive last day of the window, in `timeZone`. */ + untilDay: UsageDay, + /** + * IANA zone the client wants days bucketed in. An offset would be wrong for + * any window that crosses a DST boundary. + */ + timeZone: TrimmedNonEmptyString, +}); +export type UsageSummaryInput = typeof UsageSummaryInput.Type; + +export const UsageSummary = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + timeZone: TrimmedNonEmptyString, + sinceDay: UsageDay, + untilDay: UsageDay, + buckets: Schema.Array(UsageBucket), + sources: Schema.Array(UsageSource), + pricing: UsagePricing, + /** Wall-clock cost of the scan, surfaced in diagnostics. */ + scanDurationMs: NonNegativeInt, +}); +export type UsageSummary = typeof UsageSummary.Type; + +export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { + reason: Schema.Literals(["scanFailed", "invalidWindow"]), + /** Stable, bounded description. The underlying failure travels in `cause`. */ + detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Usage read failed (${this.reason}): ${this.detail}`; + } +} From a20923ce463335e89e92f5983d98a180536e8e7d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 08:43:03 -0400 Subject: [PATCH 19/81] fix(web): usage chart no longer makes Claude look like the bigger spender (#5697) Co-authored-by: Claude Opus 5 (1M context) --- .../usage/UsageProviderChart.test.ts | 22 ++-- .../components/usage/UsageProviderChart.tsx | 112 ++++++++---------- .../src/components/usage/usageProviders.ts | 5 +- 3 files changed, 65 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 2b647153f20..a36cd1e8833 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -77,15 +77,21 @@ describe("buildDayColumns", () => { ]); }); - it("keeps the bands contiguous so the areas stay additive", () => { + it("keeps band values absolute rather than cumulative", () => { + // Regression: the bands were once stack offsets, which drew Claude Code + // permanently above Codex regardless of which provider spent more. + const [first] = buildDayColumns(days, byDay, "cost"); + + expect(first?.bands).toEqual([ + { provider: "codex", value: 10 }, + { provider: "claude", value: 20 }, + ]); + }); + + it("reports the total as the sum of its bands", () => { for (const column of buildDayColumns(days, byDay, "cost")) { - let expectedBase = 0; - for (const band of column.bands) { - expect(band.base).toBeCloseTo(expectedBase, 9); - expect(band.top).toBeCloseTo(band.base + band.value, 9); - expectedBase = band.top; - } - expect(column.total).toBeCloseTo(expectedBase, 9); + const sum = column.bands.reduce((running, band) => running + band.value, 0); + expect(column.total).toBeCloseTo(sum, 9); } }); }); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index d1ffce25e65..1d5410ebad5 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -18,13 +18,11 @@ interface UsageProviderChartProps { readonly metric: UsageChartMetric; } -/** One day's stacked bands, shared by the paths and the hover readout. */ +/** One day's per-provider values, shared by the paths and the hover readout. */ export interface DayColumn { readonly bands: readonly { readonly provider: UsageProviderKind; readonly value: number; - readonly base: number; - readonly top: number; }[]; readonly total: number; } @@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): return path; } -/** - * The same curve walked end to start. A cubic reverses exactly by swapping its - * control points, so this traces the identical geometry. - * - * Bands must use this rather than re-smoothing their base points in reverse: - * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is - * not perfectly symmetric under reversal, and independently smoothed edges of - * adjacent bands could hairline-gap or overlap. Sharing one curve per stack - * boundary makes that geometrically impossible. - */ -function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { - const last = segments[segments.length - 1]; - if (last === undefined) return ""; - let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; - for (let index = segments.length - 1; index >= 0; index -= 1) { - const segment = segments[index]; - if (segment === undefined) continue; - path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`; - } - return path; -} - /** * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the * peak. @@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re } /** - * Turns the merged daily totals into stacked bands, one column per day. + * Turns the merged daily totals into one column per day. + * + * Values are absolute, not cumulative: the series are layered from a shared + * zero baseline rather than stacked. A stacked chart puts whichever provider is + * drawn last permanently above the other, which reads as "that one is bigger" + * even on days where it is not. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -188,14 +169,11 @@ export function buildDayColumns( ): readonly DayColumn[] { return days.map((day) => { const entry = byDay.get(day); - let stackTop = 0; - const bands = PROVIDER_ORDER.map((provider) => { - const value = valueFor(entry, provider, metric); - const base = stackTop; - stackTop += value; - return { provider, value, base, top: stackTop }; - }); - return { bands, total: stackTop }; + const bands = PROVIDER_ORDER.map((provider) => ({ + provider, + value: valueFor(entry, provider, metric), + })); + return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; }); } @@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr }; } - const stacked = buildDayColumns(days, byDay, metric); + const columns = buildDayColumns(days, byDay, metric); - const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0); + // The scale tops out at the largest single provider-day, not the largest + // sum: layered series each measure from zero, so a combined peak would + // leave the plot permanently half empty. + const peak = columns.reduce( + (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), + 0, + ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); // Reserve a sliver above the top gridline so the series stroke, which is @@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); - // One smoothed curve per stack boundary (baseline, then each provider's - // cumulative top). Band k is the region between boundary k and k+1, both - // drawn from these shared control points. - const boundaries = [ - stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), - ...PROVIDER_ORDER.map((_, providerIndex) => - stacked.map((column, dayIndex) => ({ + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const curve = smoothCurve( + columns.map((column, dayIndex) => ({ x: dayIndex * step, - y: toY(column.bands[providerIndex]?.top ?? 0), + y: toY(column.bands[providerIndex]?.value ?? 0), })), - ), - ].map(smoothCurve); - - const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const top = boundaries[providerIndex + 1] ?? []; - const base = boundaries[providerIndex] ?? []; + ); + const line = curvePath(curve, "M"); return { provider, - area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, - line: curvePath(top, "M"), + total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), + area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`, + line, }; }); - return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked }; + // Paint the heavier series first so the lighter one is never buried under + // it. The fills are faint enough that the order barely shows, but the + // strokes are drawn in a second pass regardless, so neither can be hidden. + const ordered = [...built].sort((a, b) => b.total - a.total); + + return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; }, [byDay, days, metric]); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr ); })} - {paths.map(({ provider, area, line }) => ( - - - - + {/* Fills first, then every stroke, so no series covers another's line. */} + {paths.map(({ provider, area }) => ( + + ))} + {paths.map(({ provider, line }) => ( + ))} {hoverIndex === null ? null : ( diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 5356f96edc7..f8b65877dcf 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,8 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Stacking and table order. Codex sits under Claude Code so the larger band - * reads as the top surface, matching the reference layout. + * Series and table order. The chart layers both providers from a shared zero + * baseline, so this only fixes the reading order of legends, tables and hover + * rows; it does not decide which series sits above the other. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; From 89ee692bf0436505d008c1d70215e70836eba4e2 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Sat, 8 Aug 2026 14:02:51 -0700 Subject: [PATCH 20/81] fix(web): persist diff view mode (#5731) --- apps/web/src/components/DiffPanel.tsx | 4 ++-- apps/web/src/diffPanelStore.test.ts | 28 ++++++++++++++++++++++++++- apps/web/src/diffPanelStore.ts | 7 +++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 76191e6d4d7..a62f5edd4df 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -76,7 +76,6 @@ import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -312,7 +311,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); + const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index 607b7c8d580..7e7c95921f0 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -7,7 +7,33 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelSt const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); describe("diffPanelStore", () => { - beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} })); + beforeEach(() => + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + diffRenderMode: "stacked", + }), + ); + + it("keeps the selected render mode in panel and persisted state", async () => { + useDiffPanelStore.getState().setDiffRenderMode("split"); + + expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); + expect( + useDiffPanelStore.persist.getOptions().partialize?.(useDiffPanelStore.getState()), + ).toMatchObject({ diffRenderMode: "split" }); + + const { name, storage } = useDiffPanelStore.persist.getOptions(); + if (!name) throw new Error("Expected diff panel persistence to have a storage name"); + const persisted = await storage?.getItem(name); + expect(persisted?.state).toMatchObject({ diffRenderMode: "split" }); + + useDiffPanelStore.setState({ diffRenderMode: "stacked" }); + if (persisted) await storage?.setItem(name, persisted); + await useDiffPanelStore.persist.rehydrate(); + + expect(useDiffPanelStore.getState().diffRenderMode).toBe("split"); + }); it("defaults each thread to branch changes when the working tree is clean", () => { expect( diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 56b5ad23fec..ebb560a2383 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -10,12 +10,16 @@ export type DiffPanelSelection = | { kind: "unstaged" } | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; +export type DiffRenderMode = "stacked" | "split"; + const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; + diffRenderMode: DiffRenderMode; + setDiffRenderMode: (mode: DiffRenderMode) => void; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; @@ -33,6 +37,8 @@ export const useDiffPanelStore = create()( (set) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, + diffRenderMode: "stacked", + setDiffRenderMode: (diffRenderMode) => set({ diffRenderMode }), selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -126,6 +132,7 @@ export const useDiffPanelStore = create()( partialize: (state) => ({ byThreadKey: state.byThreadKey, branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, + diffRenderMode: state.diffRenderMode, }), }, ), From c2f8cb7ca1576afd70294b98dfe1de9be17aacae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:20:06 -0400 Subject: [PATCH 21/81] feat(web): show how many subagents are running at a glance (#5745) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 7 ++++++ apps/web/src/components/RightPanelTabs.tsx | 21 ++++++++++++++++- .../components/chat/PanelLayoutControls.tsx | 23 +++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b510d457fd..26a5c41ded5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5913,6 +5913,11 @@ function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -6419,6 +6424,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -6447,6 +6453,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b9345ab8c3c..0b1700e7349 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -48,6 +48,8 @@ interface RightPanelTabsProps { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + /** Running + waiting subagents; badges the Agents card in the empty state. */ + liveAgentCount: number; children: ReactNode; } @@ -96,6 +98,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + liveAgentCount: number; }) { const actions = [ { @@ -105,6 +108,7 @@ function RightPanelEmptyState(props: { available: props.browserAvailable, disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, + badgeCount: 0, }, { label: "Terminal", @@ -113,6 +117,7 @@ function RightPanelEmptyState(props: { available: true, disabledReason: null, onClick: props.onAddTerminal, + badgeCount: 0, }, { label: "Files", @@ -121,6 +126,7 @@ function RightPanelEmptyState(props: { available: props.filesAvailable, disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, + badgeCount: 0, }, { label: "Diff", @@ -129,6 +135,7 @@ function RightPanelEmptyState(props: { available: props.diffAvailable, disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, + badgeCount: 0, }, { label: "Agents", @@ -137,6 +144,7 @@ function RightPanelEmptyState(props: { available: true, disabledReason: null, onClick: props.onAddAgents, + badgeCount: props.liveAgentCount, }, ] as const; @@ -154,7 +162,17 @@ function RightPanelEmptyState(props: { const Icon = action.icon; const content = ( <> - + + + {action.badgeCount > 0 ? ( + + {action.badgeCount} + + ) : null} + {action.label} {action.description} @@ -498,6 +516,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} + liveAgentCount={props.liveAgentCount} /> ) : ( props.children diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index deb7b3ee9af..c61826f677d 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -11,6 +11,8 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; + /** Running + waiting subagents in this thread; badges the right panel toggle. */ + liveAgentCount: number; onToggleTerminal: () => void; onToggleRightPanel: () => void; } @@ -22,6 +24,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, + liveAgentCount, onToggleTerminal, onToggleRightPanel, }: PanelLayoutControlsProps) { @@ -59,18 +62,34 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="shrink-0 [-webkit-app-region:no-drag]" pressed={rightPanelOpen} onPressedChange={onToggleRightPanel} - aria-label="Toggle right panel" + aria-label={ + liveAgentCount > 0 + ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` + : "Toggle right panel" + } variant="ghost" size="sm" disabled={!rightPanelAvailable} > + {liveAgentCount > 0 ? ( + + {liveAgentCount} + + ) : null} } /> {rightPanelAvailable - ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` + ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${ + liveAgentCount > 0 + ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` + : "" + }` : "Right panel is unavailable"} From be01b287b92b4686023a5e213078a2f51b3c1880 Mon Sep 17 00:00:00 2001 From: naMqe Date: Sun, 9 Aug 2026 02:02:57 +0200 Subject: [PATCH 22/81] fix(web): add missing cursor-pointer styling to dropdowns and interactive buttons (#5716) --- apps/web/src/components/RightPanelTabs.tsx | 10 +++++----- .../components/settings/DiagnosticsSettings.tsx | 14 +++++++------- .../settings/ResourceTelemetryDiagnostics.tsx | 8 ++++---- apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 6 +++--- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/usage/UsagePage.tsx | 8 ++++---- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 0b1700e7349..5fa0d6c1c36 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -185,7 +185,7 @@ function RightPanelEmptyState(props: { key={action.label} type="button" onClick={action.onClick} - className="flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5" + className="cursor-pointer flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5" > {content} @@ -413,7 +413,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAuxClick={(event) => handleTabAuxClick(event, surface)} onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( - "group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", @@ -421,7 +421,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > @@ -173,7 +173,7 @@ export function UsagePage() { type="button" onClick={() => setMetric(option)} className={cn( - "px-2.5 py-1 text-[10px] tracking-wide uppercase", + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", option === metric ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", @@ -233,7 +233,7 @@ export function UsagePage() { type="button" onClick={() => setBreakdown(option)} className={cn( - "px-2.5 py-1 text-[10px] tracking-wide uppercase", + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", option === breakdown ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", From e70cdb478d34342d13ba4f433992394bf7303c1d Mon Sep 17 00:00:00 2001 From: Gabe Fletcher Date: Sat, 8 Aug 2026 20:24:42 -0400 Subject: [PATCH 23/81] fix(server): stop Claude resume handshakes from completing turns that never ran (#5710) Co-authored-by: t3-turbo-simulation Co-authored-by: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 167 +++++++++++++----- .../Layers/ProviderRuntimeIngestion.ts | 10 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 69 ++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 34 ++-- 4 files changed, 214 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b4468bd4c6d..258aa010e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + type OrchestrationCommand, ProjectId, ProviderItemId, type ServerSettings, @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); + const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-provider-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), + await dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + await dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + await dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "ready", + providerName: "codex", runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: createdAt, - lastError: null, - }, - createdAt, - }), - ); + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }); provider.setSession({ provider: ProviderDriverKind.make("codex"), status: "ready", @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, @@ -843,6 +840,82 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("rejects an untargeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A turn start is pending: the session reads "starting" with no active + // turn tracked yet. This is the window the Claude resume handshake's + // phantom (turn.completed with no turnId) used to slip through, stomping + // "starting" back to "ready" for a turn that never existed. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-untargeted"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + status: "completed", + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("accepts a targeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A completion that names its turn still lands even when no active turn + // is tracked (e.g. its turn.started was lost). Only untargeted + // completions are rejected. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-targeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-targeted-late"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late"), + status: "completed", + }); + + await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 40307cd9f25..03253797242 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1532,8 +1532,14 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // If no active turn is tracked, accept completion scoped to this thread. - return true; + // No active turn tracked: accept only completions that name their + // turn (covers a real completion whose turn.started was lost). An + // untargeted completion cannot prove it belongs to any turn this + // thread ran — the known emitter was the Claude resume handshake + // (system/init + result(num_turns: 0)), which is not a turn at + // all — and applying it here stomps the "starting" lifecycle + // state while a turn start is pending. + return eventTurnId !== undefined; default: return true; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d3d768b5384..711b0f6f6aa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -978,6 +978,75 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not emit turn.completed for a result with no active turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect through session.exited so the window after the second result + // is deterministically inside the collection: both results are queued + // after sendTurn returns and drain in order on the one stream consumer. + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 1, + session_id: "sdk-session-1", + uuid: "result-real", + } as unknown as SDKMessage); + + // Second result with no turn in flight — the shape the resume + // handshake (system/init + result(num_turns: 0)) delivers, and the + // same completeTurn branch every no-turnState result lands in. This + // used to emit an untargeted turn.completed; it must emit nothing. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 0, + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: "sdk-session-1", + uuid: "result-handshake", + } as unknown as SDKMessage); + + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const completions = runtimeEvents.filter((event) => event.type === "turn.completed"); + // Exactly one completion — the real turn's, targeted at its turn id. + // The buggy branch produced a second, untargeted one here. + assert.equal(completions.length, 1); + const completed = completions[0]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 92445522cc4..00839c455b4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2248,24 +2248,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: result ?? { status }, }); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, + // A result with no local turn is never a turn this adapter started: + // real turns get turnState in sendTurn, and assistant messages that + // arrive outside a turn auto-start a synthetic one. What lands here is + // the resume handshake (system/init + result(num_turns: 0)), a late + // result for a turn already completed locally (steer auto-close, + // stream teardown), or a stream failure with no turn in flight. The + // untargeted turn.completed this branch used to emit carried no turnId, + // so ingestion could not attribute it — and whenever the projection had + // no active turn (a pending turn start included) it flipped the session + // lifecycle for a turn that never existed. Keep the usage emission, + // drop the lifecycle event, and leave a tripwire so the upstream + // trigger stays measurable in the field. + yield* Effect.logInfo("claude.turn.result-without-active-turn", { threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: {}, + status, + numTurns: result?.num_turns, + hasUsage: result?.usage !== undefined, + ...(errorMessage ? { errorMessage } : {}), }); return; } From 49964e38c02ca26783449e56a3083b124a1d04c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:32:10 -0400 Subject: [PATCH 24/81] chore: vouch gfsaaser24 (#5761) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index c74a0dc48ff..28752c66444 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -15,6 +15,7 @@ github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev +github:gfsaaser24 github:github-actions[bot] github:hwanseoc github:jamesx0416 From 7b2cf4374f5d92cc01eff482ad1af92ab7a87f41 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:44:28 -0400 Subject: [PATCH 25/81] chore: vouch saphid (#5763) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 28752c66444..c3d617665fe 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -27,6 +27,7 @@ github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach +github:saphid github:shiroyasha9 github:StiensWout github:Yash-Singh1 From 89c320df0b0884a8c4df1cf596564c6bc725eb54 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 20:52:06 -0400 Subject: [PATCH 26/81] fix(server): stop Codex threads with queued follow-ups (#5762) --- .../CodexCollabRuntime.integration.test.ts | 49 +++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 13 +++-- .../testFixtures/codexCollabMockPeer.mjs | 35 ++++++++++--- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 3a02c45b23f..38e0e0a7b2c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -245,4 +245,53 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + turnIds: [activeTurnId, queuedTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-queued-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.sendTurn({ input: "queued follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 57a1162dd08..58c012bd63e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -814,13 +814,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined { function updateSession( sessionRef: Ref.Ref, - updates: Partial, + updates: Partial | ((session: ProviderSession) => Partial), ): Effect.Effect { return Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* Ref.update(sessionRef, (session) => ({ ...session, - ...updates, + ...(typeof updates === "function" ? updates(session) : updates), updatedAt, })); }); @@ -1782,11 +1782,14 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, { + yield* updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: turnId, + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, ...(normalizedModel ? { model: normalizedModel } : {}), - }); + })); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { threadId: options.threadId, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59580d2c7e6..f06e984c9aa 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,6 +16,7 @@ const fixture = JSON.parse( const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let turnStartCount = 0; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -43,14 +44,20 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - write({ id, result: fixture.responses.turnStart }); + const turnId = script.turnIds?.[turnStartCount]; + const turn = turnId + ? { ...fixture.responses.turnStart.turn, id: turnId } + : fixture.responses.turnStart.turn; + turnStartCount += 1; + write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; - const turn = fixture.responses.turnStart.turn; - write({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId: rootThreadId, turn }, - }); + if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + } for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } @@ -75,6 +82,20 @@ rl.on("line", (line) => { `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, ); + if ( + script.expectedActiveTurnId && + message.params?.threadId === script.rootThreadId && + message.params?.turnId !== script.expectedActiveTurnId + ) { + write({ + id, + error: { + code: -32000, + message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`, + }, + }); + return; + } if (script.failInterruptFor && script.failInterruptFor === target) { write({ id, error: { code: -32000, message: "thread already closed" } }); return; From 70c423a5e48ab34d8b1a033e798ab378b48dde5d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:31:20 -0400 Subject: [PATCH 27/81] fix(web): usage page loses the cost quality panel, gains a back button (#5756) Co-authored-by: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 269 ++++++++++---------- 1 file changed, 128 insertions(+), 141 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 417e490d37c..91e659ad5dd 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,5 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { RefreshCwIcon } from "lucide-react"; +import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; +import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; @@ -27,6 +28,9 @@ export function UsagePage() { const [windowDays, setWindowDays] = useState(30); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "day">("model"); + const canGoBack = useCanGoBack(); + const navigate = useNavigate(); + const router = useRouter(); // Recomputed only when the window length changes, so a re-render does not // shift the range and refetch every environment. @@ -57,11 +61,27 @@ export function UsagePage() {
    -
    -

    Usage

    -

    - {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} -

    +
    + +
    +

    Usage

    +

    + {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} +

    +
    @@ -222,140 +242,116 @@ export function UsagePage() { /> -
    -
    -
    -

    Breakdown

    -
    - {(["model", "day"] as const).map((option) => ( - - ))} -
    +
    +
    +

    Breakdown

    +
    + {(["model", "day"] as const).map((option) => ( + + ))}
    +
    - {breakdown === "model" ? ( - - - - - - - + {breakdown === "model" ? ( +
    ModelCostShareTokens
    + + + + + + + + + + {merged.models.length === 0 ? ( + + - - - {merged.models.length === 0 ? ( - - + + + + - ) : ( - merged.models.map((model) => ( - - - - - - - )) - )} - -
    ModelCostShareTokens
    + No activity in this window. +
    - No activity in this window. + ) : ( + merged.models.map((model) => ( +
    + + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)}
    - - - {model.model} - - - {formatUsd(model.costUsd)} - - {formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
    - ) : ( - - - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - + )) + )} + +
    Day - {PROVIDER_LABEL[provider]} - TotalTokens
    + ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + - - - {recentDays.length === 0 ? ( - - + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - )) - )} - -
    Day + {PROVIDER_LABEL[provider]} + TotalTokens
    + No activity in this window. +
    - No activity in this window. + ) : ( + recentDays.map((day) => ( +
    {formatDayShort(day.day)} + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)}
    {formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(day.costUsd)} - - {formatTokens(day.totalTokens)} -
    - )} -
    - -
    -

    Cost quality

    -
    - - - - -
    -
    + )) + )} + + + )}
    )} @@ -394,15 +390,6 @@ function Metric({ ); } -function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - /** * Says plainly when the totals are incomplete: an environment still answering, * one that failed, or one whose transcripts another environment already From a6c9b41f902fba2a4137806c09e829935e91baac Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:43:53 -0400 Subject: [PATCH 28/81] feat(server): agents can now open the images you paste into chat (#5757) Co-authored-by: Claude Fable 5 --- .../providerService.integration.test.ts | 2 + .../src/provider/Layers/ClaudeAdapter.ts | 12 +++- .../provider/Layers/ProviderService.test.ts | 65 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 41 ++++++++++-- 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index c57d289f992..6089d22d9aa 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -23,6 +23,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; @@ -93,6 +94,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -4148,7 +4156,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": additionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..7334cd01972 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -55,11 +55,15 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); +const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provide(NodeServices.layer), +); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -292,6 +296,7 @@ function makeProviderServiceLayer() { Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -343,6 +348,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -402,6 +408,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -486,6 +493,7 @@ it.effect( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -556,6 +564,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -611,6 +620,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -671,6 +681,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -737,6 +748,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -796,6 +808,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -927,6 +940,54 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("appends attachment file paths to the turn input text", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-attach"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-attach"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "use this screenshot", + attachments: [attachment], + }); + + const turnInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(typeof turnInput.input, "string"); + const turnText = turnInput.input ?? ""; + assert.equal(turnText.startsWith("use this screenshot"), true); + assert.include(turnText, '[Attached image "screenshot.png" is saved at: '); + assert.equal(turnText.endsWith(`${attachment.id}.png]`), true); + + // An attachment-only turn stays valid and the injected line becomes the + // whole input text, so the agent still learns the path. + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + attachments: [attachment], + }); + const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + + yield* provider.stopSession({ threadId: session.threadId }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1307,6 +1368,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1345,6 +1407,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1413,6 +1476,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1446,6 +1510,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d0acc1039c3..2ac00873df9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -35,6 +35,8 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; import { increment, providerMetricAttributes, @@ -203,6 +205,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical @@ -665,16 +668,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { + const attachments = parsed.attachments ?? []; + if (!parsed.input && attachments.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", ); } + + // Adapters inline attachment pixels into the model prompt, but the model's + // tools cannot dereference pixels. Appending the on-disk path is what lets + // a turn like "include this screenshot in the PR" copy the actual file. + // This runs after schema decode, so the appended lines are exempt from the + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so + // the overhead is bounded. Unresolvable ids are skipped here and surface + // as adapter errors when the file is read for inlining. + const attachmentPathLines = attachments.flatMap((attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const inputTextWithAttachmentPaths = + attachmentPathLines.length === 0 + ? parsed.input + : [parsed.input, attachmentPathLines.join("\n")] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + const input = { + ...parsed, + ...(inputTextWithAttachmentPaths !== undefined + ? { input: inputTextWithAttachmentPaths } + : {}), + attachments, + }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, From 5208bdeb0db95063091a29f97af3548436c5f291 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:46:27 -0400 Subject: [PATCH 29/81] fix(web): pinned reorder no longer reshuffles while writes land (#5767) Co-authored-by: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 70 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75c580402b1..f6cd9f7b175 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2269,22 +2269,28 @@ export default function Sidebar() { ); // Drag-to-reorder for the pinned block. A drop computes ONE fractional key // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case). The - // optimistic order keeps the card where it was dropped until the - // confirming event round-trips; canonical order matching it releases the - // override, and a failed write clears it (the card snaps back) with a toast. - // ANY membership change (new pin, unpin, snooze/wake) also releases it: - // the override can't say where members it never saw belong, and holding it - // would misplace them and launder the stale order into later drags. + // planPinnedReorder for the keyless-neighbor materialization case, which + // instead rewrites every key in the section). The optimistic order keeps + // the card where it was dropped until EVERY key the drop wrote is + // reflected in canonical state — a section rewrite is several sequential + // writes, and releasing on the first landed key would expose the + // half-written canonical order, reshuffling the block once per write. + // A failed write clears the override (the card snaps back) with a toast. + // A key we did NOT write landing (a concurrent client's reorder that must + // win) and ANY membership change (new pin, unpin, snooze/wake) also + // release it: the override can't say where members it never saw belong, + // and holding it would launder a stale order into later drags. const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop, so ANY landed write (ours - confirming, or a concurrent one from another client) releases the - override rather than fighting canonical state. */ + /** pinOrderKey per thread as of the drop — the baseline that tells a + concurrent client's write apart from one of our own landing. */ readonly keysAtDrop: ReadonlyMap; + /** The keys this drop writes (one per planned assignment). The + override holds until all of them appear in canonical state. */ + readonly assignedKeys: ReadonlyMap; } | null>(null); const orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return pinnedThreads; @@ -2303,24 +2309,32 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); // The override represents one drop against one snapshot of the world. - // Release it as soon as the world moves on in any way: membership - // changed (pin/unpin/snooze/wake — the override can't say where members - // it never saw belong), a key changed (our write confirming, or a - // concurrent client's reorder that must win), or canonical already - // matches. Holding it longer would misplace newcomers and launder the - // stale order into later drags. + // Release it when the world moves on: membership changed (pin/unpin/ + // snooze/wake — the override can't say where members it never saw + // belong), a key changed to something we did NOT write (a concurrent + // client's reorder that must win), every key we wrote has landed, or + // canonical already matches. Releasing on the FIRST landed key instead + // of the last exposes the half-written order mid-materialization and + // the block visibly reshuffles once per write. const membershipChanged = canonicalKeys.length !== optimisticPinnedOrder.order.length || canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const anyKeyLanded = canonical.some( - (thread, index) => - optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== - (thread.pinOrderKey ?? null), + const foreignKeyLanded = canonical.some((thread, index) => { + const threadKey = canonicalKeys[index]!; + const currentKey = thread.pinOrderKey ?? null; + if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; + return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); + }); + const currentKeyByThreadKey = new Map( + canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), + ); + const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( + ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, ); const orderConfirmed = !membershipChanged && canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || anyKeyLanded || orderConfirmed) { + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { setOptimisticPinnedOrder(null); } }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); @@ -2390,7 +2404,13 @@ export default function Sidebar() { movedId: activeKey, }); if (assignments.length === 0) return; - setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); + setOptimisticPinnedOrder({ + order: newOrder, + keysAtDrop, + assignedKeys: new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ), + }); void (async () => { // Sequential, stop on first failure. There is deliberately no // rollback: every key write is a complete, valid placement on its @@ -2404,8 +2424,12 @@ export default function Sidebar() { scopeThreadRef(thread.environmentId, thread.id), assignment.orderKey, ); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + if (result._tag === "Failure") { + // Any failure — interrupted included — releases the override: + // a key that never lands would otherwise hold it until some + // unrelated world change came along. setOptimisticPinnedOrder(null); + if (isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ From 288d8e3457f0466da2cbef2eab648331c969b8a7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 21:50:03 -0400 Subject: [PATCH 30/81] feat(web): overhaul project settings into a real settings page (#5768) Co-authored-by: Claude Fable 5 --- apps/web/src/components/CommandPalette.tsx | 11 + .../src/components/ProjectScriptsControl.tsx | 414 +------ apps/web/src/components/Sidebar.tsx | 376 +----- .../src/components/projectScriptEditor.tsx | 415 +++++++ .../settings/ProjectSettingsPanel.tsx | 1011 +++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 6 +- .../src/components/settings/settingsSearch.ts | 22 + apps/web/src/hooks/useT3ProjectFileScripts.ts | 44 +- apps/web/src/routeTree.gen.ts | 43 + apps/web/src/routes/settings.projects.tsx | 11 + .../routes/settings.projects_.$projectKey.tsx | 12 + 11 files changed, 1609 insertions(+), 756 deletions(-) create mode 100644 apps/web/src/components/projectScriptEditor.tsx create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx create mode 100644 apps/web/src/routes/settings.projects_.$projectKey.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 605127f9737..b3e845f0007 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + icon: , + run: async () => { + await navigate({ to: "/settings/projects" }); + }, + }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 7f21177e7b1..304922909b0 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -66,48 +33,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
    -
    - -
    - - - } - > - - - -
    - {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
    -
    -
    - setName(event.target.value)} - /> -
    -
    -
    - - -

    - Press a shortcut. Use Backspace to clear. -

    -
    -
    - -