From 9969ff1da9635089d22965e3c15824bb15b6fe61 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 17:35:01 -0700 Subject: [PATCH 1/2] feat: make sidebar v2 the default, fold the old sidebar into Legacy features 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..cfcafa51c62 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/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-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 e586a77fec39fdc30f92bb992d844b56c06f8e7e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 17:38:48 -0700 Subject: [PATCH 2/2] fix(web): finish v2-row group rename so hover controls reveal Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cfcafa51c62..75c580402b1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -838,7 +838,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : "text-foreground/90", ) : cn( - "truncate group-hover/v2-row:text-foreground", + "truncate group-hover/sidebar-row:text-foreground", props.isActive || isWoke ? "text-foreground" : isUnread @@ -911,7 +911,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( "shrink-0 transition-opacity", !props.isActive && - "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0", + "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0", )} > {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( @@ -973,8 +973,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Wake thread now" onClick={handleUnsnoozeClick} className={cn( - "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", - isWoke && "group-hover/v2-row:static", + "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", )} > @@ -986,8 +986,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Un-settle thread" onClick={handleUnsettleClick} className={cn( - "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", - isWoke && "group-hover/v2-row:static", + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", )} > @@ -998,8 +998,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Settle thread" onClick={handleSettleClick} className={cn( - "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", - isWoke && "group-hover/v2-row:static", + "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", )} > @@ -1101,7 +1101,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( isWokeStatus ? "pointer-events-auto" - : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", + : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} @@ -1156,7 +1156,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // 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/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100", + "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", )} >