authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 21:06:10-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 18:06:10-07:00
log0de9540739369301d5056870deeee3f258a03b14
treead4b4c8f8619685bea3b0dab6b99fb908bb74861
parent31891a1a0c208a6b244b64937741066c61fefd9c
signature Signed by PGP key B5690EEEBB952194

feat: sidebar v2 is now the default sidebar (#5672)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

25 files changed, 7223 insertions(+), 7408 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1-2
......@@ -39,8 +39,7 @@ const clientSettings: ClientSettings = {
3939 sidebarProjectSortOrder: "manual",
4040 sidebarThreadSortOrder: "created_at",
4141 sidebarThreadPreviewCount: 6,
42 sidebarV2Enabled: false,
43 sidebarV2ConfiguredByUser: false,
42 legacySidebarEnabled: false,
4443 timestampFormat: "24-hour",
4544 wordWrap: true,
4645};
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12-11
......@@ -131,7 +131,7 @@ function LocalSettingsRouteScreen() {
131131 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
132132 </SettingsSection>
133133
134 <BetaSettingsSection />
134 <LegacySettingsSection />
135135
136136 <ArchivedThreadsSettingsSection />
137137
......@@ -519,7 +519,7 @@ function ConfiguredSettingsRouteScreen() {
519519 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
520520 </SettingsSection>
521521
522 <BetaSettingsSection />
522 <LegacySettingsSection />
523523
524524 <ArchivedThreadsSettingsSection />
525525
......@@ -538,26 +538,27 @@ function GeneralSettingsSection() {
538538}
539539
540540/**
541 * Device-local beta toggles. Mobile has no client-settings sync, so this is
542 * the counterpart of web's Settings → Beta backed by mobile preferences.
541 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
542 * the counterpart of web's Settings → General → Legacy features backed by
543 * mobile preferences.
543544 */
544function BetaSettingsSection() {
545function LegacySettingsSection() {
545546 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
546547 const threadListV2Enabled = useThreadListV2Enabled();
547548
548549 return (
549550 <View className="gap-3">
550 <SettingsSection title="Beta">
551 <SettingsSection title="Legacy">
551552 <SettingsSwitchRow
552553 icon="sidebar.left"
553 label="Thread List v2"
554 value={threadListV2Enabled}
555 onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
554 label="Legacy Thread List"
555 value={!threadListV2Enabled}
556 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
556557 />
557558 </SettingsSection>
558559 <Text className="px-2 text-sm text-foreground-muted">
559 One flat thread list in creation order. Active work renders as cards; settled threads
560 collapse to compact rows. Switch back any time.
560 Brings back the original grouped thread list. The default list is flat, in creation order:
561 active work renders as cards; settled threads collapse to compact rows.
561562 </Text>
562563 </View>
563564 );
apps/mobile/src/features/threads/threadListV2.test.ts+13-9
......@@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
104104
105105describe("resolveThreadListV2Enabled", () => {
106106 it("defaults on when the device has never chosen", () => {
107 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
108 true,
109 );
107 expect(
108 resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }),
109 ).toBe(true);
110110 });
111111
112 it("honors an explicit device opt-out", () => {
113 expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false);
114 expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true);
112 it("honors an explicit legacy opt-in", () => {
113 expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe(
114 false,
115 );
116 expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe(
117 true,
118 );
115119 });
116120
117121 it("holds the default while preferences are still loading so the list does not remount", () => {
118 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe(
119 true,
120 );
122 expect(
123 resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }),
124 ).toBe(true);
121125 });
122126});
123127
apps/mobile/src/features/threads/threadListV2.ts+6-6
......@@ -103,23 +103,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
103103export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
104104
105105/**
106 * Thread List v2 is on by default on every app variant; the Settings → Beta
107 * toggle is an opt-out. Preferences persist as sparse patches, so `undefined`
108 * genuinely means "never chosen".
106 * The flat Thread List v2 is the default on every app variant; the Settings →
107 * Legacy toggle opts a device back into the grouped legacy list. Preferences
108 * persist as sparse patches, so `undefined` genuinely means "never chosen".
109109 *
110110 * `preferencesLoaded` guards the startup window: preferences load
111111 * asynchronously, and rendering one list before the stored choice arrives would
112112 * remount the whole thing a tick later. While loading, hold the default — that
113 * is where every device without an explicit opt-out lands anyway.
113 * is where every device without an explicit legacy opt-in lands anyway.
114114 */
115115export function resolveThreadListV2Enabled(input: {
116 readonly preference: boolean | undefined;
116 readonly legacyPreference: boolean | undefined;
117117 readonly preferencesLoaded: boolean;
118118}): boolean {
119119 if (!input.preferencesLoaded) {
120120 return true;
121121 }
122 return input.preference ?? true;
122 return input.legacyPreference !== true;
123123}
124124
125125export function resolveThreadListV2Status(
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts+3-3
......@@ -5,15 +5,15 @@ import { mobilePreferencesAtom } from "../../state/preferences";
55import { resolveThreadListV2Enabled } from "./threadListV2";
66
77/**
8 * Resolved Thread List v2 state: the device-local preference if the user has
9 * set one, otherwise the default (on). Every consumer must read through this
8 * Resolved Thread List v2 state: on unless the device opted into the legacy
9 * grouped list (Settings → Legacy). Every consumer must read through this
1010 * rather than the raw preference, which is undefined until explicitly chosen.
1111 */
1212export function useThreadListV2Enabled(): boolean {
1313 const preferencesResult = useAtomValue(mobilePreferencesAtom);
1414 const loaded = AsyncResult.isSuccess(preferencesResult);
1515 return resolveThreadListV2Enabled({
16 preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
16 legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined,
1717 preferencesLoaded: loaded,
1818 });
1919}
apps/mobile/src/persistence/mobile-preferences.ts+9-8
......@@ -27,12 +27,13 @@ export interface Preferences {
2727 readonly projectGroupingEnabled?: boolean;
2828 readonly projectGroupingMode?: SidebarProjectGroupingMode;
2929 /**
30 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
31 * client-settings sync, so the flat v2 thread list is opted out of per
32 * device. Undefined means the user has never chosen, which resolves to on —
33 * see `resolveThreadListV2Enabled`.
30 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
31 * no client-settings sync, so the legacy grouped thread list is opted into
32 * per device. Deliberately a fresh key (was `threadListV2Enabled`, an
33 * opt-out): sanitizing drops the old key, so every device resets to the
34 * default flat list — see `resolveThreadListV2Enabled`.
3435 */
35 readonly threadListV2Enabled?: boolean;
36 readonly legacyThreadListEnabled?: boolean;
3637}
3738
3839export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
......@@ -84,7 +85,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
8485 collapsedProjectGroups?: readonly string[];
8586 projectGroupingEnabled?: boolean;
8687 projectGroupingMode?: SidebarProjectGroupingMode;
87 threadListV2Enabled?: boolean;
88 legacyThreadListEnabled?: boolean;
8889 } = {};
8990
9091 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -121,8 +122,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
121122 ) {
122123 preferences.projectGroupingMode = parsed.projectGroupingMode;
123124 }
124 if (typeof parsed.threadListV2Enabled === "boolean") {
125 preferences.threadListV2Enabled = parsed.threadListV2Enabled;
125 if (typeof parsed.legacyThreadListEnabled === "boolean") {
126 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
126127 }
127128 return preferences;
128129}
apps/web/src/branding.logic.ts-45
......@@ -11,51 +11,6 @@ export function formatAppDisplayName(input: {
1111 return `${input.baseName} (${input.stageLabel})`;
1212}
1313
14/**
15 * Whether the sidebar v2 beta is on by default for a build stage.
16 *
17 * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved
18 * from the client's own stage label rather than the connected server's version:
19 * v2 only exists in the client, so a stable client on a nightly server has
20 * nothing to turn on.
21 */
22export function resolveSidebarV2Default(stageLabel: string): boolean {
23 const stage = stageLabel.trim().toLowerCase();
24 return stage === "nightly" || stage === "dev";
25}
26
27/**
28 * Resolved sidebar v2 state: an explicit choice if the user has made one,
29 * otherwise the default for this build stage.
30 *
31 * A stored `enabled: true` counts as an explicit choice even without the
32 * companion flag. `true` was never the schema default, so it can only have come
33 * from the Settings → Beta toggle — settings written before that flag existed
34 * would otherwise lose the opt-in and drop such users back to v1 on production.
35 * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored
36 * `updateChannel: "nightly"` as user-configured.
37 *
38 * `settingsHydrated` guards the startup window: client settings load
39 * asynchronously and the pre-hydration snapshot is just the schema defaults, so
40 * resolving against it would mount one sidebar and swap it out a tick later,
41 * remounting the tree. While hydrating, hold v1 — where both paths already
42 * start.
43 */
44export function resolveSidebarV2Enabled(input: {
45 readonly enabled: boolean;
46 readonly configuredByUser: boolean;
47 readonly settingsHydrated: boolean;
48 readonly stageLabel: string;
49}): boolean {
50 if (!input.settingsHydrated) {
51 return false;
52 }
53
54 return input.configuredByUser || input.enabled
55 ? input.enabled
56 : resolveSidebarV2Default(input.stageLabel);
57}
58
5914export function resolveServerBackedAppStageLabel(input: {
6015 readonly primaryServerVersion: string | null | undefined;
6116 readonly fallbackStageLabel: string;
apps/web/src/branding.test.ts-73
......@@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22import {
33 resolveServerBackedAppDisplayName,
44 resolveServerBackedAppStageLabel,
5 resolveSidebarV2Default,
6 resolveSidebarV2Enabled,
75} from "./branding.logic";
86
97const originalWindow = globalThis.window;
......@@ -116,74 +114,3 @@ describe("branding logic", () => {
116114 ).toBe("T3 Code (Alpha)");
117115 });
118116});
119
120describe("resolveSidebarV2Default", () => {
121 it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => {
122 expect(resolveSidebarV2Default(stage)).toBe(true);
123 });
124
125 it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => {
126 expect(resolveSidebarV2Default(stage)).toBe(false);
127 });
128});
129
130describe("resolveSidebarV2Enabled", () => {
131 const hydrated = { settingsHydrated: true } as const;
132
133 it.each(["Alpha", "Latest"])(
134 "keeps a legacy opt-in on %s builds even without the companion flag",
135 (stageLabel) => {
136 // `true` was never the schema default, so it can only be an explicit
137 // opt-in from settings written before `sidebarV2ConfiguredByUser` existed.
138 expect(
139 resolveSidebarV2Enabled({
140 ...hydrated,
141 enabled: true,
142 configuredByUser: false,
143 stageLabel,
144 }),
145 ).toBe(true);
146 },
147 );
148
149 it("applies the stage default when the beta was never enabled or configured", () => {
150 expect(
151 resolveSidebarV2Enabled({
152 ...hydrated,
153 enabled: false,
154 configuredByUser: false,
155 stageLabel: "Nightly",
156 }),
157 ).toBe(true);
158 expect(
159 resolveSidebarV2Enabled({
160 ...hydrated,
161 enabled: false,
162 configuredByUser: false,
163 stageLabel: "Latest",
164 }),
165 ).toBe(false);
166 });
167
168 it("honors an explicit opt-out over the stage default", () => {
169 expect(
170 resolveSidebarV2Enabled({
171 ...hydrated,
172 enabled: false,
173 configuredByUser: true,
174 stageLabel: "Nightly",
175 }),
176 ).toBe(false);
177 });
178
179 it("holds v1 until settings hydrate so the sidebar does not remount", () => {
180 expect(
181 resolveSidebarV2Enabled({
182 enabled: true,
183 configuredByUser: true,
184 settingsHydrated: false,
185 stageLabel: "Nightly",
186 }),
187 ).toBe(false);
188 });
189});
apps/web/src/components/AppSidebarLayout.tsx+17-9
......@@ -14,9 +14,11 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
1414import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
1515import { cn, isMacPlatform } from "../lib/utils";
1616import { primaryServerKeybindingsAtom } from "../state/server";
17import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings";
17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
18import LegacyThreadSidebar from "./LegacySidebar";
1819import ThreadSidebar from "./Sidebar";
19import ThreadSidebarV2 from "./SidebarV2";
20import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
21import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
2022import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
2123import {
2224 resolveInitialThreadSidebarWidth,
......@@ -118,13 +120,11 @@ function SidebarControl() {
118120
119121export function AppSidebarLayout({ children }: { children: ReactNode }) {
120122 const navigate = useNavigate();
121 const sidebarV2Enabled = useSidebarV2Enabled();
122 // Settings routes render the settings nav, which lives in the v1 component
123 // and is identical for both sidebars — so v1 stays mounted there.
123 const legacySidebarEnabled = useLegacySidebarEnabled();
124 // Settings routes show the settings nav in place of whichever thread
125 // sidebar is active.
124126 const pathname = useLocation({ select: (location) => location.pathname });
125127 const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
126 const useSidebarV2 = sidebarV2Enabled && !isOnSettings;
127 const useSidebarV2Theme = useSidebarV2 || isOnSettings;
128128 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
129129 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
130130 // Subscribed rather than read once: the clamp must track live window size,
......@@ -188,7 +188,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
188188 side="left"
189189 collapsible="offcanvas"
190190 data-app-sidebar=""
191 data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"}
192191 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
193192 resizable={{
194193 maxWidth: sidebarMaximumWidth,
......@@ -200,7 +199,16 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
200199 onResize: setSidebarWidth,
201200 }}
202201 >
203 {useSidebarV2 ? <ThreadSidebarV2 /> : <ThreadSidebar />}
202 {isOnSettings ? (
203 <>
204 <SidebarChromeHeader isElectron={isElectron} />
205 <SettingsSidebarNav pathname={pathname} />
206 </>
207 ) : legacySidebarEnabled ? (
208 <LegacyThreadSidebar />
209 ) : (
210 <ThreadSidebar />
211 )}
204212 <SidebarRail />
205213 </Sidebar>
206214 {children}
apps/web/src/components/LegacySidebar.tsx created+3627
......@@ -0,0 +1,3627 @@
1import {
2 ArchiveIcon,
3 ArrowUpDownIcon,
4 ChevronRightIcon,
5 CloudIcon,
6 ContainerIcon,
7 FolderPlusIcon,
8 Globe2Icon,
9 LoaderIcon,
10 SearchIcon,
11 SquarePenIcon,
12 TerminalIcon,
13 TriangleAlertIcon,
14} from "lucide-react";
15import {
16 ChangeRequestStatusIcon,
17 prStatusIndicator,
18 PrStatusTooltipContent,
19 resolveThreadPr,
20 terminalStatusFromRunningIds,
21 ThreadStatusLabel,
22 ThreadWorktreeIndicator,
23} from "./ThreadStatusIndicators";
24import { ProjectFavicon } from "./ProjectFavicon";
25import { useAtomValue } from "@effect/atom-react";
26import { autoAnimate } from "@formkit/auto-animate";
27import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
28import { useShallow } from "zustand/react/shallow";
29import {
30 DndContext,
31 type DragCancelEvent,
32 type CollisionDetection,
33 PointerSensor,
34 type DragStartEvent,
35 closestCorners,
36 pointerWithin,
37 useSensor,
38 useSensors,
39 type DragEndEvent,
40} from "@dnd-kit/core";
41import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
42import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
43import { CSS } from "@dnd-kit/utilities";
44import {
45 type ContextMenuItem,
46 ProjectId,
47 type ScopedThreadRef,
48 type ResolvedKeybindingsConfig,
49 type SidebarProjectGroupingMode,
50 ThreadId,
51} from "@t3tools/contracts";
52import {
53 parseScopedThreadKey,
54 scopedProjectKey,
55 scopedThreadKey,
56 scopeProjectRef,
57 scopeThreadRef,
58} from "@t3tools/client-runtime/environment";
59import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
60import {
61 isAtomCommandInterrupted,
62 settlePromise,
63 squashAtomCommandFailure,
64} from "@t3tools/client-runtime/state/runtime";
65import { useNavigate, useParams, useRouter } from "@tanstack/react-router";
66import {
67 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
68 MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
69 type SidebarProjectSortOrder,
70 type SidebarThreadPreviewCount,
71 type SidebarThreadSortOrder,
72} from "@t3tools/contracts/settings";
73import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
74import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
75import { isElectron } from "../env";
76import { useOpenPrLink } from "../lib/openPullRequestLink";
77import { isTerminalFocused } from "../lib/terminalFocus";
78import { isMacPlatform } from "../lib/utils";
79import {
80 readThreadShell,
81 useProject,
82 useProjects,
83 useThreadShells,
84 useThreadShellsForProjectRefs,
85} from "../state/entities";
86import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
87import { useThreadRunningTerminalIds } from "../state/terminalSessions";
88import { useThreadDiscoveredPorts } from "../portDiscoveryState";
89import { openDiscoveredPort } from "./preview/openDiscoveredPort";
90import { useAtomCommand } from "../state/use-atom-command";
91import { previewEnvironment } from "../state/preview";
92import {
93 legacyProjectCwdPreferenceKey,
94 resolveProjectExpanded,
95 useUiStateStore,
96} from "../uiStateStore";
97import {
98 resolveShortcutCommand,
99 shortcutLabelForCommand,
100 shouldShowThreadJumpHintsForModifiers,
101 threadJumpCommandForIndex,
102 threadJumpIndexFromCommand,
103 threadTraversalDirectionFromCommand,
104} from "../keybindings";
105import { isModelPickerOpen } from "../modelPickerVisibility";
106import { useShortcutModifierState } from "../shortcutModifierState";
107import { readLocalApi } from "../localApi";
108import { useComposerDraftStore } from "../composerDraftStore";
109import { useNewThreadHandler } from "../hooks/useHandleNewThread";
110import { useDesktopUpdateState } from "../state/desktopUpdate";
111
112import { useThreadActions } from "../hooks/useThreadActions";
113import { projectEnvironment } from "../state/projects";
114import { useEnvironmentQuery } from "../state/query";
115import { threadEnvironment, useEnvironmentThread } from "../state/threads";
116import { vcsEnvironment } from "../state/vcs";
117import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
118import {
119 buildThreadRouteParams,
120 resolveActiveThreadRouteRef,
121 resolveThreadRouteTarget,
122} from "../threadRoutes";
123import { stackedThreadToast, toastManager } from "./ui/toast";
124import { formatRelativeTimeLabel } from "../timestampFormat";
125import { Kbd } from "./ui/kbd";
126import {
127 getArm64IntelBuildWarningDescription,
128 getDesktopUpdateActionError,
129 getDesktopUpdateInstallConfirmationMessage,
130 isDesktopUpdateButtonDisabled,
131 resolveDesktopUpdateButtonAction,
132 shouldShowArm64IntelBuildWarning,
133 shouldToastDesktopUpdateActionResult,
134} from "./desktopUpdate.logic";
135import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
136import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
137import { Button } from "./ui/button";
138import {
139 Dialog,
140 DialogDescription,
141 DialogFooter,
142 DialogHeader,
143 DialogPanel,
144 DialogPopup,
145 DialogTitle,
146} from "./ui/dialog";
147import { Input } from "./ui/input";
148import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
149import {
150 NumberField,
151 NumberFieldDecrement,
152 NumberFieldGroup,
153 NumberFieldIncrement,
154 NumberFieldInput,
155} from "./ui/number-field";
156import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
157import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
158import {
159 SidebarContent,
160 SidebarGroup,
161 SidebarMenu,
162 SidebarMenuButton,
163 SidebarMenuItem,
164 SidebarMenuSub,
165 SidebarMenuSubButton,
166 SidebarMenuSubItem,
167 useSidebar,
168} from "./ui/sidebar";
169import { useThreadSelectionStore } from "../threadSelectionStore";
170import { openCommandPalette } from "../commandPaletteBus";
171import {
172 archiveSelectedThreadEntries,
173 buildMultiSelectThreadContextMenuItems,
174 getSidebarThreadIdsToPrewarm,
175 resolveAdjacentThreadId,
176 isContextMenuPointerDown,
177 isTrailingDoubleClick,
178 resolveProjectStatusIndicator,
179 resolveThreadRowClassName,
180 resolveThreadStatusPill,
181 orderItemsByPreferredIds,
182 shouldClearThreadSelectionOnMouseDown,
183 sortProjectsForSidebar,
184 useThreadJumpHintVisibility,
185 ThreadStatusPill,
186} from "./Sidebar.logic";
187import { sortThreads } from "../lib/threadSort";
188import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
189import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
190import { useIsMobile } from "~/hooks/useMediaQuery";
191import { CommandDialogTrigger } from "./ui/command";
192import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
193import { primaryServerKeybindingsAtom } from "../state/server";
194import {
195 derivePhysicalProjectKey,
196 deriveProjectGroupingOverrideKey,
197 getProjectOrderKey,
198 selectProjectGroupingSettings,
199} from "../logicalProject";
200import type { SidebarThreadSummary } from "../types";
201import {
202 buildPhysicalToLogicalProjectKeyMap,
203 buildSidebarProjectSnapshots,
204 type SidebarProjectGroupMember,
205 type SidebarProjectSnapshot,
206} from "../sidebarProjectGrouping";
207const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
208 updated_at: "Last user message",
209 created_at: "Created at",
210 manual: "Manual",
211};
212const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
213 updated_at: "Last user message",
214 created_at: "Created at",
215};
216const SIDEBAR_LIST_ANIMATION_OPTIONS = {
217 duration: 180,
218 easing: "ease-out",
219} as const;
220const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
221const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
222 repository: "Group by repository",
223 repository_path: "Group by repository path",
224 separate: "Keep separate",
225};
226const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
227 "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";
228
229function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
230 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
231 return null;
232}
233
234function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
235 return Math.min(
236 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
237 Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value),
238 ) as SidebarThreadPreviewCount;
239}
240
241function formatProjectMemberActionLabel(
242 member: SidebarProjectGroupMember,
243 groupedProjectCount: number,
244): string {
245 if (groupedProjectCount <= 1) {
246 return member.title;
247 }
248
249 return member.environmentLabel
250 ? `${member.environmentLabel} — ${member.workspaceRoot}`
251 : member.workspaceRoot;
252}
253
254function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] {
255 return [
256 project.projectKey,
257 ...project.memberProjects.map((member) => member.physicalProjectKey),
258 ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)),
259 ];
260}
261
262function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
263 switch (mode) {
264 case "repository":
265 return "Projects from the same repository share one sidebar row.";
266 case "repository_path":
267 return "Projects group only when both the repository and repo-relative path match.";
268 case "separate":
269 return "Every project path gets its own sidebar row.";
270 }
271}
272
273function buildThreadJumpLabelMap(input: {
274 keybindings: ResolvedKeybindingsConfig;
275 platform: string;
276 terminalOpen: boolean;
277 threadJumpCommandByKey: ReadonlyMap<
278 string,
279 NonNullable<ReturnType<typeof threadJumpCommandForIndex>>
280 >;
281}): ReadonlyMap<string, string> {
282 if (input.threadJumpCommandByKey.size === 0) {
283 return EMPTY_THREAD_JUMP_LABELS;
284 }
285
286 const shortcutLabelOptions = {
287 platform: input.platform,
288 context: {
289 terminalFocus: false,
290 terminalOpen: input.terminalOpen,
291 },
292 } as const;
293 const mapping = new Map<string, string>();
294 for (const [threadKey, command] of input.threadJumpCommandByKey) {
295 const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);
296 if (label) {
297 mapping.set(threadKey, label);
298 }
299 }
300 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
301}
302
303interface SidebarThreadRowProps {
304 thread: SidebarThreadSummary;
305 projectCwd: string | null;
306 orderedProjectThreadKeys: readonly string[];
307 isActive: boolean;
308 jumpLabel: string | null;
309 appSettingsConfirmThreadArchive: boolean;
310 renamingThreadKey: string | null;
311 renamingTitle: string;
312 setRenamingTitle: (title: string) => void;
313 startThreadRename: (threadKey: string, title: string) => void;
314 renamingInputRef: React.RefObject<HTMLInputElement | null>;
315 renamingCommittedRef: React.RefObject<boolean>;
316 confirmingArchiveThreadKey: string | null;
317 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
318 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
319 handleThreadClick: (
320 event: React.MouseEvent,
321 threadRef: ScopedThreadRef,
322 orderedProjectThreadKeys: readonly string[],
323 ) => void;
324 navigateToThread: (threadRef: ScopedThreadRef) => void;
325 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
326 handleThreadContextMenu: (
327 threadRef: ScopedThreadRef,
328 position: { x: number; y: number },
329 ) => Promise<void>;
330 clearSelection: () => void;
331 commitRename: (
332 threadRef: ScopedThreadRef,
333 newTitle: string,
334 originalTitle: string,
335 ) => Promise<void>;
336 cancelRename: () => void;
337 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
338 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
339}
340
341export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
342 const {
343 orderedProjectThreadKeys,
344 isActive,
345 jumpLabel,
346 appSettingsConfirmThreadArchive,
347 renamingThreadKey,
348 renamingTitle,
349 setRenamingTitle,
350 startThreadRename,
351 renamingInputRef,
352 renamingCommittedRef,
353 confirmingArchiveThreadKey,
354 setConfirmingArchiveThreadKey,
355 confirmArchiveButtonRefs,
356 handleThreadClick,
357 navigateToThread,
358 handleMultiSelectContextMenu,
359 handleThreadContextMenu,
360 clearSelection,
361 commitRename,
362 cancelRename,
363 attemptArchiveThread,
364 openPrLink,
365 thread,
366 } = props;
367 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
368 const threadKey = scopedThreadKey(threadRef);
369 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
370 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
371 const runningTerminalIds = useThreadRunningTerminalIds({
372 environmentId: thread.environmentId,
373 threadId: thread.id,
374 });
375 const isMobile = useIsMobile();
376 const discoveredPorts = useThreadDiscoveredPorts({
377 environmentId: thread.environmentId,
378 threadId: thread.id,
379 });
380 const openPreview = useAtomCommand(previewEnvironment.open, {
381 reportFailure: false,
382 });
383 const environment = useEnvironment(thread.environmentId);
384 const primaryEnvironmentId = usePrimaryEnvironmentId();
385 const isRemoteThread =
386 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
387 const remoteEnvLabel = environment?.label ?? null;
388 // A desktop-local secondary backend (e.g. the WSL backend) shows up as a
389 // bearer environment whose connection id is prefixed "local:". It runs on the
390 // user's own machine, so the cloud icon is misleading — label it "Local" and
391 // suppress the cloud icon (the project header already shows a container icon
392 // for desktop-local projects, see sidebarProjectGrouping).
393 const isDesktopLocalThread =
394 environment !== null && isDesktopLocalConnectionTarget(environment.entry.target);
395 const threadEnvironmentLabel = isRemoteThread
396 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
397 : null;
398 // For grouped projects, the thread may belong to a different environment
399 // than the representative project. Look up the thread's own project cwd
400 // so git status (and thus PR detection) queries the correct path.
401 const threadProject = useProject(
402 useMemo(
403 () => scopeProjectRef(thread.environmentId, thread.projectId),
404 [thread.environmentId, thread.projectId],
405 ),
406 );
407 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
408 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
409 const gitStatus = useEnvironmentQuery(
410 thread.branch != null && gitCwd !== null
411 ? vcsEnvironment.status({
412 environmentId: thread.environmentId,
413 input: { cwd: gitCwd },
414 })
415 : null,
416 );
417 const isHighlighted = isActive || isSelected;
418 const handleOpenDiscoveredPort = useCallback(
419 (event: React.MouseEvent<HTMLButtonElement>) => {
420 const port = discoveredPorts[0];
421 if (!port) return;
422 event.preventDefault();
423 event.stopPropagation();
424 navigateToThread(threadRef);
425 void (async () => {
426 const result = await openDiscoveredPort({ threadRef, port, openPreview });
427 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
428 return;
429 }
430 const error = squashAtomCommandFailure(result);
431 toastManager.add(
432 stackedThreadToast({
433 type: "error",
434 title: "Unable to open preview",
435 description:
436 error instanceof Error ? error.message : "The preview could not be opened.",
437 }),
438 );
439 })();
440 },
441 [discoveredPorts, navigateToThread, openPreview, threadRef],
442 );
443 const isThreadRunning =
444 thread.session?.status === "running" && thread.session.activeTurnId != null;
445 const threadStatus = resolveThreadStatusPill({
446 thread: {
447 ...thread,
448 lastVisitedAt,
449 },
450 });
451 const pr = resolveThreadPr({
452 threadBranch: thread.branch,
453 gitStatus: gitStatus.data,
454 });
455 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
456 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
457 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
458 const threadMetaClassName = isConfirmingArchive
459 ? "pointer-events-none opacity-0"
460 : !isThreadRunning
461 ? "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"
462 : "pointer-events-none";
463 const clearConfirmingArchive = useCallback(() => {
464 setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
465 }, [setConfirmingArchiveThreadKey, threadKey]);
466 const handleMouseLeave = useCallback(() => {
467 clearConfirmingArchive();
468 }, [clearConfirmingArchive]);
469 const handleBlurCapture = useCallback(
470 (event: React.FocusEvent<HTMLLIElement>) => {
471 const currentTarget = event.currentTarget;
472 requestAnimationFrame(() => {
473 if (currentTarget.contains(document.activeElement)) {
474 return;
475 }
476 clearConfirmingArchive();
477 });
478 },
479 [clearConfirmingArchive],
480 );
481 const handleRowClick = useCallback(
482 (event: React.MouseEvent) => {
483 handleThreadClick(event, threadRef, orderedProjectThreadKeys);
484 },
485 [handleThreadClick, orderedProjectThreadKeys, threadRef],
486 );
487 const handleRowDoubleClick = useCallback(
488 (event: React.MouseEvent) => {
489 // Already renaming this row: a double-click on the row chrome (outside the
490 // input) must not restart and discard the in-progress edit.
491 if (renamingThreadKey === threadKey) return;
492 // On mobile the first tap navigates and closes the sidebar sheet, so the
493 // inline rename can't be shown. Renaming there stays on the context menu.
494 if (isMobile) return;
495 // cmd/ctrl/shift double-clicks are multi-select intent, not rename.
496 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
497 // Ignore double-clicks bubbling from nested controls (PR status, port,
498 // archive buttons) — only the row body should enter inline rename.
499 if ((event.target as HTMLElement).closest("button, a")) return;
500 event.preventDefault();
501 startThreadRename(threadKey, thread.title);
502 },
503 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title],
504 );
505 const handleRowKeyDown = useCallback(
506 (event: React.KeyboardEvent) => {
507 if (event.key !== "Enter" && event.key !== " ") return;
508 event.preventDefault();
509 navigateToThread(threadRef);
510 },
511 [navigateToThread, threadRef],
512 );
513 const handleRowContextMenu = useCallback(
514 (event: React.MouseEvent) => {
515 event.preventDefault();
516 const hasSelection = useThreadSelectionStore.getState().hasSelection();
517 if (hasSelection && isSelected) {
518 void (async () => {
519 const result = await settlePromise(() =>
520 handleMultiSelectContextMenu({
521 x: event.clientX,
522 y: event.clientY,
523 }),
524 );
525 if (result._tag === "Failure") {
526 const error = squashAtomCommandFailure(result);
527 toastManager.add(
528 stackedThreadToast({
529 type: "error",
530 title: "Thread action failed",
531 description: error instanceof Error ? error.message : "An error occurred.",
532 }),
533 );
534 }
535 })();
536 return;
537 }
538
539 if (hasSelection) {
540 clearSelection();
541 }
542 void (async () => {
543 const result = await settlePromise(() =>
544 handleThreadContextMenu(threadRef, {
545 x: event.clientX,
546 y: event.clientY,
547 }),
548 );
549 if (result._tag === "Failure") {
550 const error = squashAtomCommandFailure(result);
551 toastManager.add(
552 stackedThreadToast({
553 type: "error",
554 title: "Thread action failed",
555 description: error instanceof Error ? error.message : "An error occurred.",
556 }),
557 );
558 }
559 })();
560 },
561 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],
562 );
563 const handlePrClick = useCallback(
564 (event: React.MouseEvent<HTMLButtonElement>) => {
565 if (!prStatus) return;
566 openPrLink(event, prStatus.url);
567 },
568 [openPrLink, prStatus],
569 );
570 const handleRenameInputRef = useCallback(
571 (element: HTMLInputElement | null) => {
572 if (element && renamingInputRef.current !== element) {
573 renamingInputRef.current = element;
574 element.focus();
575 element.select();
576 }
577 },
578 [renamingInputRef],
579 );
580 const handleRenameInputChange = useCallback(
581 (event: React.ChangeEvent<HTMLInputElement>) => {
582 setRenamingTitle(event.target.value);
583 },
584 [setRenamingTitle],
585 );
586 const handleRenameInputKeyDown = useCallback(
587 (event: React.KeyboardEvent<HTMLInputElement>) => {
588 event.stopPropagation();
589 if (event.key === "Enter") {
590 event.preventDefault();
591 renamingCommittedRef.current = true;
592 void commitRename(threadRef, renamingTitle, thread.title);
593 } else if (event.key === "Escape") {
594 event.preventDefault();
595 renamingCommittedRef.current = true;
596 cancelRename();
597 }
598 },
599 [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
600 );
601 const handleRenameInputBlur = useCallback(() => {
602 if (!renamingCommittedRef.current) {
603 void commitRename(threadRef, renamingTitle, thread.title);
604 }
605 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
606 // Keep clicks/double-clicks inside the rename input from bubbling to the row.
607 // Without stopping `dblclick`, double-clicking to select a word would re-fire
608 // the row's rename handler and reset the in-progress edit back to the title.
609 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
610 event.stopPropagation();
611 }, []);
612 const handleConfirmArchiveRef = useCallback(
613 (element: HTMLButtonElement | null) => {
614 if (element) {
615 confirmArchiveButtonRefs.current.set(threadKey, element);
616 } else {
617 confirmArchiveButtonRefs.current.delete(threadKey);
618 }
619 },
620 [confirmArchiveButtonRefs, threadKey],
621 );
622 const stopPropagationOnPointerDown = useCallback(
623 (event: React.PointerEvent<HTMLButtonElement>) => {
624 event.stopPropagation();
625 },
626 [],
627 );
628 const handleConfirmArchiveClick = useCallback(
629 (event: React.MouseEvent<HTMLButtonElement>) => {
630 event.preventDefault();
631 event.stopPropagation();
632 clearConfirmingArchive();
633 void attemptArchiveThread(threadRef);
634 },
635 [attemptArchiveThread, clearConfirmingArchive, threadRef],
636 );
637 const handleStartArchiveConfirmation = useCallback(
638 (event: React.MouseEvent<HTMLButtonElement>) => {
639 event.preventDefault();
640 event.stopPropagation();
641 setConfirmingArchiveThreadKey(threadKey);
642 requestAnimationFrame(() => {
643 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
644 });
645 },
646 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
647 );
648 const handleArchiveImmediateClick = useCallback(
649 (event: React.MouseEvent<HTMLButtonElement>) => {
650 event.preventDefault();
651 event.stopPropagation();
652 void attemptArchiveThread(threadRef);
653 },
654 [attemptArchiveThread, threadRef],
655 );
656 const rowButtonRender = useMemo(() => <div role="button" tabIndex={0} />, []);
657
658 return (
659 <SidebarMenuSubItem
660 className="w-full"
661 data-thread-item
662 onMouseLeave={handleMouseLeave}
663 onBlurCapture={handleBlurCapture}
664 >
665 <SidebarMenuSubButton
666 render={rowButtonRender}
667 size="sm"
668 isActive={isActive}
669 data-testid={`thread-row-${thread.id}`}
670 className={`${resolveThreadRowClassName({
671 isActive,
672 isSelected,
673 })} relative isolate`}
674 onClick={handleRowClick}
675 onDoubleClick={handleRowDoubleClick}
676 onKeyDown={handleRowKeyDown}
677 onContextMenu={handleRowContextMenu}
678 >
679 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
680 {prStatus && (
681 <Tooltip>
682 <TooltipTrigger
683 render={
684 <button
685 type="button"
686 aria-label={prStatus.tooltip}
687 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
688 onClick={handlePrClick}
689 >
690 <ChangeRequestStatusIcon className="size-3" />
691 </button>
692 }
693 />
694 <TooltipPopup side="top">
695 <PrStatusTooltipContent status={prStatus} />
696 </TooltipPopup>
697 </Tooltip>
698 )}
699 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
700 {renamingThreadKey === threadKey ? (
701 <input
702 ref={handleRenameInputRef}
703 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"
704 value={renamingTitle}
705 onChange={handleRenameInputChange}
706 onKeyDown={handleRenameInputKeyDown}
707 onBlur={handleRenameInputBlur}
708 onClick={handleRenameInputClick}
709 onDoubleClick={handleRenameInputClick}
710 />
711 ) : (
712 <Tooltip>
713 <TooltipTrigger
714 render={
715 <span
716 className="min-w-0 flex-1 truncate text-sm"
717 data-testid={`thread-title-${thread.id}`}
718 >
719 {thread.title}
720 </span>
721 }
722 />
723 <TooltipPopup side="top" className="max-w-80 whitespace-normal leading-tight">
724 {thread.title}
725 </TooltipPopup>
726 </Tooltip>
727 )}
728 </div>
729 <div className="ml-auto flex shrink-0 items-center gap-1.5">
730 {discoveredPorts.length > 0 && (
731 <Tooltip>
732 <TooltipTrigger
733 render={
734 <button
735 type="button"
736 aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`}
737 className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400"
738 onClick={handleOpenDiscoveredPort}
739 />
740 }
741 >
742 <Globe2Icon className="size-3" />
743 </TooltipTrigger>
744 <TooltipPopup side="top">
745 Open localhost:{discoveredPorts[0]?.port}
746 {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""}
747 </TooltipPopup>
748 </Tooltip>
749 )}
750 <ThreadWorktreeIndicator thread={thread} />
751 {terminalStatus && (
752 <Tooltip>
753 <TooltipTrigger
754 render={
755 <span
756 role="img"
757 aria-label={terminalStatus.label}
758 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
759 />
760 }
761 >
762 <TerminalIcon
763 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
764 />
765 </TooltipTrigger>
766 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
767 </Tooltip>
768 )}
769 <div
770 className={`flex min-w-12 justify-end ${
771 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
772 }`}
773 >
774 {isConfirmingArchive ? (
775 <button
776 ref={handleConfirmArchiveRef}
777 type="button"
778 data-thread-selection-safe
779 data-testid={`thread-archive-confirm-${thread.id}`}
780 aria-label={`Confirm archive ${thread.title}`}
781 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
782 onPointerDown={stopPropagationOnPointerDown}
783 onClick={handleConfirmArchiveClick}
784 >
785 Confirm
786 </button>
787 ) : !isThreadRunning ? (
788 appSettingsConfirmThreadArchive ? (
789 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
790 <button
791 type="button"
792 data-thread-selection-safe
793 data-testid={`thread-archive-${thread.id}`}
794 aria-label={`Archive ${thread.title}`}
795 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
796 onPointerDown={stopPropagationOnPointerDown}
797 onClick={handleStartArchiveConfirmation}
798 >
799 <ArchiveIcon className="size-3.5" />
800 </button>
801 </div>
802 ) : (
803 <Tooltip>
804 <TooltipTrigger
805 render={
806 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
807 <button
808 type="button"
809 data-thread-selection-safe
810 data-testid={`thread-archive-${thread.id}`}
811 aria-label={`Archive ${thread.title}`}
812 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
813 onPointerDown={stopPropagationOnPointerDown}
814 onClick={handleArchiveImmediateClick}
815 >
816 <ArchiveIcon className="size-3.5" />
817 </button>
818 </div>
819 }
820 />
821 <TooltipPopup side="top">Archive</TooltipPopup>
822 </Tooltip>
823 )
824 ) : null}
825 <span className={threadMetaClassName}>
826 <span className="inline-flex items-center gap-1">
827 {isRemoteThread && !isDesktopLocalThread && (
828 <Tooltip>
829 <TooltipTrigger
830 render={
831 <span
832 aria-label={threadEnvironmentLabel ?? "Remote"}
833 className="inline-flex items-center justify-center"
834 />
835 }
836 >
837 <CloudIcon className="size-3 text-muted-foreground/40" />
838 </TooltipTrigger>
839 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
840 </Tooltip>
841 )}
842 {jumpLabel ? (
843 <Tooltip>
844 <TooltipTrigger
845 render={
846 <span
847 aria-label={jumpLabel}
848 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
849 />
850 }
851 >
852 {jumpLabel}
853 </TooltipTrigger>
854 <TooltipPopup side="top">{jumpLabel}</TooltipPopup>
855 </Tooltip>
856 ) : (
857 <span
858 className={`text-[10px] tabular-nums ${
859 isHighlighted ? "text-foreground" : "text-secondary-label"
860 }`}
861 >
862 {formatRelativeTimeLabel(
863 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
864 )}
865 </span>
866 )}
867 </span>
868 </span>
869 </div>
870 </div>
871 </SidebarMenuSubButton>
872 </SidebarMenuSubItem>
873 );
874});
875
876interface SidebarProjectThreadListProps {
877 projectKey: string;
878 projectExpanded: boolean;
879 hasOverflowingThreads: boolean;
880 hiddenThreadStatus: ThreadStatusPill | null;
881 orderedProjectThreadKeys: readonly string[];
882 renderedThreads: readonly SidebarThreadSummary[];
883 showEmptyThreadState: boolean;
884 shouldShowThreadPanel: boolean;
885 isThreadListExpanded: boolean;
886 projectCwd: string;
887 activeRouteThreadKey: string | null;
888 threadJumpLabelByKey: ReadonlyMap<string, string>;
889 appSettingsConfirmThreadArchive: boolean;
890 renamingThreadKey: string | null;
891 renamingTitle: string;
892 setRenamingTitle: (title: string) => void;
893 startThreadRename: (threadKey: string, title: string) => void;
894 renamingInputRef: React.RefObject<HTMLInputElement | null>;
895 renamingCommittedRef: React.RefObject<boolean>;
896 confirmingArchiveThreadKey: string | null;
897 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
898 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
899 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
900 handleThreadClick: (
901 event: React.MouseEvent,
902 threadRef: ScopedThreadRef,
903 orderedProjectThreadKeys: readonly string[],
904 ) => void;
905 navigateToThread: (threadRef: ScopedThreadRef) => void;
906 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
907 handleThreadContextMenu: (
908 threadRef: ScopedThreadRef,
909 position: { x: number; y: number },
910 ) => Promise<void>;
911 clearSelection: () => void;
912 commitRename: (
913 threadRef: ScopedThreadRef,
914 newTitle: string,
915 originalTitle: string,
916 ) => Promise<void>;
917 cancelRename: () => void;
918 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
919 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
920 expandThreadListForProject: (projectKey: string) => void;
921 collapseThreadListForProject: (projectKey: string) => void;
922}
923
924const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
925 props: SidebarProjectThreadListProps,
926) {
927 const {
928 projectKey,
929 projectExpanded,
930 hasOverflowingThreads,
931 hiddenThreadStatus,
932 orderedProjectThreadKeys,
933 renderedThreads,
934 showEmptyThreadState,
935 shouldShowThreadPanel,
936 isThreadListExpanded,
937 projectCwd,
938 activeRouteThreadKey,
939 threadJumpLabelByKey,
940 appSettingsConfirmThreadArchive,
941 renamingThreadKey,
942 renamingTitle,
943 setRenamingTitle,
944 startThreadRename,
945 renamingInputRef,
946 renamingCommittedRef,
947 confirmingArchiveThreadKey,
948 setConfirmingArchiveThreadKey,
949 confirmArchiveButtonRefs,
950 attachThreadListAutoAnimateRef,
951 handleThreadClick,
952 navigateToThread,
953 handleMultiSelectContextMenu,
954 handleThreadContextMenu,
955 clearSelection,
956 commitRename,
957 cancelRename,
958 attemptArchiveThread,
959 openPrLink,
960 expandThreadListForProject,
961 collapseThreadListForProject,
962 } = props;
963 const showMoreButtonRender = useMemo(() => <button type="button" />, []);
964 const showLessButtonRender = useMemo(() => <button type="button" />, []);
965
966 return (
967 <SidebarMenuSub
968 ref={attachThreadListAutoAnimateRef}
969 className="mx-0.5 my-0 w-full translate-x-0 gap-0.5 overflow-hidden border-l-0 px-1 py-0 sm:mx-1 sm:px-1.5"
970 >
971 {shouldShowThreadPanel && showEmptyThreadState ? (
972 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
973 <div
974 data-thread-selection-safe
975 className="flex h-8 w-full translate-x-0 items-center px-2 text-left text-xs text-sidebar-muted-foreground/75"
976 >
977 <span>No threads yet</span>
978 </div>
979 </SidebarMenuSubItem>
980 ) : null}
981 {shouldShowThreadPanel &&
982 renderedThreads.map((thread) => {
983 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
984 return (
985 <SidebarThreadRow
986 key={threadKey}
987 thread={thread}
988 projectCwd={projectCwd}
989 orderedProjectThreadKeys={orderedProjectThreadKeys}
990 isActive={activeRouteThreadKey === threadKey}
991 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
992 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
993 renamingThreadKey={renamingThreadKey}
994 renamingTitle={renamingTitle}
995 setRenamingTitle={setRenamingTitle}
996 startThreadRename={startThreadRename}
997 renamingInputRef={renamingInputRef}
998 renamingCommittedRef={renamingCommittedRef}
999 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
1000 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
1001 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1002 handleThreadClick={handleThreadClick}
1003 navigateToThread={navigateToThread}
1004 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1005 handleThreadContextMenu={handleThreadContextMenu}
1006 clearSelection={clearSelection}
1007 commitRename={commitRename}
1008 cancelRename={cancelRename}
1009 attemptArchiveThread={attemptArchiveThread}
1010 openPrLink={openPrLink}
1011 />
1012 );
1013 })}
1014
1015 {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && (
1016 <SidebarMenuSubItem className="w-full">
1017 <SidebarMenuSubButton
1018 render={showMoreButtonRender}
1019 data-thread-selection-safe
1020 size="sm"
1021 className="h-8 w-full translate-x-0 justify-start px-2 text-left text-xs text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1022 onClick={() => {
1023 expandThreadListForProject(projectKey);
1024 }}
1025 >
1026 <span className="flex min-w-0 flex-1 items-center gap-2">
1027 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1028 <span>Show more</span>
1029 </span>
1030 </SidebarMenuSubButton>
1031 </SidebarMenuSubItem>
1032 )}
1033 {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
1034 <SidebarMenuSubItem className="w-full">
1035 <SidebarMenuSubButton
1036 render={showLessButtonRender}
1037 data-thread-selection-safe
1038 size="sm"
1039 className="h-8 w-full translate-x-0 justify-start px-2 text-left text-xs text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1040 onClick={() => {
1041 collapseThreadListForProject(projectKey);
1042 }}
1043 >
1044 <span>Show less</span>
1045 </SidebarMenuSubButton>
1046 </SidebarMenuSubItem>
1047 )}
1048 </SidebarMenuSub>
1049 );
1050});
1051
1052interface SidebarProjectItemProps {
1053 project: SidebarProjectSnapshot;
1054 isThreadListExpanded: boolean;
1055 activeRouteThreadKey: string | null;
1056 newThreadShortcutLabel: string | null;
1057 handleNewThread: ReturnType<typeof useNewThreadHandler>;
1058 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
1059 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
1060 threadJumpLabelByKey: ReadonlyMap<string, string>;
1061 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
1062 expandThreadListForProject: (projectKey: string) => void;
1063 collapseThreadListForProject: (projectKey: string) => void;
1064 dragInProgressRef: React.RefObject<boolean>;
1065 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
1066 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
1067 isManualProjectSorting: boolean;
1068 dragHandleProps: SortableProjectHandleProps | null;
1069}
1070
1071const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
1072 const {
1073 project,
1074 isThreadListExpanded,
1075 activeRouteThreadKey,
1076 newThreadShortcutLabel,
1077 handleNewThread,
1078 archiveThread,
1079 deleteThread,
1080 threadJumpLabelByKey,
1081 attachThreadListAutoAnimateRef,
1082 expandThreadListForProject,
1083 collapseThreadListForProject,
1084 dragInProgressRef,
1085 suppressProjectClickAfterDragRef,
1086 suppressProjectClickForContextMenuRef,
1087 isManualProjectSorting,
1088 dragHandleProps,
1089 } = props;
1090 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
1091 (settings) => settings.sidebarThreadSortOrder,
1092 );
1093 const appSettingsConfirmThreadDelete = useClientSettings<boolean>(
1094 (settings) => settings.confirmThreadDelete,
1095 );
1096 const appSettingsConfirmThreadArchive = useClientSettings<boolean>(
1097 (settings) => settings.confirmThreadArchive,
1098 );
1099 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1100 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1101 reportFailure: false,
1102 });
1103 const updateProject = useAtomCommand(projectEnvironment.update, {
1104 reportFailure: false,
1105 });
1106 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1107 reportFailure: false,
1108 });
1109 const updateSettings = useUpdateClientSettings();
1110 const sidebarThreadPreviewCount = useClientSettings<SidebarThreadPreviewCount>(
1111 (settings) => settings.sidebarThreadPreviewCount,
1112 );
1113 const router = useRouter();
1114 const { isMobile, setOpenMobile } = useSidebar();
1115 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
1116 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
1117 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
1118 const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
1119 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
1120 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
1121 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
1122 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1123 threadId: ThreadId;
1124 }>({
1125 onCopy: (ctx) => {
1126 toastManager.add({
1127 type: "success",
1128 title: "Thread ID copied",
1129 description: ctx.threadId,
1130 });
1131 },
1132 onError: (error) => {
1133 toastManager.add(
1134 stackedThreadToast({
1135 type: "error",
1136 title: "Failed to copy thread ID",
1137 description: error instanceof Error ? error.message : "An error occurred.",
1138 }),
1139 );
1140 },
1141 });
1142 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1143 path: string;
1144 }>({
1145 onCopy: (ctx) => {
1146 toastManager.add({
1147 type: "success",
1148 title: "Path copied",
1149 description: ctx.path,
1150 });
1151 },
1152 onError: (error) => {
1153 toastManager.add(
1154 stackedThreadToast({
1155 type: "error",
1156 title: "Failed to copy path",
1157 description: error instanceof Error ? error.message : "An error occurred.",
1158 }),
1159 );
1160 },
1161 });
1162 const openPrLink = useOpenPrLink();
1163 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
1164 const sidebarThreadByKey = useMemo(
1165 () =>
1166 new Map(
1167 sidebarThreads.map(
1168 (thread) =>
1169 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1170 ),
1171 ),
1172 [sidebarThreads],
1173 );
1174 // Keep a ref so callbacks can read the latest map without appearing in
1175 // dependency arrays (avoids invalidating every thread-row memo on each
1176 // thread-list change).
1177 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
1178 sidebarThreadByKeyRef.current = sidebarThreadByKey;
1179 const projectThreads = sidebarThreads;
1180 const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]);
1181 const projectExpanded = useUiStateStore((state) =>
1182 resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys),
1183 );
1184 const threadLastVisitedAts = useUiStateStore(
1185 useShallow((state) =>
1186 projectThreads.map(
1187 (thread) =>
1188 state.threadLastVisitedAtById[
1189 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
1190 ] ?? null,
1191 ),
1192 ),
1193 );
1194 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1195 const [renamingTitle, setRenamingTitle] = useState("");
1196 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1197 const [projectRenameTarget, setProjectRenameTarget] = useState<SidebarProjectGroupMember | null>(
1198 null,
1199 );
1200 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1201 const [projectGroupingTarget, setProjectGroupingTarget] =
1202 useState<SidebarProjectGroupMember | null>(null);
1203 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
1204 SidebarProjectGroupingMode | "inherit"
1205 >("inherit");
1206 const renamingCommittedRef = useRef(false);
1207 const renamingInputRef = useRef<HTMLInputElement | null>(null);
1208 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1209 const memberProjectByScopedKey = useMemo(
1210 () =>
1211 new Map(
1212 project.memberProjects.map((member) => [
1213 scopedProjectKey(scopeProjectRef(member.environmentId, member.id)),
1214 member,
1215 ]),
1216 ),
1217 [project.memberProjects],
1218 );
1219 const memberThreadCountByPhysicalKey = useMemo(() => {
1220 const counts = new Map<string, number>(
1221 project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const),
1222 );
1223 for (const thread of projectThreads) {
1224 const member = memberProjectByScopedKey.get(
1225 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
1226 );
1227 if (!member) {
1228 continue;
1229 }
1230 counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1);
1231 }
1232 return counts;
1233 }, [memberProjectByScopedKey, project.memberProjects, projectThreads]);
1234
1235 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1236 const lastVisitedAtByThreadKey = new Map(
1237 projectThreads.map((thread, index) => [
1238 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1239 threadLastVisitedAts[index] ?? null,
1240 ]),
1241 );
1242 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1243 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1244 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1245 );
1246 return resolveThreadStatusPill({
1247 thread: {
1248 ...thread,
1249 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1250 },
1251 });
1252 };
1253 const visibleProjectThreads = sortThreads(
1254 projectThreads.filter((thread) => thread.archivedAt === null),
1255 threadSortOrder,
1256 );
1257 const projectStatus = resolveProjectStatusIndicator(
1258 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1259 );
1260 return {
1261 orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
1262 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1263 ),
1264 projectStatus,
1265 visibleProjectThreads,
1266 };
1267 }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
1268 const pinnedCollapsedThread = useMemo(() => {
1269 const activeThreadKey = activeRouteThreadKey ?? undefined;
1270 if (!activeThreadKey || projectExpanded) {
1271 return null;
1272 }
1273 return (
1274 visibleProjectThreads.find(
1275 (thread) =>
1276 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
1277 ) ?? null
1278 );
1279 }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
1280
1281 const {
1282 hasOverflowingThreads,
1283 hiddenThreadStatus,
1284 renderedThreads,
1285 showEmptyThreadState,
1286 shouldShowThreadPanel,
1287 } = useMemo(() => {
1288 const lastVisitedAtByThreadKey = new Map(
1289 projectThreads.map((thread, index) => [
1290 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1291 threadLastVisitedAts[index] ?? null,
1292 ]),
1293 );
1294 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1295 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1296 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1297 );
1298 return resolveThreadStatusPill({
1299 thread: {
1300 ...thread,
1301 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1302 },
1303 });
1304 };
1305 const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount;
1306 const previewThreads =
1307 isThreadListExpanded || !hasOverflowingThreads
1308 ? visibleProjectThreads
1309 : visibleProjectThreads.slice(0, sidebarThreadPreviewCount);
1310 const visibleThreadKeys = new Set(
1311 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1312 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1313 ),
1314 );
1315 const renderedThreads = pinnedCollapsedThread
1316 ? [pinnedCollapsedThread]
1317 : visibleProjectThreads.filter((thread) =>
1318 visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1319 );
1320 const hiddenThreads = visibleProjectThreads.filter(
1321 (thread) =>
1322 !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1323 );
1324 return {
1325 hasOverflowingThreads,
1326 hiddenThreadStatus: resolveProjectStatusIndicator(
1327 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1328 ),
1329 renderedThreads,
1330 showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
1331 shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null,
1332 };
1333 }, [
1334 isThreadListExpanded,
1335 pinnedCollapsedThread,
1336 projectExpanded,
1337 projectThreads,
1338 sidebarThreadPreviewCount,
1339 threadLastVisitedAts,
1340 visibleProjectThreads,
1341 ]);
1342
1343 const handleProjectButtonClick = useCallback(
1344 (event: React.MouseEvent<HTMLButtonElement>) => {
1345 if (suppressProjectClickForContextMenuRef.current) {
1346 suppressProjectClickForContextMenuRef.current = false;
1347 event.preventDefault();
1348 event.stopPropagation();
1349 return;
1350 }
1351 if (dragInProgressRef.current) {
1352 event.preventDefault();
1353 event.stopPropagation();
1354 return;
1355 }
1356 if (suppressProjectClickAfterDragRef.current) {
1357 suppressProjectClickAfterDragRef.current = false;
1358 event.preventDefault();
1359 event.stopPropagation();
1360 return;
1361 }
1362 if (useThreadSelectionStore.getState().hasSelection()) {
1363 clearSelection();
1364 }
1365 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1366 },
1367 [
1368 clearSelection,
1369 dragInProgressRef,
1370 projectExpanded,
1371 projectPreferenceKeys,
1372 setProjectExpanded,
1373 suppressProjectClickAfterDragRef,
1374 suppressProjectClickForContextMenuRef,
1375 ],
1376 );
1377
1378 const handleProjectButtonKeyDown = useCallback(
1379 (event: React.KeyboardEvent<HTMLButtonElement>) => {
1380 if (event.key !== "Enter" && event.key !== " ") return;
1381 event.preventDefault();
1382 if (dragInProgressRef.current) {
1383 return;
1384 }
1385 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1386 },
1387 [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded],
1388 );
1389
1390 const handleProjectButtonPointerDownCapture = useCallback(
1391 (event: React.PointerEvent<HTMLButtonElement>) => {
1392 suppressProjectClickForContextMenuRef.current = false;
1393 if (
1394 isContextMenuPointerDown({
1395 button: event.button,
1396 ctrlKey: event.ctrlKey,
1397 isMac: isMacPlatform(navigator.platform),
1398 })
1399 ) {
1400 event.stopPropagation();
1401 }
1402
1403 suppressProjectClickAfterDragRef.current = false;
1404 },
1405 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1406 );
1407
1408 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1409 setProjectRenameTarget(member);
1410 setProjectRenameTitle(member.title);
1411 }, []);
1412
1413 const openProjectGroupingDialog = useCallback(
1414 (member: SidebarProjectGroupMember) => {
1415 const overrideKey = deriveProjectGroupingOverrideKey(member);
1416 setProjectGroupingTarget(member);
1417 setProjectGroupingSelection(
1418 projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit",
1419 );
1420 },
1421 [projectGroupingSettings.sidebarProjectGroupingOverrides],
1422 );
1423
1424 const removeProject = useCallback(
1425 async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => {
1426 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1427 const result = await deleteProject({
1428 environmentId: member.environmentId,
1429 input: {
1430 projectId: member.id,
1431 ...(options.force === true ? { force: true } : {}),
1432 },
1433 });
1434 if (result._tag === "Failure") {
1435 return result;
1436 }
1437 const draftStore = useComposerDraftStore.getState();
1438 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1439 if (projectDraftThread) {
1440 draftStore.clearDraftThread(projectDraftThread.draftId);
1441 }
1442 draftStore.clearProjectDraftThreadId(memberProjectRef);
1443 return result;
1444 },
1445 [deleteProject],
1446 );
1447
1448 const handleRemoveProject = useCallback(
1449 async (member: SidebarProjectGroupMember) => {
1450 const api = readLocalApi();
1451 if (!api) {
1452 return;
1453 }
1454
1455 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1456 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1457 if (memberThreadCount > 0) {
1458 const warningToastId = toastManager.add(
1459 stackedThreadToast({
1460 type: "warning",
1461 title: "Project is not empty",
1462 description: "Delete all threads in this project before removing it.",
1463 actionVariant: "destructive",
1464 actionProps: {
1465 children: "Delete anyway",
1466 onClick: () => {
1467 void (async () => {
1468 toastManager.close(warningToastId);
1469 await new Promise<void>((resolve) => {
1470 window.setTimeout(resolve, 180);
1471 });
1472
1473 const latestProjectThreads = Array.from(
1474 sidebarThreadByKeyRef.current.values(),
1475 ).filter(
1476 (thread) =>
1477 thread.environmentId === memberProjectRef.environmentId &&
1478 thread.projectId === memberProjectRef.projectId,
1479 );
1480 const confirmed = await api.dialogs.confirm(
1481 latestProjectThreads.length > 0
1482 ? [
1483 `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${
1484 latestProjectThreads.length === 1 ? "" : "s"
1485 }?`,
1486 `Path: ${member.workspaceRoot}`,
1487 ...(member.environmentLabel
1488 ? [`Environment: ${member.environmentLabel}`]
1489 : []),
1490 "This permanently clears conversation history for those threads.",
1491 "This removes only this project entry.",
1492 "This action cannot be undone.",
1493 ].join("\n")
1494 : [
1495 `Remove project "${member.title}"?`,
1496 `Path: ${member.workspaceRoot}`,
1497 ...(member.environmentLabel
1498 ? [`Environment: ${member.environmentLabel}`]
1499 : []),
1500 "This removes only this project entry.",
1501 ].join("\n"),
1502 );
1503 if (!confirmed) {
1504 return;
1505 }
1506
1507 const result = await removeProject(member, { force: true });
1508 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1509 const error = squashAtomCommandFailure(result);
1510 toastManager.add(
1511 stackedThreadToast({
1512 type: "error",
1513 title: `Failed to remove "${member.title}"`,
1514 description:
1515 error instanceof Error
1516 ? error.message
1517 : "Unknown error removing project.",
1518 }),
1519 );
1520 }
1521 })().catch((error) => {
1522 const message =
1523 error instanceof Error ? error.message : "Unknown error removing project.";
1524 console.error("Failed to remove project", {
1525 projectId: member.id,
1526 environmentId: member.environmentId,
1527 ...safeErrorLogAttributes(error),
1528 });
1529 toastManager.add(
1530 stackedThreadToast({
1531 type: "error",
1532 title: `Failed to remove "${member.title}"`,
1533 description: message,
1534 }),
1535 );
1536 });
1537 },
1538 },
1539 }),
1540 );
1541 return;
1542 }
1543
1544 const message = [
1545 `Remove project "${member.title}"?`,
1546 `Path: ${member.workspaceRoot}`,
1547 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
1548 "This removes only this project entry.",
1549 ].join("\n");
1550 const confirmed = await api.dialogs.confirm(message);
1551 if (!confirmed) {
1552 return;
1553 }
1554
1555 const result = await removeProject(member);
1556 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1557 const error = squashAtomCommandFailure(result);
1558 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1559 console.error("Failed to remove project", {
1560 projectId: member.id,
1561 environmentId: member.environmentId,
1562 ...safeErrorLogAttributes(error),
1563 });
1564 toastManager.add(
1565 stackedThreadToast({
1566 type: "error",
1567 title: `Failed to remove "${member.title}"`,
1568 description: message,
1569 }),
1570 );
1571 }
1572 },
1573 [memberThreadCountByPhysicalKey, removeProject],
1574 );
1575
1576 const handleProjectButtonContextMenu = useCallback(
1577 (event: React.MouseEvent<HTMLButtonElement>) => {
1578 event.preventDefault();
1579 suppressProjectClickForContextMenuRef.current = true;
1580 void (async () => {
1581 const api = readLocalApi();
1582 if (!api) return;
1583
1584 const actionHandlers = new Map<string, () => Promise<void> | void>();
1585 const makeLeaf = (
1586 action: "rename" | "grouping" | "copy-path" | "delete",
1587 member: SidebarProjectGroupMember,
1588 options?: {
1589 destructive?: boolean;
1590 disabled?: boolean;
1591 },
1592 ): ContextMenuItem<string> => {
1593 const id = `${action}:${member.physicalProjectKey}`;
1594 actionHandlers.set(id, () => {
1595 switch (action) {
1596 case "rename":
1597 openProjectRenameDialog(member);
1598 return;
1599 case "grouping":
1600 openProjectGroupingDialog(member);
1601 return;
1602 case "copy-path":
1603 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot });
1604 return;
1605 case "delete":
1606 return handleRemoveProject(member);
1607 }
1608 });
1609
1610 return {
1611 id,
1612 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1613 ...(options?.destructive ? { destructive: true } : {}),
1614 ...(options?.disabled ? { disabled: true } : {}),
1615 };
1616 };
1617
1618 const buildTargetedItem = (
1619 action: "rename" | "grouping" | "copy-path" | "delete",
1620 label: string,
1621 options?: {
1622 destructive?: boolean;
1623 isDisabled?: (member: SidebarProjectGroupMember) => boolean;
1624 },
1625 ): ContextMenuItem<string> => {
1626 if (project.memberProjects.length === 1) {
1627 const singleMember = project.memberProjects[0]!;
1628 return {
1629 ...makeLeaf(action, singleMember, {
1630 ...(options?.destructive ? { destructive: true } : {}),
1631 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
1632 }),
1633 label,
1634 ...(action === "delete" ? { icon: "trash" } : {}),
1635 };
1636 }
1637
1638 return {
1639 id: `${action}:submenu`,
1640 label,
1641 ...(action === "delete" ? { icon: "trash" } : {}),
1642 children: project.memberProjects.map((member) =>
1643 makeLeaf(action, member, {
1644 ...(options?.destructive ? { destructive: true } : {}),
1645 ...(options?.isDisabled?.(member) ? { disabled: true } : {}),
1646 }),
1647 ),
1648 };
1649 };
1650
1651 const clicked = await api.contextMenu.show(
1652 [
1653 buildTargetedItem("rename", "Rename"),
1654 buildTargetedItem("grouping", "Group into..."),
1655 buildTargetedItem("copy-path", "Copy Path"),
1656 buildTargetedItem("delete", "Remove", {
1657 destructive: true,
1658 }),
1659 ],
1660 {
1661 x: event.clientX,
1662 y: event.clientY,
1663 },
1664 );
1665
1666 if (!clicked) {
1667 return;
1668 }
1669
1670 await actionHandlers.get(clicked)?.();
1671 })();
1672 },
1673 [
1674 copyPathToClipboard,
1675 handleRemoveProject,
1676 openProjectGroupingDialog,
1677 openProjectRenameDialog,
1678 project.groupedProjectCount,
1679 project.memberProjects,
1680 suppressProjectClickForContextMenuRef,
1681 ],
1682 );
1683
1684 const navigateToThread = useCallback(
1685 (threadRef: ScopedThreadRef) => {
1686 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
1687 clearSelection();
1688 }
1689 setSelectionAnchor(scopedThreadKey(threadRef));
1690 if (isMobile) {
1691 setOpenMobile(false);
1692 }
1693 void router.navigate({
1694 to: "/$environmentId/$threadId",
1695 params: buildThreadRouteParams(threadRef),
1696 });
1697 },
1698 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
1699 );
1700
1701 const handleThreadClick = useCallback(
1702 (
1703 event: React.MouseEvent,
1704 threadRef: ScopedThreadRef,
1705 orderedProjectThreadKeys: readonly string[],
1706 ) => {
1707 const isMac = isMacPlatform(navigator.platform);
1708 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1709 const isShiftClick = event.shiftKey;
1710 const threadKey = scopedThreadKey(threadRef);
1711 const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
1712
1713 if (isModClick) {
1714 event.preventDefault();
1715 toggleThreadSelection(threadKey);
1716 return;
1717 }
1718
1719 if (isShiftClick) {
1720 event.preventDefault();
1721 rangeSelectTo(threadKey, orderedProjectThreadKeys);
1722 return;
1723 }
1724
1725 // Ignore the trailing click of a plain double-click so it doesn't navigate
1726 // while a double-click is starting an inline rename. Placed after the
1727 // modifier branches so cmd/shift selection still processes every click.
1728 if (isTrailingDoubleClick(event.detail)) {
1729 return;
1730 }
1731
1732 if (currentSelectionCount > 0) {
1733 clearSelection();
1734 }
1735 setSelectionAnchor(threadKey);
1736 if (isMobile) {
1737 setOpenMobile(false);
1738 }
1739 void router.navigate({
1740 to: "/$environmentId/$threadId",
1741 params: buildThreadRouteParams(threadRef),
1742 });
1743 },
1744 [
1745 clearSelection,
1746 isMobile,
1747 rangeSelectTo,
1748 router,
1749 setOpenMobile,
1750 setSelectionAnchor,
1751 toggleThreadSelection,
1752 ],
1753 );
1754
1755 const handleMultiSelectContextMenu = useCallback(
1756 async (position: { x: number; y: number }) => {
1757 const api = readLocalApi();
1758 if (!api) return;
1759 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1760 if (threadKeys.length === 0) return;
1761 const count = threadKeys.length;
1762 const selectedThreadEntries = threadKeys.flatMap((threadKey) => {
1763 const threadRef = parseScopedThreadKey(threadKey);
1764 const thread = threadRef ? readThreadShell(threadRef) : null;
1765 return threadRef && thread ? [{ threadKey, threadRef, thread }] : [];
1766 });
1767 const hasRunningThread = selectedThreadEntries.some(
1768 ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null,
1769 );
1770
1771 const clicked = await api.contextMenu.show(
1772 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }),
1773 position,
1774 );
1775
1776 if (clicked === "mark-unread") {
1777 for (const { threadKey, thread } of selectedThreadEntries) {
1778 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
1779 }
1780 clearSelection();
1781 return;
1782 }
1783
1784 if (clicked === "archive") {
1785 if (appSettingsConfirmThreadArchive) {
1786 const confirmed = await api.dialogs.confirm(
1787 `Archive ${count} thread${count === 1 ? "" : "s"}?`,
1788 );
1789 if (!confirmed) return;
1790 }
1791
1792 const archiveOutcome = await archiveSelectedThreadEntries({
1793 entries: selectedThreadEntries,
1794 archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }),
1795 });
1796 for (const failure of archiveOutcome.followupFailures) {
1797 if (isAtomCommandInterrupted(failure)) continue;
1798 const error = squashAtomCommandFailure(failure);
1799 toastManager.add(
1800 stackedThreadToast({
1801 type: "error",
1802 title: "Thread archived, but navigation failed",
1803 description: error instanceof Error ? error.message : "An error occurred.",
1804 }),
1805 );
1806 }
1807 if (archiveOutcome.mutationFailure) {
1808 removeFromSelection(archiveOutcome.archivedThreadKeys);
1809 if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) {
1810 const error = squashAtomCommandFailure(archiveOutcome.mutationFailure);
1811 toastManager.add(
1812 stackedThreadToast({
1813 type: "error",
1814 title: "Failed to archive threads",
1815 description: error instanceof Error ? error.message : "An error occurred.",
1816 }),
1817 );
1818 }
1819 return;
1820 }
1821 removeFromSelection(threadKeys);
1822 return;
1823 }
1824
1825 if (clicked !== "delete") return;
1826
1827 if (appSettingsConfirmThreadDelete) {
1828 const confirmed = await api.dialogs.confirm(
1829 [
1830 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1831 "This permanently clears conversation history for these threads.",
1832 ].join("\n"),
1833 );
1834 if (!confirmed) return;
1835 }
1836
1837 const deletedThreadKeys = new Set(threadKeys);
1838 for (const { threadRef } of selectedThreadEntries) {
1839 const result = await deleteThread(threadRef, {
1840 deletedThreadKeys,
1841 });
1842 if (result._tag === "Failure") {
1843 if (!isAtomCommandInterrupted(result)) {
1844 const error = squashAtomCommandFailure(result);
1845 toastManager.add(
1846 stackedThreadToast({
1847 type: "error",
1848 title: "Failed to delete threads",
1849 description: error instanceof Error ? error.message : "An error occurred.",
1850 }),
1851 );
1852 }
1853 return;
1854 }
1855 }
1856 removeFromSelection(threadKeys);
1857 },
1858 [
1859 appSettingsConfirmThreadArchive,
1860 appSettingsConfirmThreadDelete,
1861 archiveThread,
1862 clearSelection,
1863 deleteThread,
1864 markThreadUnread,
1865 removeFromSelection,
1866 ],
1867 );
1868
1869 const createThreadForProjectMember = useCallback(
1870 (member: SidebarProjectGroupMember) => {
1871 if (isMobile) {
1872 setOpenMobile(false);
1873 }
1874 void (async () => {
1875 // No options: branch, worktree, and env mode come from the user's
1876 // configured defaults, never from the currently viewed thread.
1877 const result = await settlePromise(() =>
1878 handleNewThread(scopeProjectRef(member.environmentId, member.id)),
1879 );
1880 if (result._tag === "Failure") {
1881 const error = squashAtomCommandFailure(result);
1882 toastManager.add(
1883 stackedThreadToast({
1884 type: "error",
1885 title: "Could not create thread",
1886 description: error instanceof Error ? error.message : "An error occurred.",
1887 }),
1888 );
1889 }
1890 })();
1891 },
1892 [handleNewThread, isMobile, setOpenMobile],
1893 );
1894
1895 const handleCreateThreadClick = useCallback(
1896 (event: React.MouseEvent<HTMLButtonElement>) => {
1897 event.preventDefault();
1898 event.stopPropagation();
1899
1900 if (project.memberProjects.length === 1) {
1901 createThreadForProjectMember(project.memberProjects[0]!);
1902 return;
1903 }
1904
1905 void (async () => {
1906 const api = readLocalApi();
1907 if (!api) {
1908 return;
1909 }
1910 const clickedResult = await settlePromise(() =>
1911 api.contextMenu.show(
1912 project.memberProjects.map((member) => ({
1913 id: member.physicalProjectKey,
1914 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1915 })),
1916 {
1917 x: event.clientX,
1918 y: event.clientY,
1919 },
1920 ),
1921 );
1922 if (clickedResult._tag === "Failure") {
1923 const error = squashAtomCommandFailure(clickedResult);
1924 toastManager.add(
1925 stackedThreadToast({
1926 type: "error",
1927 title: "Could not choose environment",
1928 description: error instanceof Error ? error.message : "An error occurred.",
1929 }),
1930 );
1931 return;
1932 }
1933 const clicked = clickedResult.value;
1934 if (!clicked) {
1935 return;
1936 }
1937 const targetMember = project.memberProjects.find(
1938 (member) => member.physicalProjectKey === clicked,
1939 );
1940 if (!targetMember) {
1941 return;
1942 }
1943 createThreadForProjectMember(targetMember);
1944 })();
1945 },
1946 [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects],
1947 );
1948
1949 const attemptArchiveThread = useCallback(
1950 async (threadRef: ScopedThreadRef) => {
1951 const result = await archiveThread(threadRef);
1952 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1953 const error = squashAtomCommandFailure(result);
1954 toastManager.add(
1955 stackedThreadToast({
1956 type: "error",
1957 title: "Failed to archive thread",
1958 description: error instanceof Error ? error.message : "An error occurred.",
1959 }),
1960 );
1961 }
1962 },
1963 [archiveThread],
1964 );
1965
1966 const cancelRename = useCallback(() => {
1967 setRenamingThreadKey(null);
1968 renamingInputRef.current = null;
1969 }, []);
1970
1971 const startThreadRename = useCallback((threadKey: string, title: string) => {
1972 setRenamingThreadKey(threadKey);
1973 setRenamingTitle(title);
1974 renamingCommittedRef.current = false;
1975 }, []);
1976
1977 const commitRename = useCallback(
1978 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
1979 const threadKey = scopedThreadKey(threadRef);
1980 const finishRename = () => {
1981 setRenamingThreadKey((current) => {
1982 if (current !== threadKey) return current;
1983 renamingInputRef.current = null;
1984 return null;
1985 });
1986 };
1987
1988 const trimmed = newTitle.trim();
1989 if (trimmed.length === 0) {
1990 toastManager.add({
1991 type: "warning",
1992 title: "Thread title cannot be empty",
1993 });
1994 finishRename();
1995 return;
1996 }
1997 if (trimmed === originalTitle) {
1998 finishRename();
1999 return;
2000 }
2001 const result = await updateThreadMetadata({
2002 environmentId: threadRef.environmentId,
2003 input: {
2004 threadId: threadRef.threadId,
2005 title: trimmed,
2006 },
2007 });
2008 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2009 const error = squashAtomCommandFailure(result);
2010 toastManager.add(
2011 stackedThreadToast({
2012 type: "error",
2013 title: "Failed to rename thread",
2014 description: error instanceof Error ? error.message : "An error occurred.",
2015 }),
2016 );
2017 }
2018 finishRename();
2019 },
2020 [updateThreadMetadata],
2021 );
2022
2023 const closeProjectRenameDialog = useCallback(() => {
2024 setProjectRenameTarget(null);
2025 setProjectRenameTitle("");
2026 }, []);
2027
2028 const submitProjectRename = useCallback(async () => {
2029 if (!projectRenameTarget) {
2030 return;
2031 }
2032
2033 const trimmed = projectRenameTitle.trim();
2034 if (trimmed.length === 0) {
2035 toastManager.add({
2036 type: "warning",
2037 title: "Project title cannot be empty",
2038 });
2039 return;
2040 }
2041
2042 if (trimmed === projectRenameTarget.title) {
2043 closeProjectRenameDialog();
2044 return;
2045 }
2046
2047 const result = await updateProject({
2048 environmentId: projectRenameTarget.environmentId,
2049 input: {
2050 projectId: projectRenameTarget.id,
2051 title: trimmed,
2052 },
2053 });
2054 if (result._tag === "Success") {
2055 closeProjectRenameDialog();
2056 } else if (!isAtomCommandInterrupted(result)) {
2057 const error = squashAtomCommandFailure(result);
2058 toastManager.add(
2059 stackedThreadToast({
2060 type: "error",
2061 title: "Failed to rename project",
2062 description: error instanceof Error ? error.message : "An error occurred.",
2063 }),
2064 );
2065 }
2066 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]);
2067
2068 const closeProjectGroupingDialog = useCallback(() => {
2069 setProjectGroupingTarget(null);
2070 setProjectGroupingSelection("inherit");
2071 }, []);
2072
2073 const saveProjectGroupingPreference = useCallback(() => {
2074 if (!projectGroupingTarget) {
2075 return;
2076 }
2077
2078 const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget);
2079 const nextOverrides = {
2080 ...projectGroupingSettings.sidebarProjectGroupingOverrides,
2081 };
2082 if (projectGroupingSelection === "inherit") {
2083 delete nextOverrides[overrideKey];
2084 } else {
2085 nextOverrides[overrideKey] = projectGroupingSelection;
2086 }
2087 updateSettings({
2088 sidebarProjectGroupingOverrides: nextOverrides,
2089 });
2090 closeProjectGroupingDialog();
2091 }, [
2092 closeProjectGroupingDialog,
2093 projectGroupingSelection,
2094 projectGroupingSettings.sidebarProjectGroupingOverrides,
2095 projectGroupingTarget,
2096 updateSettings,
2097 ]);
2098
2099 const handleThreadContextMenu = useCallback(
2100 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
2101 const api = readLocalApi();
2102 if (!api) return;
2103 const threadKey = scopedThreadKey(threadRef);
2104 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
2105 if (!thread) return;
2106 const threadProject = memberProjectByScopedKey.get(
2107 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
2108 );
2109 const threadWorkspacePath =
2110 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2111 const clicked = await api.contextMenu.show(
2112 [
2113 ...(thread.branch
2114 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
2115 : []),
2116 { id: "rename", label: "Rename thread" },
2117 { id: "mark-unread", label: "Mark unread" },
2118 { id: "copy-path", label: "Copy Path" },
2119 { id: "copy-thread-id", label: "Copy Thread ID" },
2120 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
2121 ],
2122 position,
2123 );
2124
2125 if (clicked === "new-thread-on-branch") {
2126 // Explicit branch carry-over: reuse the thread's worktree when it
2127 // has one, otherwise its branch on the local checkout.
2128 const result = await settlePromise(() =>
2129 handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), {
2130 branch: thread.branch,
2131 worktreePath: thread.worktreePath,
2132 envMode: thread.worktreePath ? "worktree" : "local",
2133 startFromOrigin: false,
2134 }),
2135 );
2136 if (result._tag === "Failure") {
2137 const error = squashAtomCommandFailure(result);
2138 toastManager.add(
2139 stackedThreadToast({
2140 type: "error",
2141 title: "Could not create thread",
2142 description: error instanceof Error ? error.message : "An error occurred.",
2143 }),
2144 );
2145 }
2146 return;
2147 }
2148
2149 if (clicked === "rename") {
2150 startThreadRename(threadKey, thread.title);
2151 return;
2152 }
2153
2154 if (clicked === "mark-unread") {
2155 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2156 return;
2157 }
2158 if (clicked === "copy-path") {
2159 if (!threadWorkspacePath) {
2160 toastManager.add(
2161 stackedThreadToast({
2162 type: "error",
2163 title: "Path unavailable",
2164 description: "This thread does not have a workspace path to copy.",
2165 }),
2166 );
2167 return;
2168 }
2169 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2170 return;
2171 }
2172 if (clicked === "copy-thread-id") {
2173 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
2174 return;
2175 }
2176 if (clicked !== "delete") return;
2177 if (appSettingsConfirmThreadDelete) {
2178 const confirmed = await api.dialogs.confirm(
2179 [
2180 `Delete thread "${thread.title}"?`,
2181 "This permanently clears conversation history for this thread.",
2182 ].join("\n"),
2183 );
2184 if (!confirmed) {
2185 return;
2186 }
2187 }
2188 const result = await deleteThread(threadRef);
2189 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2190 const error = squashAtomCommandFailure(result);
2191 toastManager.add(
2192 stackedThreadToast({
2193 type: "error",
2194 title: "Failed to delete thread",
2195 description: error instanceof Error ? error.message : "An error occurred.",
2196 }),
2197 );
2198 }
2199 },
2200 [
2201 appSettingsConfirmThreadDelete,
2202 copyPathToClipboard,
2203 copyThreadIdToClipboard,
2204 deleteThread,
2205 handleNewThread,
2206 markThreadUnread,
2207 memberProjectByScopedKey,
2208 project.workspaceRoot,
2209 startThreadRename,
2210 ],
2211 );
2212
2213 return (
2214 <>
2215 <div className="group/project-header relative">
2216 <SidebarMenuButton
2217 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
2218 className={`pr-8 group-hover/project-header:bg-sidebar-row-hover group-hover/project-header:text-sidebar-foreground max-sm:pr-14 ${
2219 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : ""
2220 }`}
2221 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
2222 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
2223 onPointerDownCapture={handleProjectButtonPointerDownCapture}
2224 onClick={handleProjectButtonClick}
2225 onKeyDown={handleProjectButtonKeyDown}
2226 onContextMenu={handleProjectButtonContextMenu}
2227 >
2228 {!projectExpanded && projectStatus ? (
2229 <Tooltip>
2230 <TooltipTrigger
2231 render={
2232 <span
2233 aria-label={projectStatus.label}
2234 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2235 />
2236 }
2237 >
2238 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2239 <span
2240 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2241 projectStatus.pulse ? "animate-status-pulse" : ""
2242 }`}
2243 />
2244 </span>
2245 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2246 </TooltipTrigger>
2247 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
2248 </Tooltip>
2249 ) : (
2250 <ChevronRightIcon
2251 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
2252 projectExpanded ? "rotate-90" : ""
2253 }`}
2254 />
2255 )}
2256 <ProjectFavicon environmentId={project.environmentId} cwd={project.workspaceRoot} />
2257 <span className="flex min-w-0 flex-1 items-center gap-2">
2258 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2259 {project.displayName}
2260 </span>
2261 {project.groupedProjectCount > 1 ? (
2262 <span className="shrink-0 text-secondary-label text-[10px]">
2263 {project.groupedProjectCount} projects
2264 </span>
2265 ) : null}
2266 </span>
2267 </SidebarMenuButton>
2268 {/* Environment badge – visible by default, crossfades with the
2269 "new thread" button on hover using the same pointer-events +
2270 opacity pattern as the thread row archive/timestamp swap. */}
2271 {project.environmentPresence === "remote-only" && (
2272 <Tooltip>
2273 <TooltipTrigger
2274 render={
2275 <span
2276 aria-label={
2277 project.allRemoteMembersAreDesktopLocal
2278 ? "Local sandbox project"
2279 : "Remote project"
2280 }
2281 className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100"
2282 />
2283 }
2284 >
2285 {project.allRemoteMembersAreDesktopLocal ? (
2286 <ContainerIcon className="size-3" />
2287 ) : (
2288 <CloudIcon className="size-3" />
2289 )}
2290 </TooltipTrigger>
2291 <TooltipPopup side="top">
2292 {project.allRemoteMembersAreDesktopLocal
2293 ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}`
2294 : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`}
2295 </TooltipPopup>
2296 </Tooltip>
2297 )}
2298 <Tooltip>
2299 <TooltipTrigger
2300 render={
2301 <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
2302 <button
2303 type="button"
2304 aria-label={`Create new thread in ${project.displayName}`}
2305 data-testid="new-thread-button"
2306 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
2307 onClick={handleCreateThreadClick}
2308 >
2309 <SquarePenIcon className="size-3.5" />
2310 </button>
2311 </div>
2312 }
2313 />
2314 <TooltipPopup side="top">
2315 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
2316 </TooltipPopup>
2317 </Tooltip>
2318 </div>
2319
2320 <SidebarProjectThreadList
2321 projectKey={project.projectKey}
2322 projectExpanded={projectExpanded}
2323 hasOverflowingThreads={hasOverflowingThreads}
2324 hiddenThreadStatus={hiddenThreadStatus}
2325 orderedProjectThreadKeys={orderedProjectThreadKeys}
2326 renderedThreads={renderedThreads}
2327 showEmptyThreadState={showEmptyThreadState}
2328 shouldShowThreadPanel={shouldShowThreadPanel}
2329 isThreadListExpanded={isThreadListExpanded}
2330 projectCwd={project.workspaceRoot}
2331 activeRouteThreadKey={activeRouteThreadKey}
2332 threadJumpLabelByKey={threadJumpLabelByKey}
2333 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
2334 renamingThreadKey={renamingThreadKey}
2335 renamingTitle={renamingTitle}
2336 setRenamingTitle={setRenamingTitle}
2337 startThreadRename={startThreadRename}
2338 renamingInputRef={renamingInputRef}
2339 renamingCommittedRef={renamingCommittedRef}
2340 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
2341 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
2342 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
2343 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2344 handleThreadClick={handleThreadClick}
2345 navigateToThread={navigateToThread}
2346 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
2347 handleThreadContextMenu={handleThreadContextMenu}
2348 clearSelection={clearSelection}
2349 commitRename={commitRename}
2350 cancelRename={cancelRename}
2351 attemptArchiveThread={attemptArchiveThread}
2352 openPrLink={openPrLink}
2353 expandThreadListForProject={expandThreadListForProject}
2354 collapseThreadListForProject={collapseThreadListForProject}
2355 />
2356
2357 <Dialog
2358 open={projectRenameTarget !== null}
2359 onOpenChange={(open) => {
2360 if (!open) {
2361 closeProjectRenameDialog();
2362 }
2363 }}
2364 >
2365 <DialogPopup className="max-w-lg">
2366 <DialogHeader>
2367 <DialogTitle>Rename project</DialogTitle>
2368 <DialogDescription>
2369 {projectRenameTarget
2370 ? `Update the title for ${projectRenameTarget.workspaceRoot}.`
2371 : "Update the project title."}
2372 </DialogDescription>
2373 </DialogHeader>
2374 <DialogPanel className="space-y-4">
2375 <div className="grid gap-1.5">
2376 <span className="text-xs font-medium text-foreground">Project title</span>
2377 <Input
2378 aria-label="Project title"
2379 value={projectRenameTitle}
2380 onChange={(event) => setProjectRenameTitle(event.target.value)}
2381 onKeyDown={(event) => {
2382 if (event.key === "Enter") {
2383 event.preventDefault();
2384 void submitProjectRename();
2385 }
2386 }}
2387 />
2388 </div>
2389 {projectRenameTarget?.environmentLabel ? (
2390 <p className="text-xs text-muted-foreground">
2391 Environment: {projectRenameTarget.environmentLabel}
2392 </p>
2393 ) : null}
2394 </DialogPanel>
2395 <DialogFooter>
2396 <Button variant="outline" onClick={closeProjectRenameDialog}>
2397 Cancel
2398 </Button>
2399 <Button onClick={() => void submitProjectRename()}>Save</Button>
2400 </DialogFooter>
2401 </DialogPopup>
2402 </Dialog>
2403
2404 <Dialog
2405 open={projectGroupingTarget !== null}
2406 onOpenChange={(open) => {
2407 if (!open) {
2408 closeProjectGroupingDialog();
2409 }
2410 }}
2411 >
2412 <DialogPopup className="max-w-lg">
2413 <DialogHeader>
2414 <DialogTitle>Project grouping</DialogTitle>
2415 <DialogDescription>
2416 {projectGroupingTarget
2417 ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.`
2418 : "Choose how this project should be grouped in the sidebar."}
2419 </DialogDescription>
2420 </DialogHeader>
2421 <DialogPanel className="space-y-4">
2422 <div className="grid gap-1.5">
2423 <span className="text-xs font-medium text-foreground">Grouping rule</span>
2424 <Select
2425 value={projectGroupingSelection}
2426 onValueChange={(value) => {
2427 if (
2428 value === "inherit" ||
2429 value === "repository" ||
2430 value === "repository_path" ||
2431 value === "separate"
2432 ) {
2433 setProjectGroupingSelection(value);
2434 }
2435 }}
2436 >
2437 <SelectTrigger className="w-full" aria-label="Project grouping rule">
2438 <SelectValue>
2439 {projectGroupingSelection === "inherit"
2440 ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
2441 : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]}
2442 </SelectValue>
2443 </SelectTrigger>
2444 <SelectPopup align="end" alignItemWithTrigger={false}>
2445 <SelectItem hideIndicator value="inherit">
2446 Use global default
2447 </SelectItem>
2448 <SelectItem hideIndicator value="repository">
2449 {PROJECT_GROUPING_MODE_LABELS.repository}
2450 </SelectItem>
2451 <SelectItem hideIndicator value="repository_path">
2452 {PROJECT_GROUPING_MODE_LABELS.repository_path}
2453 </SelectItem>
2454 <SelectItem hideIndicator value="separate">
2455 {PROJECT_GROUPING_MODE_LABELS.separate}
2456 </SelectItem>
2457 </SelectPopup>
2458 </Select>
2459 </div>
2460 <p className="text-xs text-muted-foreground">
2461 {projectGroupingSelection === "inherit"
2462 ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode)
2463 : projectGroupingModeDescription(projectGroupingSelection)}
2464 </p>
2465 </DialogPanel>
2466 <DialogFooter>
2467 <Button variant="outline" onClick={closeProjectGroupingDialog}>
2468 Cancel
2469 </Button>
2470 <Button onClick={saveProjectGroupingPreference}>Save</Button>
2471 </DialogFooter>
2472 </DialogPopup>
2473 </Dialog>
2474 </>
2475 );
2476});
2477
2478const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
2479 return (
2480 <SidebarMenuItem className="rounded-md">
2481 <SidebarProjectItem {...props} />
2482 </SidebarMenuItem>
2483 );
2484});
2485
2486function LocalSecondaryStatus() {
2487 const { environments } = useEnvironments();
2488 // The desktop reports which local secondary backends (e.g. the WSL backend)
2489 // exist; the hook polls because the bridge has no change event. A backend that
2490 // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we
2491 // surface "Connecting" straight from the bootstrap list and clear it once the
2492 // matching environment reports a connected phase.
2493 const secondaries = useDesktopLocalBootstraps();
2494
2495 // Connected desktop-local environments keyed by their backend URL so we can
2496 // match a bootstrap (which only knows the URL) to its connection phase.
2497 const localEnvByUrl = useMemo(() => {
2498 const map = new Map<string, { phase: string; error: string | null }>();
2499 for (const environment of environments) {
2500 if (
2501 isDesktopLocalConnectionTarget(environment.entry.target) &&
2502 environment.displayUrl !== null
2503 ) {
2504 map.set(environment.displayUrl, {
2505 phase: environment.connection.phase,
2506 error: environment.connection.error,
2507 });
2508 }
2509 }
2510 return map;
2511 }, [environments]);
2512
2513 const connecting: string[] = [];
2514 const failed: Array<{ label: string; error: string | null }> = [];
2515 for (const bootstrap of secondaries) {
2516 const env =
2517 bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined;
2518 if (env?.phase === "connected") {
2519 continue;
2520 }
2521 if (env?.phase === "error") {
2522 failed.push({ label: bootstrap.label, error: env.error });
2523 continue;
2524 }
2525 connecting.push(bootstrap.label);
2526 }
2527
2528 if (connecting.length === 0 && failed.length === 0) {
2529 return null;
2530 }
2531
2532 return (
2533 <SidebarGroup className="px-2 pt-2 pb-0">
2534 {connecting.length > 0 ? (
2535 <Alert
2536 variant="default"
2537 className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground"
2538 >
2539 <LoaderIcon className="animate-spin" />
2540 <AlertTitle className="text-xs font-medium text-foreground">
2541 Connecting {connecting.join(", ")}
2542 </AlertTitle>
2543 </Alert>
2544 ) : null}
2545 {failed.length > 0 ? (
2546 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2547 <TriangleAlertIcon />
2548 <AlertTitle>Couldn't connect {failed.map((entry) => entry.label).join(", ")}</AlertTitle>
2549 <AlertDescription>
2550 {failed
2551 .map((entry) => entry.error)
2552 .filter(Boolean)
2553 .join("; ") || "The backend didn't respond."}
2554 </AlertDescription>
2555 </Alert>
2556 ) : null}
2557 </SidebarGroup>
2558 );
2559}
2560
2561type SortableProjectHandleProps = Pick<
2562 ReturnType<typeof useSortable>,
2563 "attributes" | "listeners" | "setActivatorNodeRef"
2564>;
2565
2566function ProjectSortMenu({
2567 projectSortOrder,
2568 threadSortOrder,
2569 threadPreviewCount,
2570 onProjectSortOrderChange,
2571 onThreadSortOrderChange,
2572 onThreadPreviewCountChange,
2573}: {
2574 projectSortOrder: SidebarProjectSortOrder;
2575 threadSortOrder: SidebarThreadSortOrder;
2576 threadPreviewCount: SidebarThreadPreviewCount;
2577 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
2578 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2579 onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void;
2580}) {
2581 const handleThreadPreviewCountChange = useCallback(
2582 (nextValue: number | null) => {
2583 if (nextValue === null) {
2584 return;
2585 }
2586
2587 const clampedValue = clampSidebarThreadPreviewCount(nextValue);
2588 if (clampedValue !== threadPreviewCount) {
2589 onThreadPreviewCountChange(clampedValue);
2590 }
2591 },
2592 [onThreadPreviewCountChange, threadPreviewCount],
2593 );
2594
2595 return (
2596 <Menu>
2597 <Tooltip>
2598 <TooltipTrigger
2599 render={
2600 <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" />
2601 }
2602 >
2603 <ArrowUpDownIcon className="size-3.5" />
2604 </TooltipTrigger>
2605 <TooltipPopup side="right">Sidebar options</TooltipPopup>
2606 </Tooltip>
2607 <MenuPopup align="end" side="bottom" className="min-w-52">
2608 <MenuGroup>
2609 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
2610 Sort projects
2611 </div>
2612 <MenuRadioGroup
2613 value={projectSortOrder}
2614 onValueChange={(value) => {
2615 onProjectSortOrderChange(value as SidebarProjectSortOrder);
2616 }}
2617 >
2618 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
2619 ([value, label]) => (
2620 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
2621 {label}
2622 </MenuRadioItem>
2623 ),
2624 )}
2625 </MenuRadioGroup>
2626 </MenuGroup>
2627 <MenuGroup>
2628 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
2629 Sort threads
2630 </div>
2631 <MenuRadioGroup
2632 value={threadSortOrder}
2633 onValueChange={(value) => {
2634 onThreadSortOrderChange(value as SidebarThreadSortOrder);
2635 }}
2636 >
2637 {(
2638 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
2639 ).map(([value, label]) => (
2640 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
2641 {label}
2642 </MenuRadioItem>
2643 ))}
2644 </MenuRadioGroup>
2645 </MenuGroup>
2646 <MenuGroup>
2647 <div className="px-2 pt-2 pb-1 text-muted-foreground sm:text-xs font-medium">
2648 Visible threads
2649 </div>
2650 <div className="px-2 py-1">
2651 <NumberField
2652 aria-label="Visible thread count"
2653 className="w-28 gap-0"
2654 max={MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
2655 min={MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
2656 onValueChange={handleThreadPreviewCountChange}
2657 size="sm"
2658 step={1}
2659 value={threadPreviewCount}
2660 >
2661 <NumberFieldGroup className="h-7 rounded-md sm:h-6.5">
2662 <NumberFieldDecrement
2663 aria-label="Decrease visible thread count"
2664 className="px-2 sm:px-2 [&_svg]:size-3.5"
2665 />
2666 <NumberFieldInput
2667 aria-label="Visible thread count"
2668 className="h-7 w-9 grow-0 px-0 text-xs leading-7 sm:h-6.5 sm:leading-6.5"
2669 inputMode="numeric"
2670 onKeyDownCapture={(event) => {
2671 event.stopPropagation();
2672 }}
2673 />
2674 <NumberFieldIncrement
2675 aria-label="Increase visible thread count"
2676 className="px-2 sm:px-2 [&_svg]:size-3.5"
2677 />
2678 </NumberFieldGroup>
2679 </NumberField>
2680 </div>
2681 </MenuGroup>
2682 </MenuPopup>
2683 </Menu>
2684 );
2685}
2686
2687function SortableProjectItem({
2688 projectId,
2689 disabled = false,
2690 children,
2691}: {
2692 projectId: string;
2693 disabled?: boolean;
2694 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
2695}) {
2696 const {
2697 attributes,
2698 listeners,
2699 setActivatorNodeRef,
2700 setNodeRef,
2701 transform,
2702 transition,
2703 isDragging,
2704 isOver,
2705 } = useSortable({ id: projectId, disabled });
2706 return (
2707 <li
2708 ref={setNodeRef}
2709 style={{
2710 transform: CSS.Translate.toString(transform),
2711 transition,
2712 }}
2713 className={`group/menu-item relative rounded-md ${
2714 isDragging ? "z-20 opacity-80" : ""
2715 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
2716 data-sidebar="menu-item"
2717 data-slot="sidebar-menu-item"
2718 >
2719 {children({ attributes, listeners, setActivatorNodeRef })}
2720 </li>
2721 );
2722}
2723
2724interface SidebarProjectsContentProps {
2725 showArm64IntelBuildWarning: boolean;
2726 arm64IntelBuildWarningDescription: string | null;
2727 desktopUpdateButtonAction: "download" | "install" | "none";
2728 desktopUpdateButtonDisabled: boolean;
2729 handleDesktopUpdateButtonClick: () => void;
2730 projectSortOrder: SidebarProjectSortOrder;
2731 threadSortOrder: SidebarThreadSortOrder;
2732 threadPreviewCount: SidebarThreadPreviewCount;
2733 updateSettings: ReturnType<typeof useUpdateClientSettings>;
2734 openAddProject: () => void;
2735 isManualProjectSorting: boolean;
2736 projectDnDSensors: ReturnType<typeof useSensors>;
2737 projectCollisionDetection: CollisionDetection;
2738 handleProjectDragStart: (event: DragStartEvent) => void;
2739 handleProjectDragEnd: (event: DragEndEvent) => void;
2740 handleProjectDragCancel: (event: DragCancelEvent) => void;
2741 handleNewThread: ReturnType<typeof useNewThreadHandler>;
2742 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
2743 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
2744 sortedProjects: readonly SidebarProjectSnapshot[];
2745 expandedThreadListsByProject: ReadonlySet<string>;
2746 activeRouteProjectKey: string | null;
2747 routeThreadKey: string | null;
2748 newThreadShortcutLabel: string | null;
2749 commandPaletteShortcutLabel: string | null;
2750 threadJumpLabelByKey: ReadonlyMap<string, string>;
2751 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
2752 expandThreadListForProject: (projectKey: string) => void;
2753 collapseThreadListForProject: (projectKey: string) => void;
2754 dragInProgressRef: React.RefObject<boolean>;
2755 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
2756 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
2757 attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
2758 projectsLength: number;
2759}
2760
2761const SidebarProjectsContent = memo(function SidebarProjectsContent(
2762 props: SidebarProjectsContentProps,
2763) {
2764 const {
2765 showArm64IntelBuildWarning,
2766 arm64IntelBuildWarningDescription,
2767 desktopUpdateButtonAction,
2768 desktopUpdateButtonDisabled,
2769 handleDesktopUpdateButtonClick,
2770 projectSortOrder,
2771 threadSortOrder,
2772 threadPreviewCount,
2773 updateSettings,
2774 openAddProject,
2775 isManualProjectSorting,
2776 projectDnDSensors,
2777 projectCollisionDetection,
2778 handleProjectDragStart,
2779 handleProjectDragEnd,
2780 handleProjectDragCancel,
2781 handleNewThread,
2782 archiveThread,
2783 deleteThread,
2784 sortedProjects,
2785 expandedThreadListsByProject,
2786 activeRouteProjectKey,
2787 routeThreadKey,
2788 newThreadShortcutLabel,
2789 commandPaletteShortcutLabel,
2790 threadJumpLabelByKey,
2791 attachThreadListAutoAnimateRef,
2792 expandThreadListForProject,
2793 collapseThreadListForProject,
2794 dragInProgressRef,
2795 suppressProjectClickAfterDragRef,
2796 suppressProjectClickForContextMenuRef,
2797 attachProjectListAutoAnimateRef,
2798 projectsLength,
2799 } = props;
2800
2801 const handleProjectSortOrderChange = useCallback(
2802 (sortOrder: SidebarProjectSortOrder) => {
2803 updateSettings({ sidebarProjectSortOrder: sortOrder });
2804 },
2805 [updateSettings],
2806 );
2807 const handleThreadSortOrderChange = useCallback(
2808 (sortOrder: SidebarThreadSortOrder) => {
2809 updateSettings({ sidebarThreadSortOrder: sortOrder });
2810 },
2811 [updateSettings],
2812 );
2813 const handleThreadPreviewCountChange = useCallback(
2814 (count: SidebarThreadPreviewCount) => {
2815 updateSettings({ sidebarThreadPreviewCount: count });
2816 },
2817 [updateSettings],
2818 );
2819
2820 return (
2821 <SidebarContent
2822 className="gap-0"
2823 fixedHeader={
2824 // Lifted above the stage backdrop, whose fade bleeds below the
2825 // header and would otherwise paint across the search row's outline.
2826 <SidebarGroup className="relative z-[1] px-2 pt-2 pb-1">
2827 <SidebarMenu>
2828 <SidebarMenuItem>
2829 <CommandDialogTrigger
2830 render={
2831 <SidebarMenuButton
2832 className="focus-visible:ring-0"
2833 data-testid="command-palette-trigger"
2834 />
2835 }
2836 >
2837 <SearchIcon />
2838 <span className="flex-1 truncate">Search</span>
2839 {commandPaletteShortcutLabel ? (
2840 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
2841 {commandPaletteShortcutLabel}
2842 </Kbd>
2843 ) : null}
2844 </CommandDialogTrigger>
2845 </SidebarMenuItem>
2846 </SidebarMenu>
2847 </SidebarGroup>
2848 }
2849 >
2850 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2851 <SidebarGroup className="px-2 pt-2 pb-0">
2852 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2853 <TriangleAlertIcon />
2854 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2855 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2856 {desktopUpdateButtonAction !== "none" ? (
2857 <AlertAction>
2858 <Button
2859 size="xs"
2860 variant="outline"
2861 disabled={desktopUpdateButtonDisabled}
2862 onClick={handleDesktopUpdateButtonClick}
2863 >
2864 {desktopUpdateButtonAction === "download"
2865 ? "Download ARM build"
2866 : "Install ARM build"}
2867 </Button>
2868 </AlertAction>
2869 ) : null}
2870 </Alert>
2871 </SidebarGroup>
2872 ) : null}
2873 <LocalSecondaryStatus />
2874 <SidebarGroup className="px-2 py-2">
2875 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2876 <span className="text-xs font-medium text-sidebar-muted-foreground/80">Projects</span>
2877 <div className="flex items-center gap-1">
2878 <ProjectSortMenu
2879 projectSortOrder={projectSortOrder}
2880 threadSortOrder={threadSortOrder}
2881 threadPreviewCount={threadPreviewCount}
2882 onProjectSortOrderChange={handleProjectSortOrderChange}
2883 onThreadSortOrderChange={handleThreadSortOrderChange}
2884 onThreadPreviewCountChange={handleThreadPreviewCountChange}
2885 />
2886 <Tooltip>
2887 <TooltipTrigger
2888 render={
2889 <button
2890 type="button"
2891 aria-label="Add project"
2892 data-testid="sidebar-add-project-trigger"
2893 className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground"
2894 onClick={openAddProject}
2895 />
2896 }
2897 >
2898 <FolderPlusIcon className="size-3.5" />
2899 </TooltipTrigger>
2900 <TooltipPopup side="right">Add project</TooltipPopup>
2901 </Tooltip>
2902 </div>
2903 </div>
2904
2905 {isManualProjectSorting ? (
2906 <DndContext
2907 sensors={projectDnDSensors}
2908 collisionDetection={projectCollisionDetection}
2909 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2910 onDragStart={handleProjectDragStart}
2911 onDragEnd={handleProjectDragEnd}
2912 onDragCancel={handleProjectDragCancel}
2913 >
2914 <SidebarMenu>
2915 <SortableContext
2916 items={sortedProjects.map((project) => project.projectKey)}
2917 strategy={verticalListSortingStrategy}
2918 >
2919 {sortedProjects.map((project) => (
2920 <SortableProjectItem key={project.projectKey} projectId={project.projectKey}>
2921 {(dragHandleProps) => (
2922 <SidebarProjectItem
2923 project={project}
2924 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2925 activeRouteThreadKey={
2926 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2927 }
2928 newThreadShortcutLabel={newThreadShortcutLabel}
2929 handleNewThread={handleNewThread}
2930 archiveThread={archiveThread}
2931 deleteThread={deleteThread}
2932 threadJumpLabelByKey={threadJumpLabelByKey}
2933 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2934 expandThreadListForProject={expandThreadListForProject}
2935 collapseThreadListForProject={collapseThreadListForProject}
2936 dragInProgressRef={dragInProgressRef}
2937 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2938 suppressProjectClickForContextMenuRef={
2939 suppressProjectClickForContextMenuRef
2940 }
2941 isManualProjectSorting={isManualProjectSorting}
2942 dragHandleProps={dragHandleProps}
2943 />
2944 )}
2945 </SortableProjectItem>
2946 ))}
2947 </SortableContext>
2948 </SidebarMenu>
2949 </DndContext>
2950 ) : (
2951 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2952 {sortedProjects.map((project) => (
2953 <SidebarProjectListRow
2954 key={project.projectKey}
2955 project={project}
2956 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2957 activeRouteThreadKey={
2958 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2959 }
2960 newThreadShortcutLabel={newThreadShortcutLabel}
2961 handleNewThread={handleNewThread}
2962 archiveThread={archiveThread}
2963 deleteThread={deleteThread}
2964 threadJumpLabelByKey={threadJumpLabelByKey}
2965 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2966 expandThreadListForProject={expandThreadListForProject}
2967 collapseThreadListForProject={collapseThreadListForProject}
2968 dragInProgressRef={dragInProgressRef}
2969 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2970 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2971 isManualProjectSorting={isManualProjectSorting}
2972 dragHandleProps={null}
2973 />
2974 ))}
2975 </SidebarMenu>
2976 )}
2977
2978 {projectsLength === 0 && (
2979 <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div>
2980 )}
2981 </SidebarGroup>
2982 </SidebarContent>
2983 );
2984});
2985
2986export default function LegacySidebar() {
2987 const projects = useProjects();
2988 const sidebarThreads = useThreadShells();
2989 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
2990 const projectOrder = useUiStateStore((store) => store.projectOrder);
2991 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
2992 const navigate = useNavigate();
2993 const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
2994 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
2995 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
2996 const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount);
2997 const updateSettings = useUpdateClientSettings();
2998 const handleNewThread = useNewThreadHandler();
2999 const { archiveThread, deleteThread } = useThreadActions();
3000 const { isMobile, setOpenMobile } = useSidebar();
3001 const routeTarget = useParams({
3002 strict: false,
3003 select: (params) => resolveThreadRouteTarget(params),
3004 });
3005 const routeDraftThread = useComposerDraftStore((store) =>
3006 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
3007 );
3008 const routeThreadRef = useMemo(
3009 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
3010 [routeDraftThread, routeTarget],
3011 );
3012 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
3013 const routeTerminalOpen = useTerminalUiStateStore((state) =>
3014 routeThreadRef
3015 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
3016 : false,
3017 );
3018 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3019 const openAddProjectCommandPalette = useCallback(
3020 () => openCommandPalette({ open: "add-project" }),
3021 [],
3022 );
3023 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
3024 ReadonlySet<string>
3025 >(() => new Set());
3026 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
3027 const dragInProgressRef = useRef(false);
3028 const suppressProjectClickAfterDragRef = useRef(false);
3029 const suppressProjectClickForContextMenuRef = useRef(false);
3030 const desktopUpdateState = useDesktopUpdateState();
3031 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
3032 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
3033 const platform = navigator.platform;
3034 const shortcutModifiers = useShortcutModifierState();
3035 const { environments } = useEnvironments();
3036 const primaryEnvironmentId = usePrimaryEnvironmentId();
3037 const environmentLabelById = useMemo(
3038 () =>
3039 new Map(
3040 environments.map((environment) => [environment.environmentId, environment.label] as const),
3041 ),
3042 [environments],
3043 );
3044 const desktopLocalEnvironmentIds = useMemo(
3045 () =>
3046 new Set(
3047 environments
3048 .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target))
3049 .map((environment) => environment.environmentId),
3050 ),
3051 [environments],
3052 );
3053 const orderedProjects = useMemo(() => {
3054 return orderItemsByPreferredIds({
3055 items: projects,
3056 preferredIds: projectOrder,
3057 getId: getProjectOrderKey,
3058 getPreferenceIds: (project) => [
3059 getProjectOrderKey(project),
3060 legacyProjectCwdPreferenceKey(project.workspaceRoot),
3061 ],
3062 });
3063 }, [projectOrder, projects]);
3064
3065 // Build a mapping from physical project key → logical project key for
3066 // cross-environment grouping. Projects that share a repositoryIdentity
3067 // canonicalKey are treated as one logical project in the sidebar.
3068 const physicalToLogicalKey = useMemo(() => {
3069 return buildPhysicalToLogicalProjectKeyMap({
3070 projects: orderedProjects,
3071 settings: projectGroupingSettings,
3072 primaryEnvironmentId,
3073 });
3074 }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
3075 const projectPhysicalKeyByScopedRef = useMemo(
3076 () =>
3077 new Map(
3078 orderedProjects.map((project) => [
3079 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
3080 derivePhysicalProjectKey(project),
3081 ]),
3082 ),
3083 [orderedProjects],
3084 );
3085
3086 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
3087 return buildSidebarProjectSnapshots({
3088 projects: orderedProjects,
3089 settings: projectGroupingSettings,
3090 primaryEnvironmentId,
3091 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
3092 isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId),
3093 });
3094 }, [
3095 environmentLabelById,
3096 desktopLocalEnvironmentIds,
3097 orderedProjects,
3098 projectGroupingSettings,
3099 primaryEnvironmentId,
3100 ]);
3101
3102 const sidebarProjectByKey = useMemo(
3103 () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
3104 [sidebarProjects],
3105 );
3106 const sidebarThreadByKey = useMemo(
3107 () =>
3108 new Map(
3109 sidebarThreads.map(
3110 (thread) =>
3111 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
3112 ),
3113 ),
3114 [sidebarThreads],
3115 );
3116 // Resolve the active route's project key to a logical key so it matches the
3117 // sidebar's grouped project entries.
3118 const activeRouteProjectKey = useMemo(() => {
3119 if (!routeThreadKey) {
3120 return null;
3121 }
3122 const activeThread = sidebarThreadByKey.get(routeThreadKey);
3123 if (!activeThread) return null;
3124 const physicalKey =
3125 projectPhysicalKeyByScopedRef.get(
3126 scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
3127 ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId));
3128 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3129 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3130
3131 // Group threads by logical project key so all threads from grouped projects
3132 // are displayed together.
3133 const threadsByProjectKey = useMemo(() => {
3134 const next = new Map<string, SidebarThreadSummary[]>();
3135 for (const thread of sidebarThreads) {
3136 const physicalKey =
3137 projectPhysicalKeyByScopedRef.get(
3138 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3139 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3140 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3141 const existing = next.get(logicalKey);
3142 if (existing) {
3143 existing.push(thread);
3144 } else {
3145 next.set(logicalKey, [thread]);
3146 }
3147 }
3148 return next;
3149 }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3150 const getCurrentSidebarShortcutContext = useCallback(
3151 () => ({
3152 terminalFocus: isTerminalFocused(),
3153 terminalOpen: routeTerminalOpen,
3154 modelPickerOpen: isModelPickerOpen(),
3155 }),
3156 [routeTerminalOpen],
3157 );
3158 const newThreadShortcutLabelOptions = useMemo(
3159 () => ({
3160 platform,
3161 context: {
3162 terminalFocus: false,
3163 terminalOpen: false,
3164 },
3165 }),
3166 [platform],
3167 );
3168 const newThreadShortcutLabel =
3169 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
3170 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
3171
3172 const navigateToThread = useCallback(
3173 (threadRef: ScopedThreadRef) => {
3174 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
3175 clearSelection();
3176 }
3177 setSelectionAnchor(scopedThreadKey(threadRef));
3178 if (isMobile) {
3179 setOpenMobile(false);
3180 }
3181 void navigate({
3182 to: "/$environmentId/$threadId",
3183 params: buildThreadRouteParams(threadRef),
3184 });
3185 },
3186 [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor],
3187 );
3188
3189 const projectDnDSensors = useSensors(
3190 useSensor(PointerSensor, {
3191 activationConstraint: { distance: 6 },
3192 }),
3193 );
3194 const projectCollisionDetection = useCallback<CollisionDetection>((args) => {
3195 const pointerCollisions = pointerWithin(args);
3196 if (pointerCollisions.length > 0) {
3197 return pointerCollisions;
3198 }
3199
3200 return closestCorners(args);
3201 }, []);
3202
3203 const handleProjectDragEnd = useCallback(
3204 (event: DragEndEvent) => {
3205 if (sidebarProjectSortOrder !== "manual") {
3206 dragInProgressRef.current = false;
3207 return;
3208 }
3209 dragInProgressRef.current = false;
3210 const { active, over } = event;
3211 if (!over || active.id === over.id) return;
3212 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
3213 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
3214 if (!activeProject || !overProject) return;
3215 const activeMemberKeys = activeProject.memberProjects.map(
3216 (member) => member.physicalProjectKey,
3217 );
3218 const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
3219 reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys);
3220 },
3221 [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects],
3222 );
3223
3224 const handleProjectDragStart = useCallback(
3225 (_event: DragStartEvent) => {
3226 if (sidebarProjectSortOrder !== "manual") {
3227 return;
3228 }
3229 dragInProgressRef.current = true;
3230 suppressProjectClickAfterDragRef.current = true;
3231 },
3232 [sidebarProjectSortOrder],
3233 );
3234
3235 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
3236 dragInProgressRef.current = false;
3237 }, []);
3238
3239 const animatedProjectListsRef = useRef(new WeakSet<HTMLElement>());
3240 const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3241 if (!node || animatedProjectListsRef.current.has(node)) {
3242 return;
3243 }
3244 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3245 animatedProjectListsRef.current.add(node);
3246 }, []);
3247
3248 const animatedThreadListsRef = useRef(new WeakSet<HTMLElement>());
3249 const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3250 if (!node || animatedThreadListsRef.current.has(node)) {
3251 return;
3252 }
3253 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3254 animatedThreadListsRef.current.add(node);
3255 }, []);
3256
3257 const visibleThreads = useMemo(
3258 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
3259 [sidebarThreads],
3260 );
3261 const sortedProjects = useMemo(() => {
3262 const sortableProjects = sidebarProjects.map((project) => ({
3263 ...project,
3264 id: project.projectKey,
3265 }));
3266 const sortableThreads = visibleThreads.map((thread) => {
3267 const physicalKey =
3268 projectPhysicalKeyByScopedRef.get(
3269 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3270 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3271 return {
3272 ...thread,
3273 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
3274 };
3275 });
3276 return sortProjectsForSidebar(
3277 sortableProjects,
3278 sortableThreads,
3279 sidebarProjectSortOrder,
3280 ).flatMap((project) => {
3281 const resolvedProject = sidebarProjectByKey.get(project.id);
3282 return resolvedProject ? [resolvedProject] : [];
3283 });
3284 }, [
3285 sidebarProjectSortOrder,
3286 physicalToLogicalKey,
3287 projectPhysicalKeyByScopedRef,
3288 sidebarProjectByKey,
3289 sidebarProjects,
3290 visibleThreads,
3291 ]);
3292 const isManualProjectSorting = sidebarProjectSortOrder === "manual";
3293 const visibleSidebarThreadKeys = useMemo(
3294 () =>
3295 sortedProjects.flatMap((project) => {
3296 const projectThreads = sortThreads(
3297 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
3298 (thread) => thread.archivedAt === null,
3299 ),
3300 sidebarThreadSortOrder,
3301 );
3302 const projectExpanded = resolveProjectExpanded(
3303 projectExpandedById,
3304 projectExpansionPreferenceKeys(project),
3305 );
3306 const activeThreadKey = routeThreadKey ?? undefined;
3307 const pinnedCollapsedThread =
3308 !projectExpanded && activeThreadKey
3309 ? (projectThreads.find(
3310 (thread) =>
3311 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
3312 activeThreadKey,
3313 ) ?? null)
3314 : null;
3315 const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
3316 if (!shouldShowThreadPanel) {
3317 return [];
3318 }
3319 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
3320 const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
3321 const previewThreads =
3322 isThreadListExpanded || !hasOverflowingThreads
3323 ? projectThreads
3324 : projectThreads.slice(0, sidebarThreadPreviewCount);
3325 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
3326 return renderedThreads.map((thread) =>
3327 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3328 );
3329 }),
3330 [
3331 sidebarThreadSortOrder,
3332 sidebarThreadPreviewCount,
3333 expandedThreadListsByProject,
3334 projectExpandedById,
3335 routeThreadKey,
3336 sortedProjects,
3337 threadsByProjectKey,
3338 ],
3339 );
3340 const threadJumpCommandByKey = useMemo(() => {
3341 const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
3342 for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
3343 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
3344 if (!jumpCommand) {
3345 return mapping;
3346 }
3347 mapping.set(threadKey, jumpCommand);
3348 }
3349
3350 return mapping;
3351 }, [visibleSidebarThreadKeys]);
3352 const threadJumpThreadKeys = useMemo(
3353 () => [...threadJumpCommandByKey.keys()],
3354 [threadJumpCommandByKey],
3355 );
3356 const sidebarShortcutContext = {
3357 terminalFocus: false,
3358 terminalOpen: routeTerminalOpen,
3359 modelPickerOpen: isModelPickerOpen(),
3360 };
3361 const threadJumpLabelByKey = useMemo(
3362 () =>
3363 buildThreadJumpLabelMap({
3364 keybindings,
3365 platform,
3366 terminalOpen: sidebarShortcutContext.terminalOpen,
3367 threadJumpCommandByKey,
3368 }),
3369 [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey],
3370 );
3371 const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
3372 shortcutModifiers,
3373 keybindings,
3374 {
3375 platform,
3376 context: sidebarShortcutContext,
3377 },
3378 );
3379 const visibleThreadJumpLabelByKey = showThreadJumpHints
3380 ? threadJumpLabelByKey
3381 : EMPTY_THREAD_JUMP_LABELS;
3382 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
3383 const prewarmedSidebarThreadKeys = useMemo(
3384 () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys),
3385 [visibleSidebarThreadKeys],
3386 );
3387 const prewarmedSidebarThreadRefs = useMemo(
3388 () =>
3389 prewarmedSidebarThreadKeys.flatMap((threadKey) => {
3390 const ref = parseScopedThreadKey(threadKey);
3391 return ref ? [ref] : [];
3392 }),
3393 [prewarmedSidebarThreadKeys],
3394 );
3395
3396 useEffect(() => {
3397 updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow);
3398 }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]);
3399
3400 useEffect(() => {
3401 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
3402 const shortcutContext = getCurrentSidebarShortcutContext();
3403
3404 if (event.defaultPrevented || event.repeat) {
3405 return;
3406 }
3407
3408 const command = resolveShortcutCommand(event, keybindings, {
3409 platform,
3410 context: shortcutContext,
3411 });
3412 const traversalDirection = threadTraversalDirectionFromCommand(command);
3413 if (traversalDirection !== null) {
3414 const targetThreadKey = resolveAdjacentThreadId({
3415 threadIds: orderedSidebarThreadKeys,
3416 currentThreadId: routeThreadKey,
3417 direction: traversalDirection,
3418 });
3419 if (!targetThreadKey) {
3420 return;
3421 }
3422 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3423 if (!targetThread) {
3424 return;
3425 }
3426
3427 event.preventDefault();
3428 event.stopPropagation();
3429 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
3430 return;
3431 }
3432
3433 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
3434 if (jumpIndex === null) {
3435 return;
3436 }
3437
3438 const targetThreadKey = threadJumpThreadKeys[jumpIndex];
3439 if (!targetThreadKey) {
3440 return;
3441 }
3442 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3443 if (!targetThread) {
3444 return;
3445 }
3446
3447 event.preventDefault();
3448 event.stopPropagation();
3449 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
3450 };
3451
3452 window.addEventListener("keydown", onWindowKeyDown);
3453
3454 return () => {
3455 window.removeEventListener("keydown", onWindowKeyDown);
3456 };
3457 }, [
3458 getCurrentSidebarShortcutContext,
3459 keybindings,
3460 navigateToThread,
3461 orderedSidebarThreadKeys,
3462 platform,
3463 routeThreadKey,
3464 sidebarThreadByKey,
3465 threadJumpThreadKeys,
3466 ]);
3467
3468 useEffect(() => {
3469 const onMouseDown = (event: globalThis.MouseEvent) => {
3470 if (!useThreadSelectionStore.getState().hasSelection()) return;
3471 const target = event.target instanceof HTMLElement ? event.target : null;
3472 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
3473 clearSelection();
3474 };
3475
3476 window.addEventListener("mousedown", onMouseDown);
3477 return () => {
3478 window.removeEventListener("mousedown", onMouseDown);
3479 };
3480 }, [clearSelection]);
3481
3482 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
3483 const desktopUpdateButtonAction = desktopUpdateState
3484 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
3485 : "none";
3486 const showArm64IntelBuildWarning =
3487 isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
3488 const arm64IntelBuildWarningDescription =
3489 desktopUpdateState && showArm64IntelBuildWarning
3490 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
3491 : null;
3492 const commandPaletteShortcutLabel = shortcutLabelForCommand(
3493 keybindings,
3494 "commandPalette.toggle",
3495 newThreadShortcutLabelOptions,
3496 );
3497 const handleDesktopUpdateButtonClick = useCallback(() => {
3498 const bridge = window.desktopBridge;
3499 if (!bridge || !desktopUpdateState) return;
3500 if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return;
3501
3502 if (desktopUpdateButtonAction === "download") {
3503 void bridge
3504 .downloadUpdate()
3505 .then((result) => {
3506 if (result.completed) {
3507 showDesktopUpdateDownloadedToast(bridge, result.state);
3508 }
3509 if (!shouldToastDesktopUpdateActionResult(result)) return;
3510 const actionError = getDesktopUpdateActionError(result);
3511 if (!actionError) return;
3512 toastManager.add(
3513 stackedThreadToast({
3514 type: "error",
3515 title: "Could not download update",
3516 description: actionError,
3517 }),
3518 );
3519 })
3520 .catch((error) => {
3521 toastManager.add(
3522 stackedThreadToast({
3523 type: "error",
3524 title: "Could not start update download",
3525 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3526 }),
3527 );
3528 });
3529 return;
3530 }
3531
3532 if (desktopUpdateButtonAction === "install") {
3533 const confirmed = window.confirm(
3534 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform),
3535 );
3536 if (!confirmed) return;
3537 void bridge
3538 .installUpdate()
3539 .then((result) => {
3540 if (!shouldToastDesktopUpdateActionResult(result)) return;
3541 const actionError = getDesktopUpdateActionError(result);
3542 if (!actionError) return;
3543 toastManager.add(
3544 stackedThreadToast({
3545 type: "error",
3546 title: "Could not install update",
3547 description: actionError,
3548 }),
3549 );
3550 })
3551 .catch((error) => {
3552 toastManager.add(
3553 stackedThreadToast({
3554 type: "error",
3555 title: "Could not install update",
3556 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3557 }),
3558 );
3559 });
3560 }
3561 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
3562
3563 const expandThreadListForProject = useCallback((projectKey: string) => {
3564 setExpandedThreadListsByProject((current) => {
3565 if (current.has(projectKey)) return current;
3566 const next = new Set(current);
3567 next.add(projectKey);
3568 return next;
3569 });
3570 }, []);
3571
3572 const collapseThreadListForProject = useCallback((projectKey: string) => {
3573 setExpandedThreadListsByProject((current) => {
3574 if (!current.has(projectKey)) return current;
3575 const next = new Set(current);
3576 next.delete(projectKey);
3577 return next;
3578 });
3579 }, []);
3580
3581 return (
3582 <>
3583 {prewarmedSidebarThreadRefs.map((threadRef) => (
3584 <SidebarThreadDetailPrewarmer key={scopedThreadKey(threadRef)} threadRef={threadRef} />
3585 ))}
3586 <SidebarChromeHeader isElectron={isElectron} />
3587
3588 <SidebarProjectsContent
3589 showArm64IntelBuildWarning={showArm64IntelBuildWarning}
3590 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
3591 desktopUpdateButtonAction={desktopUpdateButtonAction}
3592 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
3593 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
3594 projectSortOrder={sidebarProjectSortOrder}
3595 threadSortOrder={sidebarThreadSortOrder}
3596 threadPreviewCount={sidebarThreadPreviewCount}
3597 updateSettings={updateSettings}
3598 openAddProject={openAddProjectCommandPalette}
3599 isManualProjectSorting={isManualProjectSorting}
3600 projectDnDSensors={projectDnDSensors}
3601 projectCollisionDetection={projectCollisionDetection}
3602 handleProjectDragStart={handleProjectDragStart}
3603 handleProjectDragEnd={handleProjectDragEnd}
3604 handleProjectDragCancel={handleProjectDragCancel}
3605 handleNewThread={handleNewThread}
3606 archiveThread={archiveThread}
3607 deleteThread={deleteThread}
3608 sortedProjects={sortedProjects}
3609 expandedThreadListsByProject={expandedThreadListsByProject}
3610 activeRouteProjectKey={activeRouteProjectKey}
3611 routeThreadKey={routeThreadKey}
3612 newThreadShortcutLabel={newThreadShortcutLabel}
3613 commandPaletteShortcutLabel={commandPaletteShortcutLabel}
3614 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
3615 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
3616 expandThreadListForProject={expandThreadListForProject}
3617 collapseThreadListForProject={collapseThreadListForProject}
3618 dragInProgressRef={dragInProgressRef}
3619 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
3620 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
3621 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
3622 projectsLength={projects.length}
3623 />
3624 <SidebarChromeFooter />
3625 </>
3626 );
3627}
apps/web/src/components/Sidebar.logic.test.ts+27-25
......@@ -17,7 +17,7 @@ import {
1717 resolveProjectStatusIndicator,
1818 resolveSidebarStageBadgeLabel,
1919 resolveThreadRowClassName,
20 resolveSidebarV2Status,
20 resolveSidebarThreadStatus,
2121 resolveThreadStatusPill,
2222 resolveWorkingStartedAt,
2323 searchSidebarThreadsByTitle,
......@@ -25,11 +25,11 @@ import {
2525 shouldNavigateAfterProjectRemoval,
2626 shouldClearThreadSelectionOnMouseDown,
2727 sortLogicalProjectsForSidebar,
28 sortSettledThreadsForSidebarV2,
28 sortSettledThreadsForSidebar,
2929 pinOrderKeyBetween,
3030 planPinnedReorder,
31 sortPinnedThreadsForSidebarV2,
32 sortThreadsForSidebarV2,
31 sortPinnedThreadsForSidebar,
32 sortThreadsForSidebar,
3333 sortProjectsForSidebar,
3434 sortScopedProjectsForSidebar,
3535 THREAD_JUMP_HINT_SHOW_DELAY_MS,
......@@ -627,7 +627,7 @@ describe("isContextMenuPointerDown", () => {
627627 });
628628});
629629
630describe("resolveSidebarV2Status", () => {
630describe("resolveSidebarThreadStatus", () => {
631631 const session = {
632632 threadId: ThreadId.make("thread-1"),
633633 status: "running" as const,
......@@ -642,15 +642,17 @@ describe("resolveSidebarV2Status", () => {
642642 const idle = { hasPendingApprovals: false, hasPendingUserInput: false };
643643
644644 it("prioritizes approval over a running session", () => {
645 expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe(
645 expect(resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, session })).toBe(
646646 "approval",
647647 );
648648 });
649649
650650 it("prioritizes awaiting input over a running session, below approval", () => {
651 expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input");
651 expect(resolveSidebarThreadStatus({ ...idle, hasPendingUserInput: true, session })).toBe(
652 "input",
653 );
652654 expect(
653 resolveSidebarV2Status({
655 resolveSidebarThreadStatus({
654656 ...idle,
655657 hasPendingApprovals: true,
656658 hasPendingUserInput: true,
......@@ -660,9 +662,9 @@ describe("resolveSidebarV2Status", () => {
660662 });
661663
662664 it("reports working for running and starting sessions", () => {
663 expect(resolveSidebarV2Status({ ...idle, session })).toBe("working");
665 expect(resolveSidebarThreadStatus({ ...idle, session })).toBe("working");
664666 expect(
665 resolveSidebarV2Status({
667 resolveSidebarThreadStatus({
666668 ...idle,
667669 session: { ...session, status: "starting" as const },
668670 }),
......@@ -671,19 +673,19 @@ describe("resolveSidebarV2Status", () => {
671673
672674 it("reports failed only while the session status is error", () => {
673675 expect(
674 resolveSidebarV2Status({
676 resolveSidebarThreadStatus({
675677 ...idle,
676678 session: { ...session, status: "error" as const, lastError: "boom" },
677679 }),
678680 ).toBe("failed");
679681 expect(
680 resolveSidebarV2Status({
682 resolveSidebarThreadStatus({
681683 ...idle,
682684 session: { ...session, status: "stopped" as const, lastError: "persisted" },
683685 }),
684686 ).toBe("ready");
685687 expect(
686 resolveSidebarV2Status({
688 resolveSidebarThreadStatus({
687689 ...idle,
688690 session: { ...session, status: "ready" as const, lastError: "persisted" },
689691 }),
......@@ -691,7 +693,7 @@ describe("resolveSidebarV2Status", () => {
691693 });
692694
693695 it("defaults to ready with no session", () => {
694 expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready");
696 expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready");
695697 });
696698});
697699
......@@ -715,14 +717,14 @@ describe("searchSidebarThreadsByTitle", () => {
715717 });
716718});
717719
718describe("sortThreadsForSidebarV2", () => {
720describe("sortThreadsForSidebar", () => {
719721 const sortable = (input: { id: string; createdAt: string }) => ({
720722 id: input.id,
721723 createdAt: input.createdAt,
722724 });
723725
724726 it("orders by creation time, newest first, ignoring activity", () => {
725 const sorted = sortThreadsForSidebarV2([
727 const sorted = sortThreadsForSidebar([
726728 sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }),
727729 sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
728730 sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
......@@ -732,7 +734,7 @@ describe("sortThreadsForSidebarV2", () => {
732734 });
733735
734736 it("breaks creation-time ties by id so the order is stable", () => {
735 const sorted = sortThreadsForSidebarV2([
737 const sorted = sortThreadsForSidebar([
736738 sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }),
737739 sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }),
738740 ]);
......@@ -838,7 +840,7 @@ describe("planPinnedReorder", () => {
838840 });
839841});
840842
841describe("sortPinnedThreadsForSidebarV2", () => {
843describe("sortPinnedThreadsForSidebar", () => {
842844 const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
843845 id: input.id,
844846 createdAt: input.createdAt,
......@@ -846,7 +848,7 @@ describe("sortPinnedThreadsForSidebarV2", () => {
846848 });
847849
848850 it("sorts keyed threads by key ahead of keyless threads in creation order", () => {
849 const sorted = sortPinnedThreadsForSidebarV2([
851 const sorted = sortPinnedThreadsForSidebar([
850852 pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }),
851853 pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }),
852854 pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }),
......@@ -862,7 +864,7 @@ describe("sortPinnedThreadsForSidebarV2", () => {
862864 });
863865
864866 it("breaks equal keys by id so raced writes render identically everywhere", () => {
865 const sorted = sortPinnedThreadsForSidebarV2([
867 const sorted = sortPinnedThreadsForSidebar([
866868 pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }),
867869 pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }),
868870 ]);
......@@ -871,7 +873,7 @@ describe("sortPinnedThreadsForSidebarV2", () => {
871873 });
872874});
873875
874describe("sortSettledThreadsForSidebarV2", () => {
876describe("sortSettledThreadsForSidebar", () => {
875877 const settled = (input: {
876878 id: string;
877879 settledAt?: string | null;
......@@ -887,7 +889,7 @@ describe("sortSettledThreadsForSidebarV2", () => {
887889 });
888890
889891 it("orders by settle time, most recently settled first", () => {
890 const sorted = sortSettledThreadsForSidebarV2([
892 const sorted = sortSettledThreadsForSidebar([
891893 settled({
892894 id: "settled-first",
893895 settledAt: "2026-03-09T10:00:00.000Z",
......@@ -905,7 +907,7 @@ describe("sortSettledThreadsForSidebarV2", () => {
905907 });
906908
907909 it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
908 const sorted = sortSettledThreadsForSidebarV2([
910 const sorted = sortSettledThreadsForSidebar([
909911 settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
910912 settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
911913 settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
......@@ -917,7 +919,7 @@ describe("sortSettledThreadsForSidebarV2", () => {
917919 it("counts a turn completion as activity for auto-settled threads", () => {
918920 // The message came in before the other thread's, but its turn finished
919921 // after: completion time is the real "work ended" moment.
920 const sorted = sortSettledThreadsForSidebarV2([
922 const sorted = sortSettledThreadsForSidebar([
921923 settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
922924 settled({
923925 id: "completed-later",
......@@ -930,7 +932,7 @@ describe("sortSettledThreadsForSidebarV2", () => {
930932 });
931933
932934 it("breaks timestamp ties by id so the order is stable", () => {
933 const sorted = sortSettledThreadsForSidebarV2([
935 const sorted = sortSettledThreadsForSidebar([
934936 settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
935937 settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
936938 ]);
apps/web/src/components/Sidebar.logic.ts+15-9
......@@ -423,21 +423,27 @@ export function resolveThreadRowClassName(input: {
423423 );
424424}
425425
426// ── Sidebar v2 status model ─────────────────────────────────────────
426// ── Sidebar thread status model ─────────────────────────────────────
427427// Five visual states, three colors: color is reserved for "act now"
428428// (approval), "in motion" (working), and "broken" (failed). Ready is the
429429// unlabeled resting state — the agent stopped and is waiting on the user,
430430// whether it finished, asked a question, or proposed a plan.
431431// Unread completion is tracked separately: it describes whether a ready
432432// thread needs attention, not what the thread is currently doing.
433export type SidebarV2Status = "approval" | "input" | "working" | "monitoring" | "failed" | "ready";
434
435type SidebarV2StatusInput = Pick<
433export type SidebarThreadStatus =
434 | "approval"
435 | "input"
436 | "working"
437 | "monitoring"
438 | "failed"
439 | "ready";
440
441type SidebarThreadStatusInput = Pick<
436442 SidebarThreadSummary,
437443 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"
438444>;
439445
440export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {
446export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
441447 if (thread.hasPendingApprovals) {
442448 return "approval";
443449 }
......@@ -496,11 +502,11 @@ export function firstValidTimestamp(
496502 return null;
497503}
498504
499// v2 sort: static creation order, newest thread on top. Activity NEVER
505// Sidebar sort: static creation order, newest thread on top. Activity NEVER
500506// reorders the list — a row holds its position from open until settled, so
501507// the screen only moves at lifecycle transitions. Status (including pending
502508// approval) is carried by each card's edge strip, not by position.
503export function sortThreadsForSidebarV2<
509export function sortThreadsForSidebar<
504510 T extends { readonly id: string; readonly createdAt: string },
505511>(threads: readonly T[]): T[] {
506512 return [...threads].toSorted(
......@@ -517,7 +523,7 @@ export {
517523 pinOrderKeyBetween,
518524 planPinnedReorder,
519525} from "@t3tools/client-runtime/state/thread-sort";
520export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort";
526export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
521527
522528/**
523529 * Search the already-ordered sidebar thread collection by title only.
......@@ -566,7 +572,7 @@ export function resolveSettledTimestamp(thread: SettledTimestampInput): string |
566572
567573// Settled rows are history, so they order by when the work ENDED, not when
568574// the thread was created or last touched.
569export function sortSettledThreadsForSidebarV2<
575export function sortSettledThreadsForSidebar<
570576 T extends SettledTimestampInput & { readonly id: string },
571577>(threads: readonly T[]): T[] {
572578 const timestampMs = (thread: T) => {
apps/web/src/components/Sidebar.tsx+3335-3260
......@@ -1,99 +1,79 @@
1import {
2 ArchiveIcon,
3 ArrowUpDownIcon,
4 ChevronRightIcon,
5 CloudIcon,
6 ContainerIcon,
7 FolderPlusIcon,
8 Globe2Icon,
9 LoaderIcon,
10 SearchIcon,
11 SquarePenIcon,
12 TerminalIcon,
13 TriangleAlertIcon,
14} from "lucide-react";
15import {
16 ChangeRequestStatusIcon,
17 prStatusIndicator,
18 PrStatusTooltipContent,
19 resolveThreadPr,
20 terminalStatusFromRunningIds,
21 ThreadStatusLabel,
22 ThreadWorktreeIndicator,
23} from "./ThreadStatusIndicators";
24import { ProjectFavicon } from "./ProjectFavicon";
25import { useAtomValue } from "@effect/atom-react";
261import { autoAnimate } from "@formkit/auto-animate";
27import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
28import { useShallow } from "zustand/react/shallow";
2import { useAtomValue } from "@effect/atom-react";
293import {
304 DndContext,
31 type DragCancelEvent,
32 type CollisionDetection,
335 PointerSensor,
34 type DragStartEvent,
35 closestCorners,
36 pointerWithin,
6 closestCenter,
377 useSensor,
388 useSensors,
399 type DragEndEvent,
4010} from "@dnd-kit/core";
41import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
11import {
12 SortableContext,
13 arrayMove,
14 useSortable,
15 verticalListSortingStrategy,
16} from "@dnd-kit/sortable";
4217import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
4318import { CSS } from "@dnd-kit/utilities";
4419import {
45 type ContextMenuItem,
46 ProjectId,
47 type ScopedThreadRef,
48 type ResolvedKeybindingsConfig,
49 type SidebarProjectGroupingMode,
50 ThreadId,
51} from "@t3tools/contracts";
20 canSnooze,
21 effectiveSettled,
22 effectiveSnoozed,
23 threadWokeAt,
24} from "@t3tools/client-runtime/state/thread-settled";
25import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
5226import {
53 parseScopedThreadKey,
54 scopedProjectKey,
55 scopedThreadKey,
5627 scopeProjectRef,
5728 scopeThreadRef,
29 scopedThreadKey,
5830} from "@t3tools/client-runtime/environment";
59import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
31import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
32import type { TimestampFormat } from "@t3tools/contracts/settings";
33import {
34 AlarmClockIcon,
35 AlarmClockOffIcon,
36 CheckIcon,
37 ChevronDownIcon,
38 CircleAlertIcon,
39 CircleCheckIcon,
40 CircleDashedIcon,
41 ClockIcon,
42 CopyIcon,
43 FolderIcon,
44 FolderPlusIcon,
45 GitBranchIcon,
46 EllipsisIcon,
47 MessageSquareIcon,
48 PinIcon,
49 PlusIcon,
50 SearchIcon,
51 ServerIcon,
52 SquarePenIcon,
53 TerminalIcon,
54 Trash2Icon,
55 Undo2Icon,
56 XIcon,
57} from "lucide-react";
58import {
59 memo,
60 useCallback,
61 useEffect,
62 useMemo,
63 useRef,
64 useState,
65 type KeyboardEvent as ReactKeyboardEvent,
66 type MouseEvent as ReactMouseEvent,
67 type ReactNode,
68} from "react";
69import { useParams, useRouter } from "@tanstack/react-router";
70
6071import {
6172 isAtomCommandInterrupted,
6273 settlePromise,
6374 squashAtomCommandFailure,
6475} from "@t3tools/client-runtime/state/runtime";
65import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
66import {
67 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
68 MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
69 type SidebarProjectSortOrder,
70 type SidebarThreadPreviewCount,
71 type SidebarThreadSortOrder,
72} from "@t3tools/contracts/settings";
73import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
74import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
7576import { isElectron } from "../env";
76import { useOpenPrLink } from "../lib/openPullRequestLink";
77import { isTerminalFocused } from "../lib/terminalFocus";
78import { isMacPlatform } from "../lib/utils";
79import {
80 readThreadShell,
81 useProject,
82 useProjects,
83 useThreadShells,
84 useThreadShellsForProjectRefs,
85} from "../state/entities";
86import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
87import { useThreadRunningTerminalIds } from "../state/terminalSessions";
88import { useThreadDiscoveredPorts } from "../portDiscoveryState";
89import { openDiscoveredPort } from "./preview/openDiscoveredPort";
90import { useAtomCommand } from "../state/use-atom-command";
91import { previewEnvironment } from "../state/preview";
92import {
93 legacyProjectCwdPreferenceKey,
94 resolveProjectExpanded,
95 useUiStateStore,
96} from "../uiStateStore";
9777import {
9878 resolveShortcutCommand,
9979 shortcutLabelForCommand,
......@@ -102,39 +82,89 @@ import {
10282 threadJumpIndexFromCommand,
10383 threadTraversalDirectionFromCommand,
10484} from "../keybindings";
105import { isModelPickerOpen } from "../modelPickerVisibility";
10685import { useShortcutModifierState } from "../shortcutModifierState";
86import { isTerminalFocused } from "../lib/terminalFocus";
87import { isModelPickerOpen } from "../modelPickerVisibility";
88import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
89import { isMacPlatform } from "~/lib/utils";
90import { useOpenPrLink } from "../lib/openPullRequestLink";
10791import { readLocalApi } from "../localApi";
108import { useComposerDraftStore } from "../composerDraftStore";
109import { useNewThreadHandler } from "../hooks/useHandleNewThread";
110import { useDesktopUpdateState } from "../state/desktopUpdate";
111
92import {
93 deriveProjectGroupingOverrideKey,
94 getProjectOrderKey,
95 selectProjectGroupingSettings,
96} from "../logicalProject";
97import {
98 buildSidebarProjectSnapshots,
99 type SidebarProjectGroupMember,
100 type SidebarProjectSnapshot,
101} from "../sidebarProjectGrouping";
102import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
103import { useThreadSelectionStore } from "../threadSelectionStore";
112104import { useThreadActions } from "../hooks/useThreadActions";
105import { useHandleNewThread } from "../hooks/useHandleNewThread";
106import { openCommandPalette } from "../commandPaletteBus";
107import { startNewThreadFromContext } from "../lib/chatThreadActions";
108import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
109import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
110import { useNowMinute } from "../hooks/useNowMinute";
111import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
112import { useProjects, useThreadShells } from "../state/entities";
113import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
114import { vcsEnvironment } from "../state/vcs";
115import { threadEnvironment } from "../state/threads";
113116import { projectEnvironment } from "../state/projects";
114117import { useEnvironmentQuery } from "../state/query";
115import { threadEnvironment, useEnvironmentThread } from "../state/threads";
116import { vcsEnvironment } from "../state/vcs";
117import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
118import { useAtomCommand } from "../state/use-atom-command";
118119import {
119120 buildThreadRouteParams,
120121 resolveActiveThreadRouteRef,
121122 resolveThreadRouteTarget,
122123} from "../threadRoutes";
123import { stackedThreadToast, toastManager } from "./ui/toast";
124import { formatRelativeTimeLabel } from "../timestampFormat";
125import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
126import { Kbd } from "./ui/kbd";
124import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
125import type { SidebarThreadSummary } from "../types";
126import { cn } from "~/lib/utils";
127import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
128import {
129 buildBulkTitleRegenerationContextMenuItem,
130 formatWorkingDurationLabel,
131 firstValidTimestampMs,
132 hasUnseenCompletion,
133 isTrailingDoubleClick,
134 orderItemsByPreferredIds,
135 planPinnedReorder,
136 resolveAdjacentThreadId,
137 resolveSettledTimestamp,
138 resolveSidebarThreadStatus,
139 searchSidebarThreadsByTitle,
140 resolveWorkingStartedAt,
141 shouldNavigateAfterProjectRemoval,
142 sortLogicalProjectsForSidebar,
143 sortPinnedThreadsForSidebar,
144 sortSettledThreadsForSidebar,
145 sortThreadsForSidebar,
146} from "./Sidebar.logic";
147import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
148import {
149 prStatusIndicator,
150 resolveThreadPr,
151 settledPrHoverColorClass,
152 terminalStatusFromRunningIds,
153 type TerminalStatusIndicator,
154} from "./ThreadStatusIndicators";
127155import {
128 getArm64IntelBuildWarningDescription,
129 getDesktopUpdateActionError,
130 getDesktopUpdateInstallConfirmationMessage,
131 isDesktopUpdateButtonDisabled,
132 resolveDesktopUpdateButtonAction,
133 shouldShowArm64IntelBuildWarning,
134 shouldToastDesktopUpdateActionResult,
135} from "./desktopUpdate.logic";
136import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
137import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
156 resolveSnoozePresets,
157 snoozeWakeDescription,
158 snoozeWakeLabel,
159 type SnoozePreset,
160} from "./Sidebar.snooze";
161import { ProjectFavicon } from "./ProjectFavicon";
162import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
163import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
164import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
165import { primaryServerProvidersAtom } from "../state/server";
166import { useThreadRunningTerminalIds } from "../state/terminalSessions";
167import { stackedThreadToast, toastManager } from "./ui/toast";
138168import { Button } from "./ui/button";
139169import {
140170 Dialog,
......@@ -146,1542 +176,1867 @@ import {
146176 DialogTitle,
147177} from "./ui/dialog";
148178import { Input } from "./ui/input";
149import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
150import {
151 NumberField,
152 NumberFieldDecrement,
153 NumberFieldGroup,
154 NumberFieldIncrement,
155 NumberFieldInput,
156} from "./ui/number-field";
179import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
157180import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
158import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
159import {
160 SidebarContent,
161 SidebarGroup,
162 SidebarMenu,
163 SidebarMenuButton,
164 SidebarMenuItem,
165 SidebarMenuSub,
166 SidebarMenuSubButton,
167 SidebarMenuSubItem,
168 useSidebar,
169} from "./ui/sidebar";
170import { useThreadSelectionStore } from "../threadSelectionStore";
171import { openCommandPalette } from "../commandPaletteBus";
172import {
173 archiveSelectedThreadEntries,
174 buildMultiSelectThreadContextMenuItems,
175 getSidebarThreadIdsToPrewarm,
176 resolveAdjacentThreadId,
177 isContextMenuPointerDown,
178 isTrailingDoubleClick,
179 resolveProjectStatusIndicator,
180 resolveThreadRowClassName,
181 resolveThreadStatusPill,
182 orderItemsByPreferredIds,
183 shouldClearThreadSelectionOnMouseDown,
184 sortProjectsForSidebar,
185 useThreadJumpHintVisibility,
186 ThreadStatusPill,
187} from "./Sidebar.logic";
188import { sortThreads } from "../lib/threadSort";
181import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
189182import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
190import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
191import { useIsMobile } from "~/hooks/useMediaQuery";
192import { CommandDialogTrigger } from "./ui/command";
193import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
194import { primaryServerKeybindingsAtom } from "../state/server";
195import {
196 derivePhysicalProjectKey,
197 deriveProjectGroupingOverrideKey,
198 getProjectOrderKey,
199 selectProjectGroupingSettings,
200} from "../logicalProject";
201import type { SidebarThreadSummary } from "../types";
202import {
203 buildPhysicalToLogicalProjectKeyMap,
204 buildSidebarProjectSnapshots,
205 type SidebarProjectGroupMember,
206 type SidebarProjectSnapshot,
207} from "../sidebarProjectGrouping";
208const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
209 updated_at: "Last user message",
210 created_at: "Created at",
211 manual: "Manual",
212};
213const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
214 updated_at: "Last user message",
215 created_at: "Created at",
216};
217const SIDEBAR_LIST_ANIMATION_OPTIONS = {
218 duration: 180,
219 easing: "ease-out",
220} as const;
221const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
183import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
184import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
185import { useComposerDraftStore } from "../composerDraftStore";
186
187// Settled-tail paging: recent history is the common lookup; the deep tail
188// stays behind an explicit Show more.
189const SETTLED_TAIL_INITIAL_COUNT = 10;
190const SETTLED_TAIL_PAGE_COUNT = 25;
222191const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
223192 repository: "Group by repository",
224193 repository_path: "Group by repository path",
225194 separate: "Keep separate",
226195};
227const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
228 "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";
229196
230function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
231 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
232 return null;
197function compactSidebarTimeLabel(label: string): string {
198 if (label === "just now") return "now";
199 return label.endsWith(" ago") ? label.slice(0, -4) : label;
233200}
234201
235function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
236 return Math.min(
237 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
238 Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value),
239 ) as SidebarThreadPreviewCount;
202function threadTimeLabel(thread: SidebarThreadSummary): string {
203 const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;
204 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
240205}
241206
242function formatProjectMemberActionLabel(
243 member: SidebarProjectGroupMember,
244 groupedProjectCount: number,
245): string {
246 if (groupedProjectCount <= 1) {
247 return member.title;
248 }
207// Settled rows read "how long ago did this wrap up", matching their sort
208// key: both go through resolveSettledTimestamp so label and order can't
209// disagree.
210function settledTimeLabel(thread: SidebarThreadSummary): string {
211 const timestamp = resolveSettledTimestamp(thread);
212 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
213}
249214
250 return member.environmentLabel
251 ? `${member.environmentLabel} — ${member.workspaceRoot}`
252 : member.workspaceRoot;
215// Floats at the row's right edge, vertically centered, while the jump
216// modifier is held. An overlay pill instead of an inline slot: the hint
217// must neither displace the status/time label (holding ⌘ used to blank
218// out "Working") nor shift any layout when it appears. pointer-events-none
219// so it never swallows clicks meant for the settle/un-settle buttons it
220// can overlap.
221function JumpHintBadge(props: { label: string }) {
222 return (
223 <span
224 aria-hidden
225 className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
226 >
227 {props.label}
228 </span>
229 );
253230}
254231
255function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] {
256 return [
257 project.projectKey,
258 ...project.memberProjects.map((member) => member.physicalProjectKey),
259 ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)),
260 ];
232// Self-ticking so only this span re-renders each second, not the whole row.
233function WorkingDuration(props: { startedAt: string | null }) {
234 const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
235 const [, setTick] = useState(0);
236 useEffect(() => {
237 if (Number.isNaN(startedMs)) return;
238 const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000);
239 return () => window.clearInterval(id);
240 }, [startedMs]);
241 if (Number.isNaN(startedMs)) return null;
242 return (
243 <span className="font-mono tabular-nums">
244 {formatWorkingDurationLabel(Date.now() - startedMs)}
245 </span>
246 );
261247}
262248
263function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
264 switch (mode) {
265 case "repository":
266 return "Projects from the same repository share one sidebar row.";
267 case "repository_path":
268 return "Projects group only when both the repository and repo-relative path match.";
269 case "separate":
270 return "Every project path gets its own sidebar row.";
271 }
249function terminalProcessLabel(count: number): string {
250 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
272251}
273252
274function buildThreadJumpLabelMap(input: {
275 keybindings: ResolvedKeybindingsConfig;
276 platform: string;
277 terminalOpen: boolean;
278 threadJumpCommandByKey: ReadonlyMap<
279 string,
280 NonNullable<ReturnType<typeof threadJumpCommandForIndex>>
281 >;
282}): ReadonlyMap<string, string> {
283 if (input.threadJumpCommandByKey.size === 0) {
284 return EMPTY_THREAD_JUMP_LABELS;
285 }
253function SidebarThreadTooltip({
254 thread,
255 projectTitle,
256 projectCwd,
257 environmentLabel,
258 driverKind,
259 modelInstanceId,
260 modelLabel,
261 branchMismatch,
262 terminalStatus,
263 terminalProcessCount,
264}: {
265 thread: SidebarThreadSummary;
266 projectTitle: string | null;
267 projectCwd: string | null;
268 environmentLabel: string | null;
269 driverKind: ProviderInstanceEntry["driverKind"] | null;
270 modelInstanceId: string;
271 modelLabel: string;
272 branchMismatch: {
273 threadBranch: string;
274 currentBranch: string;
275 } | null;
276 terminalStatus: TerminalStatusIndicator | null;
277 terminalProcessCount: number;
278}) {
279 return (
280 <TooltipPopup
281 side="right"
282 align="start"
283 sideOffset={4}
284 variant="glass"
285 className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0"
286 >
287 <div className="flex min-w-0 max-w-80 flex-col gap-2 p-[var(--floating-content-inset)]">
288 <div className="min-w-0 truncate text-xs leading-none font-medium text-foreground">
289 {thread.title}
290 </div>
291 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
292 {projectTitle ? (
293 <div className="flex min-w-0 items-center gap-2">
294 <ProjectFavicon
295 environmentId={thread.environmentId}
296 cwd={projectCwd ?? ""}
297 className="size-3 shrink-0 stroke-muted-foreground"
298 />
299 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
300 </div>
301 ) : null}
302 {environmentLabel ? (
303 <div className="flex min-w-0 items-center gap-2">
304 <ServerIcon className="size-3 shrink-0 stroke-muted-foreground" />
305 <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div>
306 </div>
307 ) : null}
308 {thread.branch ? (
309 <div className="flex min-w-0 items-center gap-2">
310 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />
311 <div className="min-w-0 truncate text-foreground/75">{thread.branch}</div>
312 </div>
313 ) : null}
314 {branchMismatch ? (
315 <div className="flex min-w-0 items-start gap-2 text-warning">
316 <CircleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" />
317 <div className="min-w-0 flex-1 wrap-break-word leading-5">
318 You're currently checked out on another branch.
319 </div>
320 </div>
321 ) : null}
322 {driverKind ? (
323 <div className="flex min-w-0 items-center gap-2">
324 <ProviderInstanceIcon
325 driverKind={driverKind}
326 displayName={thread.session?.providerName ?? modelInstanceId}
327 iconClassName="size-3 shrink-0 grayscale opacity-60"
328 />
329 <div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>
330 </div>
331 ) : null}
332 {terminalStatus ? (
333 <div className="flex min-w-0 items-center gap-2">
334 <TerminalIcon
335 aria-hidden
336 className={cn("size-3 shrink-0", terminalStatus.colorClass)}
337 />
338 <div className="min-w-0 truncate text-foreground/75">
339 {terminalProcessLabel(terminalProcessCount)}
340 </div>
341 </div>
342 ) : null}
343 {thread.session?.lastError ? (
344 <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">
345 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />
346 <div className="min-w-0 truncate">Error occurred</div>
347 </div>
348 ) : null}
349 </div>
350 </div>
351 </TooltipPopup>
352 );
353}
286354
287 const shortcutLabelOptions = {
288 platform: input.platform,
289 context: {
290 terminalFocus: false,
291 terminalOpen: input.terminalOpen,
292 },
293 } as const;
294 const mapping = new Map<string, string>();
295 for (const [threadKey, command] of input.threadJumpCommandByKey) {
296 const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);
297 if (label) {
298 mapping.set(threadKey, label);
299 }
300 }
301 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
355/**
356 * Hover entry point for snooze: a clock button opening the preset menu.
357 * Controlled by the row (which also uses the open state to pin its hover
358 * actions while the menu is up).
359 */
360function SnoozePopoverButton(props: {
361 open: boolean;
362 onOpenChange: (open: boolean) => void;
363 onSnooze: (preset: SnoozePreset) => void;
364 timestampFormat: TimestampFormat;
365}) {
366 const { open, onOpenChange, onSnooze, timestampFormat } = props;
367 // Presets resolve at open time so "In 1 hour" is relative to the click,
368 // not to when the row mounted.
369 const presets = useMemo(
370 () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []),
371 [open, timestampFormat],
372 );
373 return (
374 <Popover open={open} onOpenChange={onOpenChange}>
375 <PopoverTrigger
376 render={
377 <button
378 type="button"
379 aria-label="Snooze thread"
380 onClick={(event) => event.stopPropagation()}
381 onDoubleClick={(event) => event.stopPropagation()}
382 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"
383 />
384 }
385 >
386 <ClockIcon className="size-3" />
387 </PopoverTrigger>
388 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
389 {presets.map((preset) => (
390 <button
391 key={preset.id}
392 type="button"
393 onClick={(event) => {
394 event.stopPropagation();
395 onOpenChange(false);
396 onSnooze(preset);
397 }}
398 className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground"
399 >
400 <span className="flex-1">{preset.label}</span>
401 <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">
402 {preset.whenLabel}
403 </span>
404 </button>
405 ))}
406 </PopoverPopup>
407 </Popover>
408 );
409}
410
411// Subset of useSortable applied to a pinned card's root <li>. Listeners go
412// on the whole card (no dedicated handle): the pointer sensor's distance
413// constraint keeps plain clicks working, and we skip dnd-kit's aria
414// attributes since there is no keyboard sensor and the card body already
415// carries its own button semantics.
416type SortablePinnedRowBag = Pick<
417 ReturnType<typeof useSortable>,
418 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
419>;
420
421function SortablePinnedThreadRow(props: {
422 id: string;
423 children: (bag: SortablePinnedRowBag) => ReactNode;
424}) {
425 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
426 id: props.id,
427 });
428 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
302429}
303430
304interface SidebarThreadRowProps {
431const SidebarThreadRow = memo(function SidebarThreadRow(props: {
305432 thread: SidebarThreadSummary;
306 projectCwd: string | null;
307 orderedProjectThreadKeys: readonly string[];
433 variant: "card" | "slim";
434 // Slim rows are either settled (action: un-settle) or merely quiet
435 // (seen Ready threads — action: settle).
436 variantAction: "settle" | "unsettle" | "unsnooze";
437 // False on environments whose server predates thread.settle/unsettle:
438 // the lifecycle affordances hide entirely rather than fail on click.
439 settlementSupported: boolean;
440 // Same contract for thread.snooze/unsnooze.
441 snoozeSupported: boolean;
442 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
443 // actions: settling clears the pin server-side, and snoozing hides the
444 // card until wake with the pin intact underneath. The glyph is also the
445 // in-row pin state cue (the pinned block has no header), so it always
446 // shows while pinned; it only becomes a clickable unpin quick-action once
447 // the pinning capability is confirmed, and stays a passive marker while
448 // the descriptor is not loaded. Pinning itself lives in the context menu.
449 pinningSupported: boolean;
450 isPinned: boolean;
451 // Present only on pinned cards whose server supports reordering: dnd-kit
452 // sortable bag applied to the card root so the whole card drags (the
453 // pointer sensor's distance constraint keeps plain clicks working).
454 sortable?: SortablePinnedRowBag | undefined;
455 // Compact wake countdown ("2h") for rows in the snoozed shelf.
456 snoozeWakeLabelText: string | null;
457 // When a snooze ended (timer or early wake); drives the Woke pill until
458 // the user visits the thread.
459 wokeAt: string | null;
308460 isActive: boolean;
309461 jumpLabel: string | null;
310 appSettingsConfirmThreadArchive: boolean;
311 renamingThreadKey: string | null;
462 currentEnvironmentId: string | null;
463 environmentLabel: string | null;
464 projectCwd: string | null;
465 projectTitle: string | null;
466 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
467 timestampFormat: TimestampFormat;
468 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
469 onThreadActivate: (threadRef: ScopedThreadRef) => void;
470 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
471 onRenameTitleChange: (title: string) => void;
472 onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;
473 onCancelRename: () => void;
474 isRenaming: boolean;
312475 renamingTitle: string;
313 setRenamingTitle: (title: string) => void;
314 startThreadRename: (threadKey: string, title: string) => void;
315 renamingInputRef: React.RefObject<HTMLInputElement | null>;
316 renamingCommittedRef: React.RefObject<boolean>;
317 confirmingArchiveThreadKey: string | null;
318 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
319 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
320 handleThreadClick: (
321 event: React.MouseEvent,
322 threadRef: ScopedThreadRef,
323 orderedProjectThreadKeys: readonly string[],
324 ) => void;
325 navigateToThread: (threadRef: ScopedThreadRef) => void;
326 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
327 handleThreadContextMenu: (
328 threadRef: ScopedThreadRef,
329 position: { x: number; y: number },
330 ) => Promise<void>;
331 clearSelection: () => void;
332 commitRename: (
333 threadRef: ScopedThreadRef,
334 newTitle: string,
335 originalTitle: string,
336 ) => Promise<void>;
337 cancelRename: () => void;
338 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
339 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
340}
341
342export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
476 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
477 onSettle: (threadRef: ScopedThreadRef) => void;
478 onUnsettle: (threadRef: ScopedThreadRef) => void;
479 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
480 onUnsnooze: (threadRef: ScopedThreadRef) => void;
481 onUnpin: (threadRef: ScopedThreadRef) => void;
482 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
483 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
484}) {
343485 const {
344 orderedProjectThreadKeys,
345 isActive,
346 jumpLabel,
347 appSettingsConfirmThreadArchive,
348 renamingThreadKey,
486 isRenaming,
487 onChangeRequestState,
488 onCancelRename,
489 onCommitRename,
490 onContextMenu,
491 onAcknowledgeWoke,
492 onRenameTitleChange,
493 onSettle,
494 onSnooze,
495 onStartRename,
496 onThreadActivate,
497 onThreadClick,
498 onUnsettle,
499 onUnsnooze,
500 onUnpin,
349501 renamingTitle,
350 setRenamingTitle,
351 startThreadRename,
352 renamingInputRef,
353 renamingCommittedRef,
354 confirmingArchiveThreadKey,
355 setConfirmingArchiveThreadKey,
356 confirmArchiveButtonRefs,
357 handleThreadClick,
358 navigateToThread,
359 handleMultiSelectContextMenu,
360 handleThreadContextMenu,
361 clearSelection,
362 commitRename,
363 cancelRename,
364 attemptArchiveThread,
365 openPrLink,
366502 thread,
503 variant,
504 variantAction,
367505 } = props;
368 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
506 const threadRef = useMemo(
507 () => scopeThreadRef(thread.environmentId, thread.id),
508 [thread.environmentId, thread.id],
509 );
369510 const threadKey = scopedThreadKey(threadRef);
511 const isRegeneratingTitle = thread.titleRegeneration != null;
370512 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
371513 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
514 const openPrLink = useOpenPrLink();
372515 const runningTerminalIds = useThreadRunningTerminalIds({
373516 environmentId: thread.environmentId,
374517 threadId: thread.id,
375518 });
376 const isMobile = useIsMobile();
377 const discoveredPorts = useThreadDiscoveredPorts({
378 environmentId: thread.environmentId,
379 threadId: thread.id,
380 });
381 const openPreview = useAtomCommand(previewEnvironment.open, {
382 reportFailure: false,
383 });
384 const environment = useEnvironment(thread.environmentId);
385 const primaryEnvironmentId = usePrimaryEnvironmentId();
386 const isRemoteThread =
387 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
388 const remoteEnvLabel = environment?.label ?? null;
389 // A desktop-local secondary backend (e.g. the WSL backend) shows up as a
390 // bearer environment whose connection id is prefixed "local:". It runs on the
391 // user's own machine, so the cloud icon is misleading — label it "Local" and
392 // suppress the cloud icon (the project header already shows a container icon
393 // for desktop-local projects, see sidebarProjectGrouping).
394 const isDesktopLocalThread =
395 environment !== null && isDesktopLocalConnectionTarget(environment.entry.target);
396 const threadEnvironmentLabel = isRemoteThread
397 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
398 : null;
399 // For grouped projects, the thread may belong to a different environment
400 // than the representative project. Look up the thread's own project cwd
401 // so git status (and thus PR detection) queries the correct path.
402 const threadProject = useProject(
403 useMemo(
404 () => scopeProjectRef(thread.environmentId, thread.projectId),
405 [thread.environmentId, thread.projectId],
406 ),
407 );
408 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
409 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
519 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
520 const terminalProcessCount = runningTerminalIds.length;
521
522 const gitCwd = thread.worktreePath ?? props.projectCwd;
410523 const gitStatus = useEnvironmentQuery(
411 thread.branch != null && gitCwd !== null
524 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
412525 ? vcsEnvironment.status({
413526 environmentId: thread.environmentId,
414527 input: { cwd: gitCwd },
415528 })
416529 : null,
417530 );
418 const isHighlighted = isActive || isSelected;
419 const handleOpenDiscoveredPort = useCallback(
420 (event: React.MouseEvent<HTMLButtonElement>) => {
421 const port = discoveredPorts[0];
422 if (!port) return;
423 event.preventDefault();
424 event.stopPropagation();
425 navigateToThread(threadRef);
426 void (async () => {
427 const result = await openDiscoveredPort({ threadRef, port, openPreview });
428 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
429 return;
430 }
431 const error = squashAtomCommandFailure(result);
432 toastManager.add(
433 stackedThreadToast({
434 type: "error",
435 title: "Unable to open preview",
436 description:
437 error instanceof Error ? error.message : "The preview could not be opened.",
438 }),
439 );
440 })();
441 },
442 [discoveredPorts, navigateToThread, openPreview, threadRef],
443 );
444 const isThreadRunning =
445 thread.session?.status === "running" && thread.session.activeTurnId != null;
446 const threadStatus = resolveThreadStatusPill({
447 thread: {
448 ...thread,
449 lastVisitedAt,
450 },
451 });
452531 const pr = resolveThreadPr({
453532 threadBranch: thread.branch,
454533 gitStatus: gitStatus.data,
455534 });
456 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
457 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
458 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
459 const threadMetaClassName = isConfirmingArchive
460 ? "pointer-events-none opacity-0"
461 : !isThreadRunning
462 ? "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"
463 : "pointer-events-none";
464 const clearConfirmingArchive = useCallback(() => {
465 setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
466 }, [setConfirmingArchiveThreadKey, threadKey]);
467 const handleMouseLeave = useCallback(() => {
468 clearConfirmingArchive();
469 }, [clearConfirmingArchive]);
470 const handleBlurCapture = useCallback(
471 (event: React.FocusEvent<HTMLLIElement>) => {
472 const currentTarget = event.currentTarget;
473 requestAnimationFrame(() => {
474 if (currentTarget.contains(document.activeElement)) {
475 return;
535 const prState = pr?.state ?? null;
536
537 // Same semantics as the legacy sidebar (never-visited counts as read):
538 // switching sidebars must not light up every historical thread as unread.
539 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
540 const status = resolveSidebarThreadStatus(thread);
541 // A woken thread reappears at its original position (the sort is
542 // deliberately static), so the pill has to carry the weight. Snoozing is
543 // an explicit act, so the pill clears only when the user re-engages:
544 // reading a completion-triggered wake, clicking the pill, sending a
545 // message, settling, archiving — or finishing the work outright (merged
546 // or closed PR). Timer wakes survive a mere visit. An unparseable visit
547 // timestamp counts as never-visited — corrupt local data must not eat
548 // the wake signal.
549 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
550 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
551 const isWoke =
552 wokeAtDate !== null &&
553 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
554 prState !== "merged" &&
555 prState !== "closed";
556 // In-flight rows (working, or waiting on approval/input) fade as a whole:
557 // there is nothing for the user to do yet, so prominence is reserved for
558 // rows that need a human — done (unread), read-but-unsettled, failed, and
559 // freshly woken. The status label keeps its hue, so waiting rows stay
560 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:
561 // working threads aren't your problem yet) — only the colored status label
562 // stands out.
563 const isInFlight =
564 status === "working" || status === "monitoring" || status === "approval" || status === "input";
565 const shouldRecede =
566 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
567 // Status hues follow the system-wide convention set by sidebar v1 and the
568 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
569 // so a thread reads the same color everywhere it surfaces.
570 const topStatus =
571 status === "working"
572 ? {
573 label: "Working",
574 icon: "working" as const,
575 // No shimmer: a label that animates forever is noise in a sidebar
576 // full of them (and repaints every vsync on high-refresh displays).
577 // Working is a background state, so it rests at the dim end of what
578 // the old pulse cycled through; only the thread you have open gets
579 // the label at full strength.
580 className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"),
476581 }
477 clearConfirmingArchive();
478 });
582 : status === "monitoring"
583 ? {
584 // Monitoring is calm background presence, not active progress
585 // (monitoring-pill D6), so it keeps the label at full strength.
586 label: "Monitoring",
587 icon: null,
588 className: "text-sky-600 dark:text-sky-400",
589 }
590 : status === "approval"
591 ? {
592 label: "Approval",
593 icon: null,
594 className: "text-amber-700 dark:text-amber-300",
595 }
596 : status === "input"
597 ? {
598 label: "Input",
599 icon: null,
600 className: "text-indigo-600 dark:text-indigo-300",
601 }
602 : status === "failed"
603 ? {
604 label: "Failed",
605 icon: null,
606 className: "text-red-700 dark:text-red-300",
607 }
608 : isWoke
609 ? {
610 label: "Woke",
611 icon: "woke" as const,
612 className: "text-amber-700 dark:text-amber-300",
613 }
614 : isUnread
615 ? {
616 label: "Done",
617 icon: "done" as const,
618 className: "text-emerald-700 dark:text-emerald-300",
619 }
620 : null;
621 const isWokeStatus = topStatus?.icon === "woke";
622
623 const branchMismatch = resolveLocalCheckoutBranchMismatch({
624 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
625 activeWorktreePath: thread.worktreePath,
626 activeThreadBranch: thread.branch,
627 currentGitBranch: gitStatus.data?.refName ?? null,
628 });
629 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
630 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
631 // Report the PR state up: the parent partitions rows with effectiveSettled,
632 // and a merged/closed PR auto-settles a thread — data only rows have.
633 useEffect(() => {
634 onChangeRequestState(threadKey, prState);
635 }, [onChangeRequestState, prState, threadKey]);
636
637 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
638 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
639 const driverKind = providerEntry?.driverKind ?? null;
640 const selectedModel = providerEntry?.models.find(
641 (model) => model.slug === thread.modelSelection.model,
642 );
643 const modelLabel = selectedModel
644 ? getTriggerDisplayModelLabel(selectedModel)
645 : thread.modelSelection.model;
646
647 const isRemote =
648 props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
649
650 const detailsTooltip = (
651 <SidebarThreadTooltip
652 thread={thread}
653 projectTitle={props.projectTitle}
654 projectCwd={props.projectCwd}
655 environmentLabel={props.environmentLabel}
656 driverKind={driverKind}
657 modelInstanceId={modelInstanceId}
658 modelLabel={modelLabel}
659 branchMismatch={branchMismatch}
660 terminalStatus={terminalStatus}
661 terminalProcessCount={terminalProcessCount}
662 />
663 );
664
665 const handleClick = useCallback(
666 (event: ReactMouseEvent) => {
667 onThreadClick(event, threadRef);
479668 },
480 [clearConfirmingArchive],
669 [onThreadClick, threadRef],
481670 );
482 const handleRowClick = useCallback(
483 (event: React.MouseEvent) => {
484 handleThreadClick(event, threadRef, orderedProjectThreadKeys);
485 },
486 [handleThreadClick, orderedProjectThreadKeys, threadRef],
487 );
488 const handleRowDoubleClick = useCallback(
489 (event: React.MouseEvent) => {
490 // Already renaming this row: a double-click on the row chrome (outside the
491 // input) must not restart and discard the in-progress edit.
492 if (renamingThreadKey === threadKey) return;
493 // On mobile the first tap navigates and closes the sidebar sheet, so the
494 // inline rename can't be shown. Renaming there stays on the context menu.
495 if (isMobile) return;
496 // cmd/ctrl/shift double-clicks are multi-select intent, not rename.
497 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
498 // Ignore double-clicks bubbling from nested controls (PR status, port,
499 // archive buttons) — only the row body should enter inline rename.
500 if ((event.target as HTMLElement).closest("button, a")) return;
671 const handleAcknowledgeWokeClick = useCallback(
672 (event: ReactMouseEvent) => {
501673 event.preventDefault();
502 startThreadRename(threadKey, thread.title);
674 event.stopPropagation();
675 if (props.wokeAt === null) return;
676 onAcknowledgeWoke(threadRef, props.wokeAt);
503677 },
504 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title],
678 [onAcknowledgeWoke, props.wokeAt, threadRef],
505679 );
506 const handleRowKeyDown = useCallback(
507 (event: React.KeyboardEvent) => {
508 if (event.key !== "Enter" && event.key !== " ") return;
680 const handleContextMenu = useCallback(
681 (event: ReactMouseEvent) => {
509682 event.preventDefault();
510 navigateToThread(threadRef);
683 onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
511684 },
512 [navigateToThread, threadRef],
685 [onContextMenu, threadRef],
513686 );
514 const handleRowContextMenu = useCallback(
515 (event: React.MouseEvent) => {
687 const handleKeyDown = useCallback(
688 (event: ReactKeyboardEvent) => {
689 if (event.target !== event.currentTarget) return;
690 if (event.key !== "Enter" && event.key !== " ") return;
516691 event.preventDefault();
517 const hasSelection = useThreadSelectionStore.getState().hasSelection();
518 if (hasSelection && isSelected) {
519 void (async () => {
520 const result = await settlePromise(() =>
521 handleMultiSelectContextMenu({
522 x: event.clientX,
523 y: event.clientY,
524 }),
525 );
526 if (result._tag === "Failure") {
527 const error = squashAtomCommandFailure(result);
528 toastManager.add(
529 stackedThreadToast({
530 type: "error",
531 title: "Thread action failed",
532 description: error instanceof Error ? error.message : "An error occurred.",
533 }),
534 );
535 }
536 })();
537 return;
538 }
539
540 if (hasSelection) {
541 clearSelection();
542 }
543 void (async () => {
544 const result = await settlePromise(() =>
545 handleThreadContextMenu(threadRef, {
546 x: event.clientX,
547 y: event.clientY,
548 }),
549 );
550 if (result._tag === "Failure") {
551 const error = squashAtomCommandFailure(result);
552 toastManager.add(
553 stackedThreadToast({
554 type: "error",
555 title: "Thread action failed",
556 description: error instanceof Error ? error.message : "An error occurred.",
557 }),
558 );
559 }
560 })();
692 onThreadActivate(threadRef);
561693 },
562 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],
694 [onThreadActivate, threadRef],
563695 );
564 const handlePrClick = useCallback(
565 (event: React.MouseEvent<HTMLButtonElement>) => {
566 if (!prStatus) return;
567 openPrLink(event, prStatus.url);
568 },
569 [openPrLink, prStatus],
570 );
571 const handleRenameInputRef = useCallback(
572 (element: HTMLInputElement | null) => {
573 if (element && renamingInputRef.current !== element) {
574 renamingInputRef.current = element;
575 element.focus();
576 element.select();
696 const handleDoubleClick = useCallback(
697 (event: ReactMouseEvent) => {
698 if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
699 return;
577700 }
701 if ((event.target as HTMLElement).closest("button, a, input")) return;
702 event.preventDefault();
703 onStartRename(threadRef, thread.title);
578704 },
579 [renamingInputRef],
580 );
581 const handleRenameInputChange = useCallback(
582 (event: React.ChangeEvent<HTMLInputElement>) => {
583 setRenamingTitle(event.target.value);
584 },
585 [setRenamingTitle],
705 [isRenaming, onStartRename, thread.title, threadRef],
586706 );
587 const handleRenameInputKeyDown = useCallback(
588 (event: React.KeyboardEvent<HTMLInputElement>) => {
707 const renameCommittedRef = useRef(false);
708 useEffect(() => {
709 if (isRenaming) renameCommittedRef.current = false;
710 }, [isRenaming]);
711 const handleRenameKeyDown = useCallback(
712 (event: ReactKeyboardEvent<HTMLInputElement>) => {
589713 event.stopPropagation();
590714 if (event.key === "Enter") {
591715 event.preventDefault();
592 renamingCommittedRef.current = true;
593 void commitRename(threadRef, renamingTitle, thread.title);
716 renameCommittedRef.current = true;
717 onCommitRename(threadRef, renamingTitle, thread.title);
594718 } else if (event.key === "Escape") {
595719 event.preventDefault();
596 renamingCommittedRef.current = true;
597 cancelRename();
720 renameCommittedRef.current = true;
721 onCancelRename();
598722 }
599723 },
600 [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
724 [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
601725 );
602 const handleRenameInputBlur = useCallback(() => {
603 if (!renamingCommittedRef.current) {
604 void commitRename(threadRef, renamingTitle, thread.title);
726 const handleRenameBlur = useCallback(() => {
727 if (!renameCommittedRef.current) {
728 onCommitRename(threadRef, renamingTitle, thread.title);
605729 }
606 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
607 // Keep clicks/double-clicks inside the rename input from bubbling to the row.
608 // Without stopping `dblclick`, double-clicking to select a word would re-fire
609 // the row's rename handler and reset the in-progress edit back to the title.
610 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
611 event.stopPropagation();
612 }, []);
613 const handleConfirmArchiveRef = useCallback(
614 (element: HTMLButtonElement | null) => {
615 if (element) {
616 confirmArchiveButtonRefs.current.set(threadKey, element);
617 } else {
618 confirmArchiveButtonRefs.current.delete(threadKey);
619 }
620 },
621 [confirmArchiveButtonRefs, threadKey],
622 );
623 const stopPropagationOnPointerDown = useCallback(
624 (event: React.PointerEvent<HTMLButtonElement>) => {
730 }, [onCommitRename, renamingTitle, thread.title, threadRef]);
731 const handleSettleClick = useCallback(
732 (event: ReactMouseEvent) => {
733 event.preventDefault();
625734 event.stopPropagation();
735 onSettle(threadRef);
626736 },
627 [],
737 [onSettle, threadRef],
628738 );
629 const handleConfirmArchiveClick = useCallback(
630 (event: React.MouseEvent<HTMLButtonElement>) => {
739 const handleUnsettleClick = useCallback(
740 (event: ReactMouseEvent) => {
631741 event.preventDefault();
632742 event.stopPropagation();
633 clearConfirmingArchive();
634 void attemptArchiveThread(threadRef);
743 onUnsettle(threadRef);
635744 },
636 [attemptArchiveThread, clearConfirmingArchive, threadRef],
745 [onUnsettle, threadRef],
637746 );
638 const handleStartArchiveConfirmation = useCallback(
639 (event: React.MouseEvent<HTMLButtonElement>) => {
747 const handleUnsnoozeClick = useCallback(
748 (event: ReactMouseEvent) => {
640749 event.preventDefault();
641750 event.stopPropagation();
642 setConfirmingArchiveThreadKey(threadKey);
643 requestAnimationFrame(() => {
644 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
645 });
751 onUnsnooze(threadRef);
646752 },
647 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
753 [onUnsnooze, threadRef],
648754 );
649 const handleArchiveImmediateClick = useCallback(
650 (event: React.MouseEvent<HTMLButtonElement>) => {
755 const handleUnpinClick = useCallback(
756 (event: ReactMouseEvent) => {
651757 event.preventDefault();
652758 event.stopPropagation();
653 void attemptArchiveThread(threadRef);
759 onUnpin(threadRef);
654760 },
655 [attemptArchiveThread, threadRef],
761 [onUnpin, threadRef],
656762 );
657 const rowButtonRender = useMemo(() => <div role="button" tabIndex={0} />, []);
658
659 return (
660 <SidebarMenuSubItem
661 className="w-full"
662 data-thread-item
663 onMouseLeave={handleMouseLeave}
664 onBlurCapture={handleBlurCapture}
763 const handleSnoozePreset = useCallback(
764 (preset: SnoozePreset) => {
765 onSnooze(threadRef, preset);
766 },
767 [onSnooze, threadRef],
768 );
769 // While the snooze popover is open the pointer leaves the row, which
770 // would fade the hover actions out from under the open menu; pin them.
771 const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false);
772 // Snooze is offered only where it can succeed: capability-gated and never
773 // on blocked-on-you work or queued turns (the server rejects both).
774 const showSnoozeButton =
775 props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
776 // If the thread becomes blocked while the popover is open, the button
777 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
778 // stale true from permanently hiding the status label / pinning the
779 // hover actions, and the effect clears the raw state so the popover
780 // doesn't resurrect if the button later remounts.
781 const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton;
782 useEffect(() => {
783 if (!showSnoozeButton) setSnoozeMenuOpen(false);
784 }, [showSnoozeButton]);
785 const handlePrClick = useCallback(
786 (event: ReactMouseEvent<HTMLElement>) => {
787 if (pr?.url) openPrLink(event, pr.url);
788 },
789 [openPrLink, pr],
790 );
791
792 // All sidebar rows share one surface model. Live threads used to look
793 // like elevated cards while settled threads were plain rows, leaving neither
794 // a useful hierarchy nor a reliable hover cue. Status now lives in the row
795 // content; surface is reserved for interaction (hover, multi-select, route).
796 const rowSurfaceClassName = cn(
797 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none",
798 props.isActive
799 ? "bg-sidebar-row-active text-sidebar-foreground"
800 : isSelected
801 ? "bg-sidebar-row-selected text-sidebar-foreground"
802 : shouldRecede
803 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
804 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
805 isInFlight &&
806 !props.isActive &&
807 !isSelected &&
808 "opacity-70 transition-opacity hover:opacity-100",
809 );
810
811 const title = isRenaming ? (
812 <input
813 autoFocus
814 value={renamingTitle}
815 aria-label="Thread title"
816 onChange={(event) => onRenameTitleChange(event.target.value)}
817 onFocus={(event) => event.currentTarget.select()}
818 onKeyDown={handleRenameKeyDown}
819 onBlur={handleRenameBlur}
820 onClick={(event) => event.stopPropagation()}
821 onDoubleClick={(event) => event.stopPropagation()}
822 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"
823 />
824 ) : (
825 <span
826 className={cn(
827 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
828 shouldRecede ? "font-normal" : "font-medium",
829 variant === "card"
830 ? cn(
831 "truncate",
832 isUnread || isWoke
833 ? "text-foreground"
834 : shouldRecede
835 ? "text-secondary-label"
836 : status === "failed"
837 ? "text-foreground/95"
838 : "text-foreground/90",
839 )
840 : cn(
841 "truncate group-hover/sidebar-row:text-foreground",
842 props.isActive || isWoke
843 ? "text-foreground"
844 : isUnread
845 ? "text-muted-foreground"
846 : "text-secondary-label/70",
847 ),
848 isRegeneratingTitle && "opacity-[0.55]",
849 )}
665850 >
666 <SidebarMenuSubButton
667 render={rowButtonRender}
668 size="sm"
669 isActive={isActive}
670 data-testid={`thread-row-${thread.id}`}
671 className={`${resolveThreadRowClassName({
672 isActive,
673 isSelected,
674 })} relative isolate`}
675 onClick={handleRowClick}
676 onDoubleClick={handleRowDoubleClick}
677 onKeyDown={handleRowKeyDown}
678 onContextMenu={handleRowContextMenu}
851 {thread.title}
852 </span>
853 );
854
855 const prBadge =
856 prStatus && pr ? (
857 <button
858 type="button"
859 onClick={handlePrClick}
860 className={cn(
861 // Sidebar chrome follows the interface font; tabular digits keep the
862 // number from reflowing as PR states stream in.
863 "shrink-0 text-xs tabular-nums hover:underline",
864 variant === "slim" && variantAction === "unsettle"
865 ? props.isActive
866 ? "text-secondary-label"
867 : cn("text-secondary-label transition-colors", settledPrHoverClass)
868 : prStatus.colorClass,
869 )}
870 aria-label={prStatus.tooltip}
679871 >
680 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
681 {prStatus && (
682 <Tooltip>
683 <TooltipTrigger
684 render={
685 <button
686 type="button"
687 aria-label={prStatus.tooltip}
688 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
689 onClick={handlePrClick}
690 >
691 <ChangeRequestStatusIcon className="size-3" />
692 </button>
693 }
872 #{pr.number}
873 </button>
874 ) : null;
875 const terminalStatusIcon = terminalStatus ? (
876 <span
877 role="img"
878 aria-label={terminalProcessLabel(terminalProcessCount)}
879 data-testid={`sidebar-terminal-status-${thread.id}`}
880 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
881 >
882 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
883 </span>
884 ) : null;
885
886 if (variant === "slim") {
887 return (
888 <li
889 data-thread-item
890 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
891 >
892 <Tooltip>
893 <TooltipTrigger
894 render={
895 <div
896 role="button"
897 tabIndex={0}
898 data-testid="sidebar-row-slim"
899 aria-busy={isRegeneratingTitle || undefined}
900 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
901 onClick={handleClick}
902 onDoubleClick={handleDoubleClick}
903 onKeyDown={handleKeyDown}
904 onContextMenu={handleContextMenu}
694905 />
695 <TooltipPopup side="top">
696 <PrStatusTooltipContent status={prStatus} />
697 </TooltipPopup>
698 </Tooltip>
699 )}
700 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
701 {renamingThreadKey === threadKey ? (
702 <input
703 ref={handleRenameInputRef}
704 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"
705 value={renamingTitle}
706 onChange={handleRenameInputChange}
707 onKeyDown={handleRenameInputKeyDown}
708 onBlur={handleRenameInputBlur}
709 onClick={handleRenameInputClick}
710 onDoubleClick={handleRenameInputClick}
711 />
712 ) : (
713 <Tooltip>
714 <TooltipTrigger
715 render={
716 <span
717 className="min-w-0 flex-1 truncate text-sm"
718 data-testid={`thread-title-${thread.id}`}
719 >
720 {thread.title}
721 </span>
722 }
906 }
907 >
908 {/* Settled history recedes: dimmed favicon at rest, restored on
909 hover so the tail stays scannable when you're hunting. */}
910 <span
911 className={cn(
912 "shrink-0 transition-opacity",
913 !props.isActive &&
914 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
915 )}
916 >
917 <ProjectFavicon
918 environmentId={thread.environmentId}
919 cwd={props.projectCwd ?? ""}
920 className="size-4"
921 fallbackIcon={MessageSquareIcon}
723922 />
724 <TooltipPopup side="top" className="max-w-80 whitespace-normal leading-tight">
725 {thread.title}
726 </TooltipPopup>
727 </Tooltip>
728 )}
729 </div>
730 <div className="ml-auto flex shrink-0 items-center gap-1.5">
731 {discoveredPorts.length > 0 && (
732 <Tooltip>
733 <TooltipTrigger
734 render={
923 </span>
924 {title}
925 {terminalStatusIcon}
926 {isRegeneratingTitle ? (
927 <span role="status" className="sr-only">
928 Regenerating title
929 </span>
930 ) : null}
931 {/* The PR badge stays outside the hover-fading slot: it must
932 remain visible AND clickable while the row is hovered. Only
933 the time/jump label yields to the settle affordance. */}
934 {prBadge}
935 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
936 <span
937 className={cn(
938 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
939 !isWoke && "group-hover/sidebar-row:opacity-0",
940 )}
941 >
942 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
943 // Snoozed rows show when they come BACK, not when they were
944 // last touched — the return ticket is the row's whole story.
945 <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
946 {props.snoozeWakeLabelText}
947 </span>
948 ) : isWoke ? (
949 // A wake can land straight in the settled tail (e.g. PR
950 // merged while snoozed); the signal must survive the trip.
735951 <button
736952 type="button"
737 aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`}
738 className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400"
739 onClick={handleOpenDiscoveredPort}
740 />
741 }
742 >
743 <Globe2Icon className="size-3" />
744 </TooltipTrigger>
745 <TooltipPopup side="top">
746 Open localhost:{discoveredPorts[0]?.port}
747 {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""}
748 </TooltipPopup>
749 </Tooltip>
750 )}
751 <ThreadWorktreeIndicator thread={thread} />
752 {terminalStatus && (
753 <Tooltip>
754 <TooltipTrigger
755 render={
756 <span
757 role="img"
758 aria-label={terminalStatus.label}
759 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
760 />
761 }
762 >
763 <TerminalIcon
764 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
765 />
766 </TooltipTrigger>
767 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
768 </Tooltip>
769 )}
770 <div
771 className={`flex min-w-12 justify-end ${
772 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
773 }`}
774 >
775 {isConfirmingArchive ? (
776 <button
777 ref={handleConfirmArchiveRef}
778 type="button"
779 data-thread-selection-safe
780 data-testid={`thread-archive-confirm-${thread.id}`}
781 aria-label={`Confirm archive ${thread.title}`}
782 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
783 onPointerDown={stopPropagationOnPointerDown}
784 onClick={handleConfirmArchiveClick}
785 >
786 Confirm
787 </button>
788 ) : !isThreadRunning ? (
789 appSettingsConfirmThreadArchive ? (
790 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
953 aria-label="Dismiss Woke notification"
954 title="Dismiss Woke notification"
955 onClick={handleAcknowledgeWokeClick}
956 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"
957 >
958 <AlarmClockIcon aria-hidden className="size-3" />
959 <span role="status">Woke</span>
960 </button>
961 ) : (
962 <span className="text-xs">
963 {variantAction === "unsettle"
964 ? settledTimeLabel(thread)
965 : threadTimeLabel(thread)}
966 </span>
967 )}
968 </span>
969 {variantAction === "unsnooze" ? (
970 !props.snoozeSupported ? null : (
791971 <button
792972 type="button"
793 data-thread-selection-safe
794 data-testid={`thread-archive-${thread.id}`}
795 aria-label={`Archive ${thread.title}`}
796 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
797 onPointerDown={stopPropagationOnPointerDown}
798 onClick={handleStartArchiveConfirmation}
973 aria-label="Wake thread now"
974 onClick={handleUnsnoozeClick}
975 className={cn(
976 "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",
977 isWoke && "group-hover/sidebar-row:static",
978 )}
799979 >
800 <ArchiveIcon className="size-3.5" />
980 <AlarmClockOffIcon className="size-3" />
801981 </button>
802 </div>
982 )
983 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
984 <button
985 type="button"
986 aria-label="Un-settle thread"
987 onClick={handleUnsettleClick}
988 className={cn(
989 "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",
990 isWoke && "group-hover/sidebar-row:static",
991 )}
992 >
993 <Undo2Icon className="mb-px size-3.5" />
994 </button>
803995 ) : (
804 <Tooltip>
805 <TooltipTrigger
806 render={
807 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
808 <button
809 type="button"
810 data-thread-selection-safe
811 data-testid={`thread-archive-${thread.id}`}
812 aria-label={`Archive ${thread.title}`}
813 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
814 onPointerDown={stopPropagationOnPointerDown}
815 onClick={handleArchiveImmediateClick}
816 >
817 <ArchiveIcon className="size-3.5" />
818 </button>
819 </div>
820 }
821 />
822 <TooltipPopup side="top">Archive</TooltipPopup>
823 </Tooltip>
824 )
825 ) : null}
826 <span className={threadMetaClassName}>
827 <span className="inline-flex items-center gap-1">
828 {isRemoteThread && !isDesktopLocalThread && (
829 <Tooltip>
830 <TooltipTrigger
831 render={
832 <span
833 aria-label={threadEnvironmentLabel ?? "Remote"}
834 className="inline-flex items-center justify-center"
835 />
836 }
837 >
838 <CloudIcon className="size-3 text-muted-foreground/40" />
839 </TooltipTrigger>
840 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
841 </Tooltip>
842 )}
843 {jumpLabel ? (
844 <Tooltip>
845 <TooltipTrigger
846 render={
847 <span
848 aria-label={jumpLabel}
849 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
850 />
851 }
852 >
853 {jumpLabel}
854 </TooltipTrigger>
855 <TooltipPopup side="top">{jumpLabel}</TooltipPopup>
856 </Tooltip>
996 <button
997 type="button"
998 aria-label="Settle thread"
999 onClick={handleSettleClick}
1000 className={cn(
1001 "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",
1002 isWoke && "group-hover/sidebar-row:static",
1003 )}
1004 >
1005 <CheckIcon className="size-3" />
1006 </button>
1007 )}
1008 </span>
1009 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1010 </TooltipTrigger>
1011 {detailsTooltip}
1012 </Tooltip>
1013 </li>
1014 );
1015 }
1016
1017 const diff = latestTurnDiff(thread);
1018
1019 const sortable = props.sortable;
1020 return (
1021 <li
1022 data-thread-item
1023 ref={sortable?.setNodeRef}
1024 style={
1025 sortable
1026 ? {
1027 transform: CSS.Translate.toString(sortable.transform),
1028 transition: sortable.transition,
1029 }
1030 : undefined
1031 }
1032 {...(sortable?.listeners ?? {})}
1033 className={cn(
1034 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
1035 sortable?.isDragging && "z-20 opacity-80",
1036 )}
1037 >
1038 <Tooltip>
1039 <TooltipTrigger
1040 render={
1041 <div
1042 role="button"
1043 tabIndex={0}
1044 data-testid="sidebar-row-card"
1045 aria-busy={isRegeneratingTitle || undefined}
1046 className={rowSurfaceClassName}
1047 onClick={handleClick}
1048 onDoubleClick={handleDoubleClick}
1049 onKeyDown={handleKeyDown}
1050 onContextMenu={handleContextMenu}
1051 />
1052 }
1053 >
1054 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
1055 <div className="flex h-5 min-w-0 items-center gap-1.5">
1056 <ProjectFavicon
1057 environmentId={thread.environmentId}
1058 cwd={props.projectCwd ?? ""}
1059 className="size-4 shrink-0"
1060 />
1061 {props.projectTitle ? (
1062 <span
1063 className={cn(
1064 "min-w-0 flex-1 truncate text-secondary-label text-xs",
1065 shouldRecede ? "font-normal" : "font-medium",
1066 )}
1067 >
1068 {props.projectTitle}
1069 </span>
1070 ) : (
1071 <span className="flex-1" />
1072 )}
1073 {props.isPinned ? (
1074 props.pinningSupported ? (
1075 <button
1076 type="button"
1077 aria-label="Unpin thread"
1078 title="Unpin thread"
1079 onClick={handleUnpinClick}
1080 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1081 >
1082 <PinIcon aria-hidden className="size-3 shrink-0" />
1083 </button>
8571084 ) : (
1085 <PinIcon
1086 aria-label="Pinned"
1087 role="img"
1088 className="size-3 shrink-0 text-muted-foreground/65"
1089 />
1090 )
1091 ) : null}
1092 {/* The visible state owns this slot's width: status at rest,
1093 actions on hover/keyboard focus or while the popover is open. Keeping
1094 the hidden state out of flow lets the project label reclaim
1095 space without either state overlapping it. */}
1096 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1097 {/* Read-only status labels yield to the hover actions. Woke is
1098 itself an action, so it stays pointer-enabled and visible
1099 while the other controls appear beside it. */}
1100 <span
1101 className={cn(
1102 isWokeStatus
1103 ? "pointer-events-auto"
1104 : "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",
1105 "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1106 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1107 )}
1108 >
1109 {topStatus ? (
1110 isWokeStatus ? (
1111 <button
1112 type="button"
1113 aria-label="Dismiss Woke notification"
1114 title="Dismiss Woke notification"
1115 onClick={handleAcknowledgeWokeClick}
1116 className={cn(
1117 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1118 topStatus.className,
1119 )}
1120 >
1121 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1122 <span role="status">{topStatus.label}</span>
1123 </button>
1124 ) : (
1125 <span
1126 className={cn(
1127 "inline-flex items-center gap-1 font-medium",
1128 topStatus.className,
1129 )}
1130 >
1131 {topStatus.icon === "working" ? (
1132 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1133 ) : topStatus.icon === "done" ? (
1134 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1135 ) : null}
1136 {/* The label alone is the live region: a role="status"
1137 wrapper around the ticking duration would make
1138 screen readers announce every second. */}
1139 <span role="status">{topStatus.label}</span>
1140 {status === "working" ? (
1141 <span aria-hidden>
1142 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1143 </span>
1144 ) : null}
1145 </span>
1146 )
1147 ) : (
1148 threadTimeLabel(thread)
1149 )}
1150 </span>
1151 {props.settlementSupported || showSnoozeButton ? (
8581152 <span
859 className={`text-[10px] tabular-nums ${
860 isHighlighted ? "text-foreground" : "text-secondary-label"
861 }`}
862 >
863 {formatRelativeTimeLabel(
864 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
1153 className={cn(
1154 // focus-visible, not focus-within: a mouse click leaves
1155 // the Settle button focused, and a plain focus-within
1156 // would keep the controls pinned over the status label
1157 // once the pointer moves away (e.g. after a failed
1158 // settle) instead of cross-fading back.
1159 "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",
1160 snoozeMenuOpen && "pointer-events-auto static opacity-100",
8651161 )}
1162 >
1163 {showSnoozeButton ? (
1164 <SnoozePopoverButton
1165 open={snoozeMenuOpen}
1166 onOpenChange={setSnoozeMenuOpen}
1167 onSnooze={handleSnoozePreset}
1168 timestampFormat={props.timestampFormat}
1169 />
1170 ) : null}
1171 {props.settlementSupported ? (
1172 <button
1173 type="button"
1174 aria-label="Settle thread"
1175 onClick={handleSettleClick}
1176 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1177 >
1178 <CheckIcon className="size-3.5" />
1179 Settle
1180 </button>
1181 ) : null}
8661182 </span>
867 )}
1183 ) : null}
8681184 </span>
869 </span>
1185 </div>
1186 <div className="mt-1 flex min-w-0">
1187 {title}
1188 {isRegeneratingTitle ? (
1189 <span role="status" className="sr-only">
1190 Regenerating title
1191 </span>
1192 ) : null}
1193 </div>
1194 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
1195 {/* While working, the current plan step outranks the branch:
1196 it's the one line that says what the thread is doing. */}
1197 {status === "working" && thread.planProgress ? (
1198 <span className="min-w-0 flex-1 truncate whitespace-nowrap">
1199 {thread.planProgress.step}
1200 {/* Completed count, matching the transcript chip's n/m. */}
1201 <span className="text-icon-muted tabular-nums">
1202 {" "}
1203 {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps}
1204 </span>
1205 </span>
1206 ) : thread.branch ? (
1207 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
1208 ) : (
1209 <span className="flex-1" />
1210 )}
1211 {terminalStatusIcon}
1212 {prBadge}
1213 {diff ? (
1214 <span className="shrink-0 font-mono">
1215 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
1216 <span className="text-red-600 dark:text-red-400">−{diff.deletions}</span>
1217 </span>
1218 ) : null}
1219 <span
1220 aria-hidden
1221 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
1222 >
1223 {isRemote ? (
1224 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
1225 <ServerIcon aria-hidden className="size-3.5" />
1226 </span>
1227 ) : null}
1228 {driverKind ? (
1229 <span className="inline-flex shrink-0 items-center opacity-60">
1230 <ProviderInstanceIcon
1231 driverKind={driverKind}
1232 displayName={thread.session?.providerName ?? modelInstanceId}
1233 iconClassName="size-3.5"
1234 />
1235 </span>
1236 ) : null}
1237 </span>
1238 </div>
8701239 </div>
871 </div>
872 </SidebarMenuSubButton>
873 </SidebarMenuSubItem>
1240 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1241 </TooltipTrigger>
1242 {detailsTooltip}
1243 </Tooltip>
1244 </li>
8741245 );
8751246});
8761247
877interface SidebarProjectThreadListProps {
878 projectKey: string;
879 projectExpanded: boolean;
880 hasOverflowingThreads: boolean;
881 hiddenThreadStatus: ThreadStatusPill | null;
882 orderedProjectThreadKeys: readonly string[];
883 renderedThreads: readonly SidebarThreadSummary[];
884 showEmptyThreadState: boolean;
885 shouldShowThreadPanel: boolean;
886 isThreadListExpanded: boolean;
887 projectCwd: string;
888 activeRouteThreadKey: string | null;
889 threadJumpLabelByKey: ReadonlyMap<string, string>;
890 appSettingsConfirmThreadArchive: boolean;
891 renamingThreadKey: string | null;
892 renamingTitle: string;
893 setRenamingTitle: (title: string) => void;
894 startThreadRename: (threadKey: string, title: string) => void;
895 renamingInputRef: React.RefObject<HTMLInputElement | null>;
896 renamingCommittedRef: React.RefObject<boolean>;
897 confirmingArchiveThreadKey: string | null;
898 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
899 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
900 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
901 handleThreadClick: (
902 event: React.MouseEvent,
903 threadRef: ScopedThreadRef,
904 orderedProjectThreadKeys: readonly string[],
905 ) => void;
906 navigateToThread: (threadRef: ScopedThreadRef) => void;
907 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
908 handleThreadContextMenu: (
909 threadRef: ScopedThreadRef,
910 position: { x: number; y: number },
911 ) => Promise<void>;
912 clearSelection: () => void;
913 commitRename: (
914 threadRef: ScopedThreadRef,
915 newTitle: string,
916 originalTitle: string,
917 ) => Promise<void>;
918 cancelRename: () => void;
919 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
920 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
921 expandThreadListForProject: (projectKey: string) => void;
922 collapseThreadListForProject: (projectKey: string) => void;
1248function latestTurnDiff(
1249 thread: SidebarThreadSummary,
1250): { insertions: number; deletions: number } | null {
1251 // Shells don't carry checkpoint summaries; diff stats render only when the
1252 // shell projection grows them. Kept as a seam so the row layout is ready.
1253 void thread;
1254 return null;
9231255}
9241256
925const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
926 props: SidebarProjectThreadListProps,
927) {
928 const {
929 projectKey,
930 projectExpanded,
931 hasOverflowingThreads,
932 hiddenThreadStatus,
933 orderedProjectThreadKeys,
934 renderedThreads,
935 showEmptyThreadState,
936 shouldShowThreadPanel,
937 isThreadListExpanded,
938 projectCwd,
939 activeRouteThreadKey,
940 threadJumpLabelByKey,
941 appSettingsConfirmThreadArchive,
942 renamingThreadKey,
943 renamingTitle,
944 setRenamingTitle,
945 startThreadRename,
946 renamingInputRef,
947 renamingCommittedRef,
948 confirmingArchiveThreadKey,
949 setConfirmingArchiveThreadKey,
950 confirmArchiveButtonRefs,
951 attachThreadListAutoAnimateRef,
952 handleThreadClick,
953 navigateToThread,
954 handleMultiSelectContextMenu,
955 handleThreadContextMenu,
956 clearSelection,
957 commitRename,
958 cancelRename,
959 attemptArchiveThread,
960 openPrLink,
961 expandThreadListForProject,
962 collapseThreadListForProject,
963 } = props;
964 const showMoreButtonRender = useMemo(() => <button type="button" />, []);
965 const showLessButtonRender = useMemo(() => <button type="button" />, []);
966
1257const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1258 thread: SidebarThreadSummary;
1259 projectCwd: string | null;
1260 projectTitle: string | null;
1261 environmentLabel: string | null;
1262 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
1263 isHighlighted: boolean;
1264 isRouteActive: boolean;
1265 resultId: string;
1266 onHighlight: () => void;
1267 onSelect: () => void;
1268}) {
1269 const { thread } = props;
1270 // Same details tooltip as the regular rows: a search hit is still a thread,
1271 // and the hover card is how you disambiguate identically-titled results.
1272 const gitCwd = thread.worktreePath ?? props.projectCwd;
1273 const gitStatus = useEnvironmentQuery(
1274 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1275 ? vcsEnvironment.status({
1276 environmentId: thread.environmentId,
1277 input: { cwd: gitCwd },
1278 })
1279 : null,
1280 );
1281 const branchMismatch = resolveLocalCheckoutBranchMismatch({
1282 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
1283 activeWorktreePath: thread.worktreePath,
1284 activeThreadBranch: thread.branch,
1285 currentGitBranch: gitStatus.data?.refName ?? null,
1286 });
1287 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
1288 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
1289 const driverKind = providerEntry?.driverKind ?? null;
1290 const selectedModel = providerEntry?.models.find(
1291 (model) => model.slug === thread.modelSelection.model,
1292 );
1293 const modelLabel = selectedModel
1294 ? getTriggerDisplayModelLabel(selectedModel)
1295 : thread.modelSelection.model;
1296 const runningTerminalIds = useThreadRunningTerminalIds({
1297 environmentId: thread.environmentId,
1298 threadId: thread.id,
1299 });
1300 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
9671301 return (
968 <SidebarMenuSub
969 ref={attachThreadListAutoAnimateRef}
970 className="mx-0.5 my-0 w-full translate-x-0 gap-0.5 overflow-hidden border-l-0 px-1 py-0 sm:mx-1 sm:px-1.5"
971 >
972 {shouldShowThreadPanel && showEmptyThreadState ? (
973 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
974 <div
975 data-thread-selection-safe
976 className="flex h-8 w-full translate-x-0 items-center px-2 text-left text-xs text-sidebar-muted-foreground/75"
977 >
978 <span>No threads yet</span>
979 </div>
980 </SidebarMenuSubItem>
981 ) : null}
982 {shouldShowThreadPanel &&
983 renderedThreads.map((thread) => {
984 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
985 return (
986 <SidebarThreadRow
987 key={threadKey}
988 thread={thread}
989 projectCwd={projectCwd}
990 orderedProjectThreadKeys={orderedProjectThreadKeys}
991 isActive={activeRouteThreadKey === threadKey}
992 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
993 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
994 renamingThreadKey={renamingThreadKey}
995 renamingTitle={renamingTitle}
996 setRenamingTitle={setRenamingTitle}
997 startThreadRename={startThreadRename}
998 renamingInputRef={renamingInputRef}
999 renamingCommittedRef={renamingCommittedRef}
1000 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
1001 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
1002 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1003 handleThreadClick={handleThreadClick}
1004 navigateToThread={navigateToThread}
1005 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1006 handleThreadContextMenu={handleThreadContextMenu}
1007 clearSelection={clearSelection}
1008 commitRename={commitRename}
1009 cancelRename={cancelRename}
1010 attemptArchiveThread={attemptArchiveThread}
1011 openPrLink={openPrLink}
1302 <li role="presentation" className="list-none">
1303 <Tooltip>
1304 <TooltipTrigger
1305 render={
1306 <button
1307 id={props.resultId}
1308 type="button"
1309 role="option"
1310 // aria-activedescendant options: focus stays on the search input,
1311 // which owns all keyboard interaction for the listbox.
1312 tabIndex={-1}
1313 aria-selected={props.isHighlighted}
1314 aria-current={props.isRouteActive ? "page" : undefined}
1315 aria-label={
1316 props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title
1317 }
1318 onMouseMove={props.onHighlight}
1319 onClick={props.onSelect}
1320 className={cn(
1321 "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
1322 props.isHighlighted || props.isRouteActive
1323 ? "bg-sidebar-row-active text-sidebar-foreground"
1324 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
1325 )}
10121326 />
1013 );
1014 })}
1015
1016 {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && (
1017 <SidebarMenuSubItem className="w-full">
1018 <SidebarMenuSubButton
1019 render={showMoreButtonRender}
1020 data-thread-selection-safe
1021 size="sm"
1022 className="h-8 w-full translate-x-0 justify-start px-2 text-left text-xs text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1023 onClick={() => {
1024 expandThreadListForProject(projectKey);
1025 }}
1026 >
1027 <span className="flex min-w-0 flex-1 items-center gap-2">
1028 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1029 <span>Show more</span>
1030 </span>
1031 </SidebarMenuSubButton>
1032 </SidebarMenuSubItem>
1033 )}
1034 {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
1035 <SidebarMenuSubItem className="w-full">
1036 <SidebarMenuSubButton
1037 render={showLessButtonRender}
1038 data-thread-selection-safe
1039 size="sm"
1040 className="h-8 w-full translate-x-0 justify-start px-2 text-left text-xs text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1041 onClick={() => {
1042 collapseThreadListForProject(projectKey);
1043 }}
1044 >
1045 <span>Show less</span>
1046 </SidebarMenuSubButton>
1047 </SidebarMenuSubItem>
1048 )}
1049 </SidebarMenuSub>
1327 }
1328 >
1329 <ProjectFavicon
1330 environmentId={thread.environmentId}
1331 cwd={props.projectCwd ?? ""}
1332 className="size-4 shrink-0"
1333 fallbackIcon={MessageSquareIcon}
1334 />
1335 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
1336 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
1337 {threadTimeLabel(thread)}
1338 </span>
1339 </TooltipTrigger>
1340 <SidebarThreadTooltip
1341 thread={thread}
1342 projectTitle={props.projectTitle}
1343 projectCwd={props.projectCwd}
1344 environmentLabel={props.environmentLabel}
1345 driverKind={driverKind}
1346 modelInstanceId={modelInstanceId}
1347 modelLabel={modelLabel}
1348 branchMismatch={branchMismatch}
1349 terminalStatus={terminalStatus}
1350 terminalProcessCount={runningTerminalIds.length}
1351 />
1352 </Tooltip>
1353 </li>
10501354 );
10511355});
10521356
1053interface SidebarProjectItemProps {
1054 project: SidebarProjectSnapshot;
1055 isThreadListExpanded: boolean;
1056 activeRouteThreadKey: string | null;
1057 newThreadShortcutLabel: string | null;
1058 handleNewThread: ReturnType<typeof useNewThreadHandler>;
1059 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
1060 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
1061 threadJumpLabelByKey: ReadonlyMap<string, string>;
1062 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
1063 expandThreadListForProject: (projectKey: string) => void;
1064 collapseThreadListForProject: (projectKey: string) => void;
1065 dragInProgressRef: React.RefObject<boolean>;
1066 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
1067 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
1068 isManualProjectSorting: boolean;
1069 dragHandleProps: SortableProjectHandleProps | null;
1070}
1071
1072const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
1357export default function Sidebar() {
1358 const projects = useProjects();
1359 const projectOrder = useUiStateStore((store) => store.projectOrder);
1360 const threads = useThreadShells();
1361 const router = useRouter();
1362 const { isMobile, setOpenMobile } = useSidebar();
1363 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1364 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1365 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1366 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1367 const timestampFormat = useClientSettings((s) => s.timestampFormat);
1368 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
10731369 const {
1074 project,
1075 isThreadListExpanded,
1076 activeRouteThreadKey,
1077 newThreadShortcutLabel,
1078 handleNewThread,
1079 archiveThread,
1370 settleThread,
1371 unsettleThread,
1372 snoozeThread,
1373 unsnoozeThread,
1374 pinThread,
1375 unpinThread,
1376 reorderPinnedThread,
10801377 deleteThread,
1081 threadJumpLabelByKey,
1082 attachThreadListAutoAnimateRef,
1083 expandThreadListForProject,
1084 collapseThreadListForProject,
1085 dragInProgressRef,
1086 suppressProjectClickAfterDragRef,
1087 suppressProjectClickForContextMenuRef,
1088 isManualProjectSorting,
1089 dragHandleProps,
1090 } = props;
1091 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
1092 (settings) => settings.sidebarThreadSortOrder,
1093 );
1094 const appSettingsConfirmThreadDelete = useClientSettings<boolean>(
1095 (settings) => settings.confirmThreadDelete,
1096 );
1097 const appSettingsConfirmThreadArchive = useClientSettings<boolean>(
1098 (settings) => settings.confirmThreadArchive,
1099 );
1100 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1101 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1378 } = useThreadActions();
1379 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
11021380 reportFailure: false,
11031381 });
1104 const updateProject = useAtomCommand(projectEnvironment.update, {
1382 const deleteProject = useAtomCommand(projectEnvironment.delete, {
11051383 reportFailure: false,
11061384 });
1107 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1385 const updateProject = useAtomCommand(projectEnvironment.update, {
11081386 reportFailure: false,
11091387 });
11101388 const updateSettings = useUpdateClientSettings();
1111 const sidebarThreadPreviewCount = useClientSettings<SidebarThreadPreviewCount>(
1112 (settings) => settings.sidebarThreadPreviewCount,
1113 );
1114 const router = useRouter();
1115 const { isMobile, setOpenMobile } = useSidebar();
1116 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
1117 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
1118 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
1119 const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
1120 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
1121 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
1122 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
1123 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1124 threadId: ThreadId;
1125 }>({
1126 onCopy: (ctx) => {
1389 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
1390 onCopy: ({ path }) => {
11271391 toastManager.add({
11281392 type: "success",
1129 title: "Thread ID copied",
1130 description: ctx.threadId,
1393 title: "Path copied",
1394 description: path,
11311395 });
11321396 },
11331397 onError: (error) => {
11341398 toastManager.add(
11351399 stackedThreadToast({
11361400 type: "error",
1137 title: "Failed to copy thread ID",
1401 title: "Failed to copy path",
11381402 description: error instanceof Error ? error.message : "An error occurred.",
11391403 }),
11401404 );
11411405 },
11421406 });
1143 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1144 path: string;
1145 }>({
1146 onCopy: (ctx) => {
1407 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
1408 target: "branch name",
1409 onCopy: ({ branch }) => {
11471410 toastManager.add({
11481411 type: "success",
1149 title: "Path copied",
1150 description: ctx.path,
1412 title: "Branch copied",
1413 description: branch,
11511414 });
11521415 },
11531416 onError: (error) => {
11541417 toastManager.add(
11551418 stackedThreadToast({
11561419 type: "error",
1157 title: "Failed to copy path",
1420 title: "Failed to copy branch",
11581421 description: error instanceof Error ? error.message : "An error occurred.",
11591422 }),
11601423 );
11611424 },
11621425 });
1163 const openPrLink = useOpenPrLink();
1164 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
1165 const sidebarThreadByKey = useMemo(
1426 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
1427 null,
1428 );
1429 const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
1430 const newThreadContext = useHandleNewThread();
1431 const openAddProjectCommandPalette = useCallback(
1432 () => openCommandPalette({ open: "add-project" }),
1433 [],
1434 );
1435 const { environments } = useEnvironments();
1436 const primaryEnvironmentId = usePrimaryEnvironmentId();
1437 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
1438 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
1439 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
1440 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
1441 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
1442 const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
1443 const acknowledgeWoke = useCallback(
1444 (threadRef: ScopedThreadRef, visitedAt: string) => {
1445 markThreadVisited(scopedThreadKey(threadRef), visitedAt);
1446 },
1447 [markThreadVisited],
1448 );
1449 const routeTarget = useParams({
1450 strict: false,
1451 select: (params) => resolveThreadRouteTarget(params),
1452 });
1453 const routeDraftThread = useComposerDraftStore((store) =>
1454 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
1455 );
1456 const routeThreadRef = useMemo(
1457 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
1458 [routeDraftThread, routeTarget],
1459 );
1460 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
1461 const routeTargetRef = useRef(routeTarget);
1462 routeTargetRef.current = routeTarget;
1463 // Post-settle navigation validates against the CURRENT route, not the one
1464 // captured when the settle started: if the user navigated elsewhere while
1465 // the command was in flight, completing it must not yank them away.
1466 const routeThreadKeyRef = useRef(routeThreadKey);
1467 routeThreadKeyRef.current = routeThreadKey;
1468
1469 const environmentLabelById = useMemo(
11661470 () =>
11671471 new Map(
1168 sidebarThreads.map(
1169 (thread) =>
1170 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1171 ),
1172 ),
1173 [sidebarThreads],
1174 );
1175 // Keep a ref so callbacks can read the latest map without appearing in
1176 // dependency arrays (avoids invalidating every thread-row memo on each
1177 // thread-list change).
1178 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
1179 sidebarThreadByKeyRef.current = sidebarThreadByKey;
1180 const projectThreads = sidebarThreads;
1181 const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]);
1182 const projectExpanded = useUiStateStore((state) =>
1183 resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys),
1184 );
1185 const threadLastVisitedAts = useUiStateStore(
1186 useShallow((state) =>
1187 projectThreads.map(
1188 (thread) =>
1189 state.threadLastVisitedAtById[
1190 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
1191 ] ?? null,
1472 environments.map((environment) => [environment.environmentId, environment.label] as const),
11921473 ),
1193 ),
1474 [environments],
11941475 );
1195 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1196 const [renamingTitle, setRenamingTitle] = useState("");
1197 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1198 const [projectRenameTarget, setProjectRenameTarget] = useState<SidebarProjectGroupMember | null>(
1199 null,
1476 const orderedProjects = useMemo(
1477 () =>
1478 orderItemsByPreferredIds({
1479 items: projects,
1480 preferredIds: projectOrder,
1481 getId: getProjectOrderKey,
1482 getPreferenceIds: (project) => [
1483 getProjectOrderKey(project),
1484 legacyProjectCwdPreferenceKey(project.workspaceRoot),
1485 ],
1486 }),
1487 [projectOrder, projects],
1488 );
1489 const unsortedProjectGroups = useMemo(
1490 () =>
1491 buildSidebarProjectSnapshots({
1492 projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
1493 settings: projectGroupingSettings,
1494 primaryEnvironmentId,
1495 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
1496 }),
1497 [
1498 environmentLabelById,
1499 orderedProjects,
1500 primaryEnvironmentId,
1501 projectGroupingSettings,
1502 projects,
1503 sidebarProjectSortOrder,
1504 ],
12001505 );
1201 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1202 const [projectGroupingTarget, setProjectGroupingTarget] =
1203 useState<SidebarProjectGroupMember | null>(null);
1204 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
1205 SidebarProjectGroupingMode | "inherit"
1206 >("inherit");
1207 const renamingCommittedRef = useRef(false);
1208 const renamingInputRef = useRef<HTMLInputElement | null>(null);
1209 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1210 const memberProjectByScopedKey = useMemo(
1506 const projectGroups = useMemo(
1507 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
1508 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
1509 );
1510 const serverProviders = useAtomValue(primaryServerProvidersAtom);
1511 const providerEntryByInstanceId = useMemo(
1512 () =>
1513 new Map(
1514 deriveProviderInstanceEntries(serverProviders).map(
1515 (entry) => [entry.instanceId as string, entry] as const,
1516 ),
1517 ),
1518 [serverProviders],
1519 );
1520 const projectCwdByKey = useMemo(
12111521 () =>
12121522 new Map(
1213 project.memberProjects.map((member) => [
1214 scopedProjectKey(scopeProjectRef(member.environmentId, member.id)),
1215 member,
1523 projects.map((project) => [
1524 `${project.environmentId}:${project.id}`,
1525 project.workspaceRoot,
12161526 ]),
12171527 ),
1218 [project.memberProjects],
1528 [projects],
12191529 );
1220 const memberThreadCountByPhysicalKey = useMemo(() => {
1221 const counts = new Map<string, number>(
1222 project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const),
1223 );
1224 for (const thread of projectThreads) {
1225 const member = memberProjectByScopedKey.get(
1226 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
1227 );
1228 if (!member) {
1229 continue;
1230 }
1231 counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1);
1232 }
1233 return counts;
1234 }, [memberProjectByScopedKey, project.memberProjects, projectThreads]);
1235
1236 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1237 const lastVisitedAtByThreadKey = new Map(
1238 projectThreads.map((thread, index) => [
1239 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1240 threadLastVisitedAts[index] ?? null,
1241 ]),
1242 );
1243 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1244 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1245 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1246 );
1247 return resolveThreadStatusPill({
1248 thread: {
1249 ...thread,
1250 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1251 },
1252 });
1253 };
1254 const visibleProjectThreads = sortThreads(
1255 projectThreads.filter((thread) => thread.archivedAt === null),
1256 threadSortOrder,
1257 );
1258 const projectStatus = resolveProjectStatusIndicator(
1259 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1260 );
1261 return {
1262 orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
1263 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1530 const projectDisplayNameByKey = useMemo(
1531 () =>
1532 new Map(
1533 projectGroups.flatMap((group) =>
1534 group.memberProjects.map(
1535 (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
1536 ),
1537 ),
12641538 ),
1265 projectStatus,
1266 visibleProjectThreads,
1267 };
1268 }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
1269 const pinnedCollapsedThread = useMemo(() => {
1270 const activeThreadKey = activeRouteThreadKey ?? undefined;
1271 if (!activeThreadKey || projectExpanded) {
1272 return null;
1273 }
1274 return (
1275 visibleProjectThreads.find(
1276 (thread) =>
1277 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
1278 ) ?? null
1279 );
1280 }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
1281
1282 const {
1283 hasOverflowingThreads,
1284 hiddenThreadStatus,
1285 renderedThreads,
1286 showEmptyThreadState,
1287 shouldShowThreadPanel,
1288 } = useMemo(() => {
1289 const lastVisitedAtByThreadKey = new Map(
1290 projectThreads.map((thread, index) => [
1291 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1292 threadLastVisitedAts[index] ?? null,
1293 ]),
1294 );
1295 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1296 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1297 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1298 );
1299 return resolveThreadStatusPill({
1300 thread: {
1301 ...thread,
1302 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1303 },
1539 [projectGroups],
1540 );
1541
1542 // now is quantized to the minute so effectiveSettled memoization doesn't
1543 // churn on every render; auto-settle thresholds are day-granular anyway.
1544 const nowMinute = useNowMinute();
1545 // Snooze wake times are second-precise, so classifying with the quantized
1546 // minute would hold a woken thread on the shelf for up to a minute. The
1547 // tick is a plain counter bumped exactly at the next wake boundary (armed
1548 // below, after the partition knows the boundary); the partition reads a
1549 // fresh clock whenever it recomputes.
1550 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
1551
1552 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
1553 // closed PR auto-settles its thread on the next partition.
1554 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
1555 ReadonlyMap<string, "open" | "closed" | "merged">
1556 >(() => new Map());
1557 const handleChangeRequestState = useCallback(
1558 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
1559 setChangeRequestStateByKey((current) => {
1560 if ((current.get(threadKey) ?? null) === state) return current;
1561 const next = new Map(current);
1562 if (state === null) {
1563 next.delete(threadKey);
1564 } else {
1565 next.set(threadKey, state);
1566 }
1567 return next;
13041568 });
1305 };
1306 const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount;
1307 const previewThreads =
1308 isThreadListExpanded || !hasOverflowingThreads
1309 ? visibleProjectThreads
1310 : visibleProjectThreads.slice(0, sidebarThreadPreviewCount);
1311 const visibleThreadKeys = new Set(
1312 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1313 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1314 ),
1315 );
1316 const renderedThreads = pinnedCollapsedThread
1317 ? [pinnedCollapsedThread]
1318 : visibleProjectThreads.filter((thread) =>
1319 visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1320 );
1321 const hiddenThreads = visibleProjectThreads.filter(
1322 (thread) =>
1323 !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1324 );
1325 return {
1326 hasOverflowingThreads,
1327 hiddenThreadStatus: resolveProjectStatusIndicator(
1328 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1329 ),
1330 renderedThreads,
1331 showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
1332 shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null,
1333 };
1334 }, [
1335 isThreadListExpanded,
1336 pinnedCollapsedThread,
1337 projectExpanded,
1338 projectThreads,
1339 sidebarThreadPreviewCount,
1340 threadLastVisitedAts,
1341 visibleProjectThreads,
1342 ]);
1343
1344 const handleProjectButtonClick = useCallback(
1345 (event: React.MouseEvent<HTMLButtonElement>) => {
1346 if (suppressProjectClickForContextMenuRef.current) {
1347 suppressProjectClickForContextMenuRef.current = false;
1348 event.preventDefault();
1349 event.stopPropagation();
1350 return;
1351 }
1352 if (dragInProgressRef.current) {
1353 event.preventDefault();
1354 event.stopPropagation();
1355 return;
1356 }
1357 if (suppressProjectClickAfterDragRef.current) {
1358 suppressProjectClickAfterDragRef.current = false;
1359 event.preventDefault();
1360 event.stopPropagation();
1361 return;
1362 }
1363 if (useThreadSelectionStore.getState().hasSelection()) {
1364 clearSelection();
1365 }
1366 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
13671569 },
1368 [
1369 clearSelection,
1370 dragInProgressRef,
1371 projectExpanded,
1372 projectPreferenceKeys,
1373 setProjectExpanded,
1374 suppressProjectClickAfterDragRef,
1375 suppressProjectClickForContextMenuRef,
1376 ],
1570 [],
13771571 );
13781572
1379 const handleProjectButtonKeyDown = useCallback(
1380 (event: React.KeyboardEvent<HTMLButtonElement>) => {
1381 if (event.key !== "Enter" && event.key !== " ") return;
1382 event.preventDefault();
1383 if (dragInProgressRef.current) {
1384 return;
1385 }
1386 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1387 },
1388 [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded],
1573 // Project scope: one menu above the list. Scoping filters the list without
1574 // making the header width depend on the number or length of project names.
1575 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
1576 const scopedProjectGroup = useMemo(
1577 () =>
1578 projectScopeKey === null
1579 ? null
1580 : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
1581 [projectGroups, projectScopeKey],
13891582 );
1390
1391 const handleProjectButtonPointerDownCapture = useCallback(
1392 (event: React.PointerEvent<HTMLButtonElement>) => {
1393 suppressProjectClickForContextMenuRef.current = false;
1394 if (
1395 isContextMenuPointerDown({
1396 button: event.button,
1397 ctrlKey: event.ctrlKey,
1398 isMac: isMacPlatform(navigator.platform),
1399 })
1400 ) {
1401 event.stopPropagation();
1402 }
1403
1404 suppressProjectClickAfterDragRef.current = false;
1405 },
1406 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1583 const scopedProjectKeys = useMemo(
1584 () =>
1585 scopedProjectGroup === null
1586 ? null
1587 : new Set(
1588 scopedProjectGroup.memberProjectRefs.map(
1589 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
1590 ),
1591 ),
1592 [scopedProjectGroup],
14071593 );
1594 useEffect(() => {
1595 if (projectScopeKey !== null && scopedProjectGroup === null) {
1596 setProjectScopeKey(null);
1597 }
1598 }, [projectScopeKey, scopedProjectGroup]);
1599 // Scope flips drop the selection: rows selected under the old scope may be
1600 // hidden now, and bulk actions must never count or touch invisible rows.
1601 useEffect(() => {
1602 clearSelection();
1603 }, [clearSelection, projectScopeKey]);
14081604
1409 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1410 setProjectRenameTarget(member);
1411 setProjectRenameTitle(member.title);
1412 }, []);
1605 const handleRemoveProjectMembers = useCallback(
1606 async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => {
1607 const api = readLocalApi();
1608 if (!api) return;
14131609
1414 const openProjectGroupingDialog = useCallback(
1415 (member: SidebarProjectGroupMember) => {
1416 const overrideKey = deriveProjectGroupingOverrideKey(member);
1417 setProjectGroupingTarget(member);
1418 setProjectGroupingSelection(
1419 projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit",
1610 const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`));
1611 const projectThreads = threads.filter((thread) =>
1612 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
14201613 );
1421 },
1422 [projectGroupingSettings.sidebarProjectGroupingOverrides],
1423 );
1614 const isWholeGroup = members.length === projectGroup.memberProjects.length;
1615 const singleMember = members.length === 1 ? members[0]! : null;
1616 const targetLabel = singleMember?.title ?? projectGroup.displayName;
1617 const confirmed = await settlePromise(() =>
1618 api.dialogs.confirm(
1619 projectThreads.length > 0
1620 ? [
1621 `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
1622 ...(singleMember
1623 ? [
1624 `Path: ${singleMember.workspaceRoot}`,
1625 ...(singleMember.environmentLabel
1626 ? [`Environment: ${singleMember.environmentLabel}`]
1627 : []),
1628 ]
1629 : [`This removes ${members.length} grouped project entries.`]),
1630 "This permanently clears conversation history for those threads.",
1631 isWholeGroup
1632 ? "This removes only the project entries, not the files on disk."
1633 : "Other entries in this grouped project are unaffected.",
1634 "This action cannot be undone.",
1635 ].join("\n")
1636 : [
1637 `Remove project "${targetLabel}"?`,
1638 ...(singleMember
1639 ? [
1640 `Path: ${singleMember.workspaceRoot}`,
1641 ...(singleMember.environmentLabel
1642 ? [`Environment: ${singleMember.environmentLabel}`]
1643 : []),
1644 ]
1645 : [`This removes ${members.length} grouped project entries.`]),
1646 isWholeGroup
1647 ? "This removes only the project entries, not the files on disk."
1648 : "Other entries in this grouped project are unaffected.",
1649 ].join("\n"),
1650 ),
1651 );
1652 if (confirmed._tag === "Failure" || !confirmed.value) return;
14241653
1425 const removeProject = useCallback(
1426 async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => {
1427 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1428 const result = await deleteProject({
1429 environmentId: member.environmentId,
1430 input: {
1431 projectId: member.id,
1432 ...(options.force === true ? { force: true } : {}),
1433 },
1434 });
1435 if (result._tag === "Failure") {
1436 return result;
1437 }
14381654 const draftStore = useComposerDraftStore.getState();
1439 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1440 if (projectDraftThread) {
1441 draftStore.clearDraftThread(projectDraftThread.draftId);
1442 }
1443 draftStore.clearProjectDraftThreadId(memberProjectRef);
1444 return result;
1445 },
1446 [deleteProject],
1447 );
1655 let shouldNavigate = false;
1656 for (const project of members) {
1657 const memberThreads = projectThreads.filter(
1658 (thread) =>
1659 thread.environmentId === project.environmentId && thread.projectId === project.id,
1660 );
1661 const projectRef = scopeProjectRef(project.environmentId, project.id);
1662 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
1663 const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({
1664 routeTarget: routeTargetRef.current,
1665 projectThreads: memberThreads,
1666 projectDraftId: projectDraftThread?.draftId ?? null,
1667 });
14481668
1449 const handleRemoveProject = useCallback(
1450 async (member: SidebarProjectGroupMember) => {
1451 const api = readLocalApi();
1452 if (!api) {
1453 return;
1454 }
1669 const result = await deleteProject({
1670 environmentId: project.environmentId,
1671 input: {
1672 projectId: project.id,
1673 ...(memberThreads.length > 0 ? { force: true } : {}),
1674 },
1675 });
1676 if (result._tag === "Failure") {
1677 if (!isAtomCommandInterrupted(result)) {
1678 const error = squashAtomCommandFailure(result);
1679 toastManager.add(
1680 stackedThreadToast({
1681 type: "error",
1682 title: `Failed to remove "${project.title}"`,
1683 description: error instanceof Error ? error.message : "An error occurred.",
1684 }),
1685 );
1686 }
1687 if (shouldNavigate) {
1688 void router.navigate({ to: "/" });
1689 }
1690 return;
1691 }
14551692
1456 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1457 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1458 if (memberThreadCount > 0) {
1459 const warningToastId = toastManager.add(
1460 stackedThreadToast({
1461 type: "warning",
1462 title: "Project is not empty",
1463 description: "Delete all threads in this project before removing it.",
1464 actionVariant: "destructive",
1465 actionProps: {
1466 children: "Delete anyway",
1467 onClick: () => {
1468 void (async () => {
1469 toastManager.close(warningToastId);
1470 await new Promise<void>((resolve) => {
1471 window.setTimeout(resolve, 180);
1472 });
1473
1474 const latestProjectThreads = Array.from(
1475 sidebarThreadByKeyRef.current.values(),
1476 ).filter(
1477 (thread) =>
1478 thread.environmentId === memberProjectRef.environmentId &&
1479 thread.projectId === memberProjectRef.projectId,
1480 );
1481 const confirmed = await api.dialogs.confirm(
1482 latestProjectThreads.length > 0
1483 ? [
1484 `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${
1485 latestProjectThreads.length === 1 ? "" : "s"
1486 }?`,
1487 `Path: ${member.workspaceRoot}`,
1488 ...(member.environmentLabel
1489 ? [`Environment: ${member.environmentLabel}`]
1490 : []),
1491 "This permanently clears conversation history for those threads.",
1492 "This removes only this project entry.",
1493 "This action cannot be undone.",
1494 ].join("\n")
1495 : [
1496 `Remove project "${member.title}"?`,
1497 `Path: ${member.workspaceRoot}`,
1498 ...(member.environmentLabel
1499 ? [`Environment: ${member.environmentLabel}`]
1500 : []),
1501 "This removes only this project entry.",
1502 ].join("\n"),
1503 );
1504 if (!confirmed) {
1505 return;
1506 }
1693 shouldNavigate ||= memberRemovalNeedsNavigation;
1694 if (projectDraftThread) {
1695 draftStore.clearDraftThread(projectDraftThread.draftId);
1696 }
1697 draftStore.clearProjectDraftThreadId(projectRef);
1698 }
15071699
1508 const result = await removeProject(member, { force: true });
1509 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1510 const error = squashAtomCommandFailure(result);
1511 toastManager.add(
1512 stackedThreadToast({
1513 type: "error",
1514 title: `Failed to remove "${member.title}"`,
1515 description:
1516 error instanceof Error
1517 ? error.message
1518 : "Unknown error removing project.",
1519 }),
1520 );
1521 }
1522 })().catch((error) => {
1523 const message =
1524 error instanceof Error ? error.message : "Unknown error removing project.";
1525 console.error("Failed to remove project", {
1526 projectId: member.id,
1527 environmentId: member.environmentId,
1528 ...safeErrorLogAttributes(error),
1529 });
1530 toastManager.add(
1531 stackedThreadToast({
1532 type: "error",
1533 title: `Failed to remove "${member.title}"`,
1534 description: message,
1535 }),
1536 );
1537 });
1538 },
1539 },
1540 }),
1541 );
1542 return;
1700 if (shouldNavigate) {
1701 void router.navigate({ to: "/" });
15431702 }
1703 },
1704 [deleteProject, router, threads],
1705 );
15441706
1545 const message = [
1546 `Remove project "${member.title}"?`,
1547 `Path: ${member.workspaceRoot}`,
1548 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
1549 "This removes only this project entry.",
1550 ].join("\n");
1551 const confirmed = await api.dialogs.confirm(message);
1552 if (!confirmed) {
1707 const renameProjectMember = useCallback(
1708 async (member: SidebarProjectGroupMember, nextTitle: string) => {
1709 const title = nextTitle.trim();
1710 if (!title) {
1711 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
15531712 return;
15541713 }
1555
1556 const result = await removeProject(member);
1714 if (title === member.title) return;
1715 const result = await updateProject({
1716 environmentId: member.environmentId,
1717 input: { projectId: member.id, title },
1718 });
15571719 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
15581720 const error = squashAtomCommandFailure(result);
1559 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1560 console.error("Failed to remove project", {
1561 projectId: member.id,
1562 environmentId: member.environmentId,
1563 ...safeErrorLogAttributes(error),
1564 });
15651721 toastManager.add(
15661722 stackedThreadToast({
15671723 type: "error",
1568 title: `Failed to remove "${member.title}"`,
1569 description: message,
1724 title: "Failed to rename project",
1725 description: error instanceof Error ? error.message : "An error occurred.",
15701726 }),
15711727 );
15721728 }
15731729 },
1574 [memberThreadCountByPhysicalKey, removeProject],
1730 [updateProject],
1731 );
1732
1733 const updateProjectGroupingPreference = useCallback(
1734 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
1735 const overrideKey = deriveProjectGroupingOverrideKey(member);
1736 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
1737 if (selection === "inherit") {
1738 delete nextOverrides[overrideKey];
1739 } else {
1740 nextOverrides[overrideKey] = selection;
1741 }
1742 updateSettings({ sidebarProjectGroupingOverrides: nextOverrides });
1743 },
1744 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings],
15751745 );
15761746
1577 const handleProjectButtonContextMenu = useCallback(
1578 (event: React.MouseEvent<HTMLButtonElement>) => {
1747 const handleProjectActions = useCallback(
1748 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
15791749 event.preventDefault();
1580 suppressProjectClickForContextMenuRef.current = true;
1581 void (async () => {
1582 const api = readLocalApi();
1583 if (!api) return;
1750 event.stopPropagation();
1751 setProjectScopeMenuOpen(false);
1752 window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup));
1753 },
1754 [],
1755 );
15841756
1585 const actionHandlers = new Map<string, () => Promise<void> | void>();
1586 const makeLeaf = (
1587 action: "rename" | "grouping" | "copy-path" | "delete",
1588 member: SidebarProjectGroupMember,
1589 options?: {
1590 destructive?: boolean;
1591 disabled?: boolean;
1592 },
1593 ): ContextMenuItem<string> => {
1594 const id = `${action}:${member.physicalProjectKey}`;
1595 actionHandlers.set(id, () => {
1596 switch (action) {
1597 case "rename":
1598 openProjectRenameDialog(member);
1599 return;
1600 case "grouping":
1601 openProjectGroupingDialog(member);
1602 return;
1603 case "copy-path":
1604 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot });
1605 return;
1606 case "delete":
1607 return handleRemoveProject(member);
1608 }
1609 });
1757 // Settled threads stay in the live shell stream (settled ≠ archived), so
1758 // the partition works directly off live shells: no archived-snapshot
1759 // merging, no optimistic holds. Archived threads remain hidden here —
1760 // archive keeps its original "remove from sidebar" meaning.
1761 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1762 const {
1763 pinnedThreads,
1764 reorderablePinnedKeys,
1765 activeThreads,
1766 snoozedThreads,
1767 settledThreads,
1768 snoozeNow,
1769 } = useMemo(() => {
1770 const now = `${nowMinute}:00.000Z`;
1771 // Snooze classification uses a REAL clock, not the quantized minute:
1772 // wake times are second-precise and a woken thread must not linger on
1773 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1774 // memo exactly at the next wake boundary.
1775 void snoozeWakeTick;
1776 const preciseNow = new Date().toISOString();
1777 const visible = threads.filter(
1778 (thread) =>
1779 thread.archivedAt === null &&
1780 (scopedProjectKeys === null ||
1781 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1782 );
1783 const pinned: EnvironmentThreadShell[] = [];
1784 const active: EnvironmentThreadShell[] = [];
1785 const snoozed: EnvironmentThreadShell[] = [];
1786 const settled: EnvironmentThreadShell[] = [];
1787 for (const thread of visible) {
1788 // Threads on servers without the settlement capability (old server,
1789 // or descriptor not loaded yet) never classify as settled: the user
1790 // could neither un-settle nor pin them, so auto-settling them would
1791 // strand rows in a tail with no working affordances.
1792 const supportsSettlement =
1793 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1794 const supportsSnooze =
1795 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1796 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1797 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1798 // Snooze outranks everything, including a pin: "hide until Tuesday"
1799 // temporarily suspends "keep on top". The pin survives underneath —
1800 // and so does its pinOrderKey, so on wake the thread reappears at
1801 // its exact slot in the pinned block. (For unpinned threads
1802 // this is also the snooze-beats-auto-settle rule: the wake time is a
1803 // stronger statement about when the thread matters again.)
1804 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1805 snoozed.push(thread);
1806 // A pin otherwise overrides the lifecycle: pinned threads never
1807 // auto-settle out of sight. (The decider clears settled state on
1808 // pin and the pin on settle, so pin-vs-settled conflicts only
1809 // arise from stale or raced writes.)
1810 } else if (thread.pinnedAt != null) {
1811 pinned.push(thread);
1812 } else if (
1813 supportsSettlement &&
1814 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1815 ) {
1816 settled.push(thread);
1817 } else {
1818 active.push(thread);
1819 }
1820 }
1821 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
1822 // user-arranged keys first, keyless threads in creation order below.
1823 // Server capability only gates DRAGGING — it must not influence the
1824 // sort, or mixed-version fleets would render different pinned orders on
1825 // web and mobile from the same data.
1826 return {
1827 pinnedThreads: sortPinnedThreadsForSidebar(pinned),
1828 reorderablePinnedKeys: new Set(
1829 pinned
1830 .filter(
1831 (thread) =>
1832 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
1833 true,
1834 )
1835 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1836 ),
1837 activeThreads: sortThreadsForSidebar(active),
1838 // Soonest wake first: "what comes back next" is the shelf's question.
1839 snoozedThreads: snoozed.toSorted(
1840 (left, right) =>
1841 firstValidTimestampMs(left.snoozedUntil ?? null) -
1842 firstValidTimestampMs(right.snoozedUntil ?? null),
1843 ),
1844 settledThreads: sortSettledThreadsForSidebar(settled),
1845 snoozeNow: preciseNow,
1846 };
1847 }, [
1848 autoSettleAfterDays,
1849 changeRequestStateByKey,
1850 nowMinute,
1851 scopedProjectKeys,
1852 serverConfigs,
1853 snoozeWakeTick,
1854 threads,
1855 ]);
16101856
1611 return {
1612 id,
1613 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1614 ...(options?.destructive ? { destructive: true } : {}),
1615 ...(options?.disabled ? { disabled: true } : {}),
1616 };
1617 };
1618
1619 const buildTargetedItem = (
1620 action: "rename" | "grouping" | "copy-path" | "delete",
1621 label: string,
1622 options?: {
1623 destructive?: boolean;
1624 isDisabled?: (member: SidebarProjectGroupMember) => boolean;
1625 },
1626 ): ContextMenuItem<string> => {
1627 if (project.memberProjects.length === 1) {
1628 const singleMember = project.memberProjects[0]!;
1629 return {
1630 ...makeLeaf(action, singleMember, {
1631 ...(options?.destructive ? { destructive: true } : {}),
1632 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
1633 }),
1634 label,
1635 ...(action === "delete" ? { icon: "trash" } : {}),
1636 };
1637 }
1857 const threadSearchInputRef = useRef<HTMLInputElement>(null);
1858 const [threadSearchQuery, setThreadSearchQuery] = useState("");
1859 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
1860 const isSearchingThreads = threadSearchQuery.trim().length > 0;
1861 const searchableThreads = useMemo(
1862 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
1863 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
1864 );
1865 const threadSearchResults = useMemo(
1866 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
1867 [searchableThreads, threadSearchQuery],
1868 );
1869 const threadSearchResultOrderKey = threadSearchResults
1870 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
1871 .join("\0");
16381872
1639 return {
1640 id: `${action}:submenu`,
1641 label,
1642 ...(action === "delete" ? { icon: "trash" } : {}),
1643 children: project.memberProjects.map((member) =>
1644 makeLeaf(action, member, {
1645 ...(options?.destructive ? { destructive: true } : {}),
1646 ...(options?.isDisabled?.(member) ? { disabled: true } : {}),
1647 }),
1648 ),
1649 };
1650 };
1873 useEffect(() => {
1874 setActiveSearchResultIndex(0);
1875 }, [threadSearchResultOrderKey]);
16511876
1652 const clicked = await api.contextMenu.show(
1653 [
1654 buildTargetedItem("rename", "Rename"),
1655 buildTargetedItem("grouping", "Group into..."),
1656 buildTargetedItem("copy-path", "Copy Path"),
1657 buildTargetedItem("delete", "Remove", {
1658 destructive: true,
1659 }),
1660 ],
1661 {
1662 x: event.clientX,
1663 y: event.clientY,
1664 },
1877 useEffect(() => {
1878 if (!isSearchingThreads) return;
1879 document
1880 .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`)
1881 ?.scrollIntoView({ block: "nearest" });
1882 }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]);
1883
1884 // Arm a timeout for the earliest upcoming wake so the shelf empties the
1885 // moment a snooze expires instead of on the next minute tick. Sorted
1886 // soonest-first, so entry 0 is the boundary.
1887 useEffect(() => {
1888 const nextWakeAtMs =
1889 snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null
1890 ? Date.parse(snoozedThreads[0].snoozedUntil)
1891 : Number.NaN;
1892 if (Number.isNaN(nextWakeAtMs)) return;
1893 // setTimeout delays are signed 32-bit: anything larger overflows and
1894 // fires immediately, turning a far-future wake (event-condition snoozes
1895 // synced from elsewhere) into a tight re-arm loop. Clamped, the timer
1896 // just re-arms every ~24.8 days until the wake is in range.
1897 const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647);
1898 const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
1899 return () => window.clearTimeout(id);
1900 }, [snoozedThreads]);
1901
1902 // The settled tail renders in pages: history shouldn't dominate the
1903 // sidebar, and the common lookups are recent. Expansion resets when the
1904 // filter context changes so a scope/search flip never inherits a deep
1905 // page state.
1906 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
1907 const settledResetKey = projectScopeKey ?? "all";
1908 const lastSettledResetKeyRef = useRef(settledResetKey);
1909 if (lastSettledResetKeyRef.current !== settledResetKey) {
1910 lastSettledResetKeyRef.current = settledResetKey;
1911 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
1912 }
1913 const visibleSettledThreads = useMemo(() => {
1914 if (settledThreads.length <= settledVisibleCount) return settledThreads;
1915 const visible = settledThreads.slice(0, settledVisibleCount);
1916 // The open thread must never hide under "Show more": navigating into a
1917 // deep settled thread (search, deep link) pulls its row into the visible
1918 // tail so the highlight and the un-settle affordance stay reachable.
1919 if (routeThreadKey !== null) {
1920 const routeThread = settledThreads
1921 .slice(settledVisibleCount)
1922 .find(
1923 (thread) =>
1924 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
16651925 );
1926 if (routeThread !== undefined) visible.push(routeThread);
1927 }
1928 return visible;
1929 }, [routeThreadKey, settledThreads, settledVisibleCount]);
1930 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;
1931 const showMoreSettled = useCallback(
1932 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
1933 [],
1934 );
1935 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
1936 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
1937 const renderedSettledThreads = useMemo(() => {
1938 if (settledShelfExpanded) return visibleSettledThreads;
1939 if (routeThreadKey === null) return [];
1940 const routeThread = visibleSettledThreads.find(
1941 (thread) =>
1942 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1943 );
1944 return routeThread === undefined ? [] : [routeThread];
1945 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
1946
1947 // The snoozed shelf is collapsed by default: out of the way, never gone.
1948 // Collapsed threads don't render (and so don't participate in jump
1949 // shortcuts or multi-select), matching the settled tail's paging model.
1950 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
1951 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
1952 const visibleSnoozedThreads = useMemo(() => {
1953 if (snoozedShelfExpanded) return snoozedThreads;
1954 // The open thread must never vanish behind the collapsed shelf: a
1955 // snoozed thread reached by route (deep link, open before snoozing
1956 // elsewhere) keeps its row — with highlight and wake affordance — same
1957 // exception the settled tail's "Show more" makes.
1958 if (routeThreadKey === null) return [];
1959 const routeThread = snoozedThreads.find(
1960 (thread) =>
1961 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1962 );
1963 return routeThread === undefined ? [] : [routeThread];
1964 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
16661965
1667 if (!clicked) {
1668 return;
1669 }
1670
1671 await actionHandlers.get(clicked)?.();
1672 })();
1673 },
1674 [
1675 copyPathToClipboard,
1676 handleRemoveProject,
1677 openProjectGroupingDialog,
1678 openProjectRenameDialog,
1679 project.groupedProjectCount,
1680 project.memberProjects,
1681 suppressProjectClickForContextMenuRef,
1682 ],
1966 const orderedThreads = useMemo(
1967 () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1968 [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],
1969 );
1970 const orderedThreadKeys = useMemo(
1971 () =>
1972 orderedThreads.map((thread) =>
1973 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1974 ),
1975 [orderedThreads],
1976 );
1977 // Rows call back into the click handler without carrying the ordered list as
1978 // a prop — a fresh array identity per shell update would defeat every row's
1979 // memoization. The ref keeps shift-range-select working against the list as
1980 // rendered at click time.
1981 const orderedThreadKeysRef = useRef(orderedThreadKeys);
1982 orderedThreadKeysRef.current = orderedThreadKeys;
1983 const threadByKey = useMemo(
1984 () =>
1985 new Map(
1986 orderedThreads.map(
1987 (thread) =>
1988 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1989 ),
1990 ),
1991 [orderedThreads],
1992 );
1993 // Handlers read these through refs: depending on per-update Map/Set
1994 // identities would give every row a fresh callback prop on each shell
1995 // event and defeat row memoization during streaming.
1996 const threadByKeyRef = useRef(threadByKey);
1997 threadByKeyRef.current = threadByKey;
1998 // handleNewThread is inherently unstable (depends on the projects list);
1999 // a ref keeps it out of attemptSettle's dependency array.
2000 const handleNewThreadRef = useRef(newThreadContext.handleNewThread);
2001 handleNewThreadRef.current = newThreadContext.handleNewThread;
2002 const settledThreadKeys = useMemo(
2003 () =>
2004 new Set(
2005 settledThreads.map((thread) =>
2006 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2007 ),
2008 ),
2009 [settledThreads],
16832010 );
2011 const settledThreadKeysRef = useRef(settledThreadKeys);
2012 settledThreadKeysRef.current = settledThreadKeys;
2013 const snoozedThreadKeys = useMemo(
2014 () =>
2015 new Set(
2016 snoozedThreads.map((thread) =>
2017 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2018 ),
2019 ),
2020 [snoozedThreads],
2021 );
2022 const snoozedThreadKeysRef = useRef(snoozedThreadKeys);
2023 snoozedThreadKeysRef.current = snoozedThreadKeys;
2024
2025 const jumpLabelByKey = useMemo(() => {
2026 const mapping = new Map<string, string>();
2027 for (const [index, threadKey] of orderedThreadKeys.entries()) {
2028 const jumpCommand = threadJumpCommandForIndex(index);
2029 if (!jumpCommand) break;
2030 const label = shortcutLabelForCommand(keybindings, jumpCommand);
2031 if (label) mapping.set(threadKey, label);
2032 }
2033 return mapping;
2034 }, [keybindings, orderedThreadKeys]);
2035 const [showJumpHints, setShowJumpHints] = useState(false);
16842036
2037 // Settled threads are live shells, so opening one is plain navigation:
2038 // history stays readable without un-settling, and sending a message or
2039 // starting a session un-settles server-side.
16852040 const navigateToThread = useCallback(
16862041 (threadRef: ScopedThreadRef) => {
16872042 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
......@@ -1699,145 +2054,636 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16992054 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
17002055 );
17012056
1702 const handleThreadClick = useCallback(
1703 (
1704 event: React.MouseEvent,
1705 threadRef: ScopedThreadRef,
1706 orderedProjectThreadKeys: readonly string[],
1707 ) => {
1708 const isMac = isMacPlatform(navigator.platform);
1709 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1710 const isShiftClick = event.shiftKey;
1711 const threadKey = scopedThreadKey(threadRef);
1712 const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
1713
1714 if (isModClick) {
2057 const clearThreadSearch = useCallback(() => {
2058 setThreadSearchQuery("");
2059 setActiveSearchResultIndex(0);
2060 }, []);
2061 const selectThreadSearchResult = useCallback(
2062 (thread: EnvironmentThreadShell) => {
2063 clearThreadSearch();
2064 navigateToThread(scopeThreadRef(thread.environmentId, thread.id));
2065 },
2066 [clearThreadSearch, navigateToThread],
2067 );
2068 const handleThreadSearchKeyDown = useCallback(
2069 (event: ReactKeyboardEvent<HTMLInputElement>) => {
2070 // IME composition (Japanese/Chinese input) uses the same keys; committing
2071 // a candidate must not move the highlight or navigate away mid-compose.
2072 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2073 if (event.key === "Escape" && isSearchingThreads) {
17152074 event.preventDefault();
1716 toggleThreadSelection(threadKey);
2075 event.stopPropagation();
2076 clearThreadSearch();
17172077 return;
17182078 }
1719
1720 if (isShiftClick) {
2079 if (threadSearchResults.length === 0) return;
2080 if (event.key === "ArrowDown") {
17212081 event.preventDefault();
1722 rangeSelectTo(threadKey, orderedProjectThreadKeys);
2082 setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length);
17232083 return;
17242084 }
1725
1726 // Ignore the trailing click of a plain double-click so it doesn't navigate
1727 // while a double-click is starting an inline rename. Placed after the
1728 // modifier branches so cmd/shift selection still processes every click.
1729 if (isTrailingDoubleClick(event.detail)) {
2085 if (event.key === "ArrowUp") {
2086 event.preventDefault();
2087 setActiveSearchResultIndex(
2088 (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length,
2089 );
17302090 return;
17312091 }
1732
1733 if (currentSelectionCount > 0) {
1734 clearSelection();
1735 }
1736 setSelectionAnchor(threadKey);
1737 if (isMobile) {
1738 setOpenMobile(false);
2092 if (event.key === "Enter") {
2093 event.preventDefault();
2094 const result = threadSearchResults[activeSearchResultIndex];
2095 if (result) selectThreadSearchResult(result);
17392096 }
1740 void router.navigate({
1741 to: "/$environmentId/$threadId",
1742 params: buildThreadRouteParams(threadRef),
1743 });
17442097 },
17452098 [
1746 clearSelection,
1747 isMobile,
1748 rangeSelectTo,
1749 router,
1750 setOpenMobile,
1751 setSelectionAnchor,
1752 toggleThreadSelection,
2099 activeSearchResultIndex,
2100 clearThreadSearch,
2101 isSearchingThreads,
2102 selectThreadSearchResult,
2103 threadSearchResults,
17532104 ],
17542105 );
17552106
1756 const handleMultiSelectContextMenu = useCallback(
1757 async (position: { x: number; y: number }) => {
1758 const api = readLocalApi();
1759 if (!api) return;
1760 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1761 if (threadKeys.length === 0) return;
1762 const count = threadKeys.length;
1763 const selectedThreadEntries = threadKeys.flatMap((threadKey) => {
1764 const threadRef = parseScopedThreadKey(threadKey);
1765 const thread = threadRef ? readThreadShell(threadRef) : null;
1766 return threadRef && thread ? [{ threadKey, threadRef, thread }] : [];
1767 });
1768 const hasRunningThread = selectedThreadEntries.some(
1769 ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null,
1770 );
1771
1772 const clicked = await api.contextMenu.show(
1773 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }),
1774 position,
1775 );
1776
1777 if (clicked === "mark-unread") {
1778 for (const { threadKey, thread } of selectedThreadEntries) {
1779 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2107 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
2108 const [renamingTitle, setRenamingTitle] = useState("");
2109 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
2110 setRenamingThreadKey(scopedThreadKey(threadRef));
2111 setRenamingTitle(title);
2112 }, []);
2113 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
2114 const commitThreadRename = useCallback(
2115 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
2116 void (async () => {
2117 const trimmed = title.trim();
2118 setRenamingThreadKey(null);
2119 if (trimmed.length === 0) {
2120 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
2121 return;
17802122 }
1781 clearSelection();
2123 if (trimmed === originalTitle) return;
2124 const result = await updateThreadMetadata({
2125 environmentId: threadRef.environmentId,
2126 input: { threadId: threadRef.threadId, title: trimmed },
2127 });
2128 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2129 const error = squashAtomCommandFailure(result);
2130 toastManager.add(
2131 stackedThreadToast({
2132 type: "error",
2133 title: "Failed to rename thread",
2134 description: error instanceof Error ? error.message : "An error occurred.",
2135 }),
2136 );
2137 }
2138 })();
2139 },
2140 [updateThreadMetadata],
2141 );
2142
2143 const handleThreadClick = useCallback(
2144 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
2145 const isMac = isMacPlatform(navigator.platform);
2146 const isModClick = isMac ? event.metaKey : event.ctrlKey;
2147 const threadKey = scopedThreadKey(threadRef);
2148 if (isModClick) {
2149 event.preventDefault();
2150 toggleThreadSelection(threadKey);
2151 return;
2152 }
2153 if (event.shiftKey) {
2154 event.preventDefault();
2155 rangeSelectTo(threadKey, orderedThreadKeysRef.current);
17822156 return;
17832157 }
2158 if (isTrailingDoubleClick(event.detail)) {
2159 return;
2160 }
2161 navigateToThread(threadRef);
2162 },
2163 [navigateToThread, rangeSelectTo, toggleThreadSelection],
2164 );
2165
2166 // A settle per thread at a time: double clicks and repeated menu picks
2167 // must not dispatch a second settle that fails and toasts a false error.
2168 const settlingThreadKeysRef = useRef(new Set<string>());
2169 // Parking the thread you're looking at (settle or snooze) moves you
2170 // forward: the next remaining card (never a settled or snoozed row, never
2171 // one leaving in the same batch), or a fresh draft in this project when it
2172 // was the last active one. Callers snapshot the plan BEFORE the command
2173 // mutates the partition; background parks never navigate (null plan).
2174 const planForwardNavigation = useCallback(
2175 (threadKey: string, coParkingKeys?: ReadonlySet<string>): (() => void) | null => {
2176 if (routeThreadKeyRef.current !== threadKey) return null;
2177 const shell = threadByKeyRef.current.get(threadKey);
2178 const orderedKeys = orderedThreadKeysRef.current;
2179 const settledKeys = settledThreadKeysRef.current;
2180 const snoozedKeys = snoozedThreadKeysRef.current;
2181 const currentIndex = orderedKeys.indexOf(threadKey);
2182 const nextCardKey =
2183 currentIndex === -1
2184 ? null
2185 : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find(
2186 (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key),
2187 ) ?? null);
2188 const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
2189 return nextThread
2190 ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
2191 : shell
2192 ? () =>
2193 void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId))
2194 : () => void router.navigate({ to: "/" });
2195 },
2196 [navigateToThread, router],
2197 );
17842198
1785 if (clicked === "archive") {
1786 if (appSettingsConfirmThreadArchive) {
1787 const confirmed = await api.dialogs.confirm(
1788 `Archive ${count} thread${count === 1 ? "" : "s"}?`,
2199 const attemptSettle = useCallback(
2200 (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => {
2201 void (async () => {
2202 const threadKey = scopedThreadKey(threadRef);
2203 if (settlingThreadKeysRef.current.has(threadKey)) return;
2204 settlingThreadKeysRef.current.add(threadKey);
2205 try {
2206 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
2207 const result = await settleThread(threadRef);
2208 if (result._tag === "Failure") {
2209 // Never navigate away from a thread that did not settle.
2210 if (!isAtomCommandInterrupted(result)) {
2211 const error = squashAtomCommandFailure(result);
2212 toastManager.add(
2213 stackedThreadToast({
2214 type: "error",
2215 title: "Failed to settle thread",
2216 description: error instanceof Error ? error.message : "An error occurred.",
2217 }),
2218 );
2219 }
2220 return;
2221 }
2222 // Only move forward if the user is still on the settled thread —
2223 // a navigation made during the await wins over ours.
2224 if (routeThreadKeyRef.current === threadKey) {
2225 navigateAfterSettle?.();
2226 }
2227 } finally {
2228 settlingThreadKeysRef.current.delete(threadKey);
2229 }
2230 })();
2231 },
2232 [planForwardNavigation, settleThread],
2233 );
2234 const attemptUnsettle = useCallback(
2235 (threadRef: ScopedThreadRef) => {
2236 void (async () => {
2237 const result = await unsettleThread(threadRef);
2238 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2239 const error = squashAtomCommandFailure(result);
2240 toastManager.add(
2241 stackedThreadToast({
2242 type: "error",
2243 title: "Failed to un-settle thread",
2244 description: error instanceof Error ? error.message : "An error occurred.",
2245 }),
17892246 );
1790 if (!confirmed) return;
17912247 }
1792
1793 const archiveOutcome = await archiveSelectedThreadEntries({
1794 entries: selectedThreadEntries,
1795 archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }),
1796 });
1797 for (const failure of archiveOutcome.followupFailures) {
1798 if (isAtomCommandInterrupted(failure)) continue;
1799 const error = squashAtomCommandFailure(failure);
2248 })();
2249 },
2250 [unsettleThread],
2251 );
2252 const attemptUnsnooze = useCallback(
2253 (threadRef: ScopedThreadRef) => {
2254 void (async () => {
2255 const result = await unsnoozeThread(threadRef);
2256 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2257 const error = squashAtomCommandFailure(result);
2258 toastManager.add(
2259 stackedThreadToast({
2260 type: "error",
2261 title: "Failed to wake thread",
2262 description: error instanceof Error ? error.message : "An error occurred.",
2263 }),
2264 );
2265 }
2266 })();
2267 },
2268 [unsnoozeThread],
2269 );
2270 // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
2271 // for the moved thread and sends it to that thread's own server (see
2272 // planPinnedReorder for the keyless-neighbor materialization case). The
2273 // optimistic order keeps the card where it was dropped until the
2274 // confirming event round-trips; canonical order matching it releases the
2275 // override, and a failed write clears it (the card snaps back) with a toast.
2276 // ANY membership change (new pin, unpin, snooze/wake) also releases it:
2277 // the override can't say where members it never saw belong, and holding it
2278 // would misplace them and launder the stale order into later drags.
2279 const pinnedDndSensors = useSensors(
2280 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
2281 );
2282 const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
2283 readonly order: readonly string[];
2284 /** pinOrderKey per thread as of the drop, so ANY landed write (ours
2285 confirming, or a concurrent one from another client) releases the
2286 override rather than fighting canonical state. */
2287 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2288 } | null>(null);
2289 const orderedPinnedThreads = useMemo(() => {
2290 if (optimisticPinnedOrder === null) return pinnedThreads;
2291 return orderItemsByPreferredIds({
2292 items: pinnedThreads,
2293 preferredIds: optimisticPinnedOrder.order,
2294 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2295 });
2296 }, [optimisticPinnedOrder, pinnedThreads]);
2297 useEffect(() => {
2298 if (optimisticPinnedOrder === null) return;
2299 const canonical = pinnedThreads.filter((thread) =>
2300 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2301 );
2302 const canonicalKeys = canonical.map((thread) =>
2303 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2304 );
2305 // The override represents one drop against one snapshot of the world.
2306 // Release it as soon as the world moves on in any way: membership
2307 // changed (pin/unpin/snooze/wake — the override can't say where members
2308 // it never saw belong), a key changed (our write confirming, or a
2309 // concurrent client's reorder that must win), or canonical already
2310 // matches. Holding it longer would misplace newcomers and launder the
2311 // stale order into later drags.
2312 const membershipChanged =
2313 canonicalKeys.length !== optimisticPinnedOrder.order.length ||
2314 canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
2315 const anyKeyLanded = canonical.some(
2316 (thread, index) =>
2317 optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !==
2318 (thread.pinOrderKey ?? null),
2319 );
2320 const orderConfirmed =
2321 !membershipChanged &&
2322 canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
2323 if (membershipChanged || anyKeyLanded || orderConfirmed) {
2324 setOptimisticPinnedOrder(null);
2325 }
2326 }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
2327 const attemptPin = useCallback(
2328 (threadRef: ScopedThreadRef) => {
2329 void (async () => {
2330 // Fresh pins take the top of the arranged run: pinThread computes a
2331 // key before the smallest key across ALL pinned shells — including
2332 // snoozed pins hidden from this list, whose keys are still part of
2333 // the run — so the new pin can't land beneath a hidden head.
2334 const result = await pinThread(threadRef);
2335 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2336 const error = squashAtomCommandFailure(result);
2337 toastManager.add(
2338 stackedThreadToast({
2339 type: "error",
2340 title: "Failed to pin thread",
2341 description: error instanceof Error ? error.message : "An error occurred.",
2342 }),
2343 );
2344 }
2345 })();
2346 },
2347 [pinThread],
2348 );
2349 const attemptUnpin = useCallback(
2350 (threadRef: ScopedThreadRef) => {
2351 void (async () => {
2352 const result = await unpinThread(threadRef);
2353 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2354 const error = squashAtomCommandFailure(result);
18002355 toastManager.add(
18012356 stackedThreadToast({
18022357 type: "error",
1803 title: "Thread archived, but navigation failed",
2358 title: "Failed to unpin thread",
18042359 description: error instanceof Error ? error.message : "An error occurred.",
18052360 }),
18062361 );
18072362 }
1808 if (archiveOutcome.mutationFailure) {
1809 removeFromSelection(archiveOutcome.archivedThreadKeys);
1810 if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) {
1811 const error = squashAtomCommandFailure(archiveOutcome.mutationFailure);
2363 })();
2364 },
2365 [unpinThread],
2366 );
2367
2368 const handlePinnedDragEnd = useCallback(
2369 (event: DragEndEvent) => {
2370 const activeKey = String(event.active.id);
2371 const overKey = event.over === null ? null : String(event.over.id);
2372 if (overKey === null || activeKey === overKey) return;
2373 const reorderable = orderedPinnedThreads.filter((thread) =>
2374 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2375 );
2376 const keys = reorderable.map((thread) =>
2377 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2378 );
2379 const fromIndex = keys.indexOf(activeKey);
2380 const toIndex = keys.indexOf(overKey);
2381 if (fromIndex === -1 || toIndex === -1) return;
2382 const newOrder = arrayMove([...keys], fromIndex, toIndex);
2383 const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
2384 const keysAtDrop = new Map(
2385 reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
2386 );
2387 const assignments = planPinnedReorder({
2388 orderedIds: newOrder,
2389 keysById: keysAtDrop,
2390 movedId: activeKey,
2391 });
2392 if (assignments.length === 0) return;
2393 setOptimisticPinnedOrder({ order: newOrder, keysAtDrop });
2394 void (async () => {
2395 // Sequential, stop on first failure. There is deliberately no
2396 // rollback: every key write is a complete, valid placement on its
2397 // own, so a partial materialization leaves a sensible order (and
2398 // the next drag repairs the rest) — unwinding writes across
2399 // servers would trade that for real inconsistency windows.
2400 for (const assignment of assignments) {
2401 const thread = threadByKey.get(assignment.id);
2402 if (thread === undefined) continue;
2403 const result = await reorderPinnedThread(
2404 scopeThreadRef(thread.environmentId, thread.id),
2405 assignment.orderKey,
2406 );
2407 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2408 setOptimisticPinnedOrder(null);
2409 const error = squashAtomCommandFailure(result);
18122410 toastManager.add(
18132411 stackedThreadToast({
18142412 type: "error",
1815 title: "Failed to archive threads",
2413 title: "Failed to reorder pinned threads",
18162414 description: error instanceof Error ? error.message : "An error occurred.",
18172415 }),
18182416 );
2417 return;
18192418 }
1820 return;
18212419 }
1822 removeFromSelection(threadKeys);
1823 return;
2420 })();
2421 },
2422 [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
2423 );
2424 // One snooze per thread at a time — same double-dispatch guard as settle.
2425 const snoozingThreadKeysRef = useRef(new Set<string>());
2426 const performSnooze = useCallback(
2427 async (
2428 threadRef: ScopedThreadRef,
2429 preset: SnoozePreset,
2430 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
2431 ) => {
2432 const threadKey = scopedThreadKey(threadRef);
2433 if (snoozingThreadKeysRef.current.has(threadKey)) {
2434 return { status: "skipped" } as const;
18242435 }
2436 snoozingThreadKeysRef.current.add(threadKey);
2437 try {
2438 // Snoozing the open thread moves you forward, same as settle —
2439 // both park the thread you're done with for now.
2440 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
2441 const result = await snoozeThread(threadRef, preset.snoozedUntil);
2442 if (result._tag === "Failure") {
2443 // Never navigate away from a thread that did not snooze.
2444 return isAtomCommandInterrupted(result)
2445 ? ({ status: "interrupted" } as const)
2446 : ({ status: "failure", error: squashAtomCommandFailure(result) } as const);
2447 }
2448 // Only move forward if the user is still on the snoozed thread —
2449 // a navigation made during the await wins over ours.
2450 if (routeThreadKeyRef.current === threadKey) {
2451 navigateAfterSnooze?.();
2452 }
2453 return { status: "success" } as const;
2454 } finally {
2455 snoozingThreadKeysRef.current.delete(threadKey);
2456 }
2457 },
2458 [planForwardNavigation, snoozeThread],
2459 );
2460 const attemptSnooze = useCallback(
2461 (
2462 threadRef: ScopedThreadRef,
2463 preset: SnoozePreset,
2464 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
2465 ) => {
2466 void (async () => {
2467 const outcome = await performSnooze(threadRef, preset, opts);
2468 if (outcome.status === "failure") {
2469 toastManager.add(
2470 stackedThreadToast({
2471 type: "error",
2472 title: "Failed to snooze thread",
2473 description:
2474 outcome.error instanceof Error ? outcome.error.message : "An error occurred.",
2475 }),
2476 );
2477 return;
2478 }
2479 if (outcome.status !== "success") return;
2480 // Snooze hides the row, so the toast is the only confirmation —
2481 // and the Undo is the escape hatch for a mis-click.
2482 toastManager.add(
2483 stackedThreadToast({
2484 type: "success",
2485 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
2486 timeout: 5_000,
2487 actionProps: {
2488 children: "Undo",
2489 onClick: () => attemptUnsnooze(threadRef),
2490 },
2491 }),
2492 );
2493 })();
2494 },
2495 [attemptUnsnooze, performSnooze, timestampFormat],
2496 );
18252497
1826 if (clicked !== "delete") return;
1827
1828 if (appSettingsConfirmThreadDelete) {
1829 const confirmed = await api.dialogs.confirm(
2498 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
2499 const handleMultiSelectContextMenu = useCallback(
2500 async (position: { x: number; y: number }) => {
2501 const api = readLocalApi();
2502 if (!api) return;
2503 // One exact actionable set: keys whose rows are actually rendered
2504 // right now. Selections can outlive their rows (settled-tail paging,
2505 // thread deletion elsewhere) and the menu labels must count only what
2506 // the actions will touch.
2507 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter(
2508 (threadKey) => threadByKeyRef.current.has(threadKey),
2509 );
2510 if (threadKeys.length === 0) return;
2511 const count = threadKeys.length;
2512 // Snooze (N) is offered when every selected thread can actually take
2513 // it — a mixed selection with blocked-on-you work would half-apply.
2514 const selectionNow = new Date();
2515 const selectedThreads = threadKeys.flatMap((threadKey) => {
2516 const thread = threadByKeyRef.current.get(threadKey);
2517 return thread ? [thread] : [];
2518 });
2519 const canSnoozeSelection = selectedThreads.every(
2520 (thread) =>
2521 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
2522 canSnooze(thread, { now: selectionNow.toISOString() }),
2523 );
2524 const titleRegenerationThreads = selectedThreads.filter(
2525 (thread) =>
2526 serverConfigs.get(thread.environmentId)?.environment.capabilities
2527 .threadTitleRegeneration === true,
2528 );
2529 const regeneratableTitleThreads = titleRegenerationThreads.filter(
2530 (thread) => thread.titleRegeneration == null,
2531 );
2532 const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({
2533 supportedCount: titleRegenerationThreads.length,
2534 actionableCount: regeneratableTitleThreads.length,
2535 });
2536 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2537 const clicked = await settlePromise(() =>
2538 api.contextMenu.show(
18302539 [
1831 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1832 "This permanently clears conversation history for these threads.",
1833 ].join("\n"),
2540 { id: "settle", label: `Settle (${count})` },
2541 ...(canSnoozeSelection
2542 ? [
2543 {
2544 id: "snooze",
2545 label: `Snooze (${count})`,
2546 children: snoozePresets.map((preset) => ({
2547 id: `snooze:${preset.id}`,
2548 label: `${preset.label} (${preset.whenLabel})`,
2549 })),
2550 },
2551 ]
2552 : []),
2553 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
2554 { id: "mark-unread", label: `Mark unread (${count})` },
2555 { id: "delete", label: `Delete (${count})`, destructive: true },
2556 ],
2557 position,
2558 ),
2559 );
2560 if (clicked._tag === "Failure") return;
2561 if (clicked.value?.startsWith("snooze:")) {
2562 const preset = snoozePresets.find(
2563 (candidate) => `snooze:${candidate.id}` === clicked.value,
18342564 );
1835 if (!confirmed) return;
1836 }
2565 if (preset) {
2566 // Post-snooze navigation must skip threads snoozing in this same
2567 // batch — they are all leaving the card block together.
2568 const coSnoozingKeys = new Set(threadKeys);
2569 clearSelection();
2570 const outcomes = await Promise.all(
2571 selectedThreads.map(async (thread) => {
2572 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
2573 const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys });
2574 return { outcome, threadRef };
2575 }),
2576 );
2577 const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) =>
2578 outcome.status === "success" ? [threadRef] : [],
2579 );
2580 const failures = outcomes.flatMap(({ outcome }) =>
2581 outcome.status === "failure" ? [outcome.error] : [],
2582 );
18372583
1838 const deletedThreadKeys = new Set(threadKeys);
1839 for (const { threadRef } of selectedThreadEntries) {
1840 const result = await deleteThread(threadRef, {
2584 if (snoozedThreadRefs.length > 0) {
2585 const snoozedCount = snoozedThreadRefs.length;
2586 const failedCount = failures.length;
2587 toastManager.add(
2588 stackedThreadToast({
2589 type: failedCount > 0 ? "warning" : "success",
2590 title:
2591 failedCount > 0
2592 ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads`
2593 : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`,
2594 description:
2595 failedCount > 0
2596 ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.`
2597 : undefined,
2598 timeout: 5_000,
2599 actionProps: {
2600 children: "Undo",
2601 onClick: () => {
2602 for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef);
2603 },
2604 },
2605 }),
2606 );
2607 } else if (failures.length > 0) {
2608 const firstError = failures[0];
2609 toastManager.add(
2610 stackedThreadToast({
2611 type: "error",
2612 title: "Failed to snooze threads",
2613 description:
2614 firstError instanceof Error ? firstError.message : "An error occurred.",
2615 }),
2616 );
2617 }
2618 }
2619 return;
2620 }
2621 if (clicked.value === "regenerate-title") {
2622 for (const thread of regeneratableTitleThreads) {
2623 const result = await updateThreadMetadata({
2624 environmentId: thread.environmentId,
2625 input: { threadId: thread.id, regenerateTitle: true },
2626 });
2627 if (result._tag === "Success") continue;
2628 if (!isAtomCommandInterrupted(result)) {
2629 const error = squashAtomCommandFailure(result);
2630 toastManager.add(
2631 stackedThreadToast({
2632 type: "error",
2633 title: "Failed to regenerate thread titles",
2634 description: error instanceof Error ? error.message : "An error occurred.",
2635 }),
2636 );
2637 }
2638 return;
2639 }
2640 clearSelection();
2641 return;
2642 }
2643 if (clicked.value === "settle") {
2644 // Post-settle navigation must skip threads settling in this same
2645 // batch — they are all leaving the card block together. Rows that
2646 // are already explicitly settled are skipped: nothing to do on a
2647 // valid mixed selection. Pinned rows ARE included: the decider
2648 // clears the pin as part of settling, so they park like the rest.
2649 const coSettlingKeys = new Set(threadKeys);
2650 for (const threadKey of threadKeys) {
2651 const thread = threadByKeyRef.current.get(threadKey);
2652 if (!thread || thread.settledOverride === "settled") continue;
2653 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
2654 }
2655 clearSelection();
2656 return;
2657 }
2658 if (clicked.value === "mark-unread") {
2659 for (const threadKey of threadKeys) {
2660 const thread = threadByKeyRef.current.get(threadKey);
2661 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
2662 }
2663 clearSelection();
2664 return;
2665 }
2666 if (clicked.value !== "delete") return;
2667 if (confirmThreadDelete) {
2668 const confirmed = await settlePromise(() =>
2669 api.dialogs.confirm(
2670 [
2671 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
2672 "This permanently clears conversation history for these threads.",
2673 ].join("\n"),
2674 ),
2675 );
2676 if (confirmed._tag === "Failure" || !confirmed.value) return;
2677 }
2678 // Grown as deletions actually land, never seeded with the whole batch:
2679 // orphaned-worktree detection must only discount threads that are
2680 // really gone, or the first delete would treat still-alive batch mates
2681 // as deleted and remove a worktree they still point at.
2682 const deletedThreadKeys = new Set<string>();
2683 for (const threadKey of threadKeys) {
2684 const thread = threadByKeyRef.current.get(threadKey);
2685 if (!thread) continue;
2686 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
18412687 deletedThreadKeys,
18422688 });
18432689 if (result._tag === "Failure") {
......@@ -1853,1784 +2699,1013 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18532699 }
18542700 return;
18552701 }
2702 deletedThreadKeys.add(threadKey);
18562703 }
18572704 removeFromSelection(threadKeys);
18582705 },
18592706 [
1860 appSettingsConfirmThreadArchive,
1861 appSettingsConfirmThreadDelete,
1862 archiveThread,
2707 attemptSettle,
2708 attemptSnooze,
18632709 clearSelection,
2710 confirmThreadDelete,
18642711 deleteThread,
18652712 markThreadUnread,
2713 performSnooze,
18662714 removeFromSelection,
2715 serverConfigs,
2716 attemptUnsnooze,
2717 updateThreadMetadata,
2718 timestampFormat,
18672719 ],
18682720 );
18692721
1870 const createThreadForProjectMember = useCallback(
1871 (member: SidebarProjectGroupMember) => {
1872 if (isMobile) {
1873 setOpenMobile(false);
1874 }
1875 void (async () => {
1876 // No options: branch, worktree, and env mode come from the user's
1877 // configured defaults, never from the currently viewed thread.
1878 const result = await settlePromise(() =>
1879 handleNewThread(scopeProjectRef(member.environmentId, member.id)),
1880 );
1881 if (result._tag === "Failure") {
1882 const error = squashAtomCommandFailure(result);
1883 toastManager.add(
1884 stackedThreadToast({
1885 type: "error",
1886 title: "Could not create thread",
1887 description: error instanceof Error ? error.message : "An error occurred.",
1888 }),
1889 );
1890 }
1891 })();
1892 },
1893 [handleNewThread, isMobile, setOpenMobile],
1894 );
1895
1896 const handleCreateThreadClick = useCallback(
1897 (event: React.MouseEvent<HTMLButtonElement>) => {
1898 event.preventDefault();
1899 event.stopPropagation();
1900
1901 if (project.memberProjects.length === 1) {
1902 createThreadForProjectMember(project.memberProjects[0]!);
1903 return;
1904 }
1905
2722 const handleThreadContextMenu = useCallback(
2723 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
19062724 void (async () => {
19072725 const api = readLocalApi();
1908 if (!api) {
2726 if (!api) return;
2727 const threadKey = scopedThreadKey(threadRef);
2728 const selectionState = useThreadSelectionStore.getState();
2729 if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) {
2730 await handleMultiSelectContextMenu(position);
19092731 return;
19102732 }
1911 const clickedResult = await settlePromise(() =>
2733 const thread = threadByKeyRef.current.get(threadKey);
2734 if (!thread) return;
2735 const threadWorkspacePath =
2736 thread.worktreePath ??
2737 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
2738 null;
2739 // Un-settle works on every settled row: for explicit settles it
2740 // clears the override, for auto-settled rows it pins the thread
2741 // active until real activity clears the pin. Environments without
2742 // the settlement capability get no lifecycle items at all.
2743 const supportsSettlement =
2744 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
2745 true;
2746 const supportsSnooze =
2747 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2748 const supportsPinning =
2749 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
2750 const supportsTitleRegeneration =
2751 serverConfigs.get(thread.environmentId)?.environment.capabilities
2752 .threadTitleRegeneration === true;
2753 const isRegeneratingTitle = thread.titleRegeneration != null;
2754 const isSettled = settledThreadKeysRef.current.has(threadKey);
2755 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2756 const isPinned = thread.pinnedAt != null;
2757 // Presets resolve at menu-open time (same as the popover).
2758 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2759 const clicked = await settlePromise(() =>
19122760 api.contextMenu.show(
1913 project.memberProjects.map((member) => ({
1914 id: member.physicalProjectKey,
1915 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1916 })),
1917 {
1918 x: event.clientX,
1919 y: event.clientY,
1920 },
2761 buildThreadActionMenuItems({
2762 branch: thread.branch ?? null,
2763 isPinned,
2764 isSettled,
2765 isSnoozed,
2766 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
2767 isRegeneratingTitle,
2768 supports: {
2769 settlement: supportsSettlement,
2770 snooze: supportsSnooze,
2771 pinning: supportsPinning,
2772 titleRegeneration: supportsTitleRegeneration,
2773 },
2774 snoozePresets,
2775 }),
2776 position,
19212777 ),
19222778 );
1923 if (clickedResult._tag === "Failure") {
1924 const error = squashAtomCommandFailure(clickedResult);
1925 toastManager.add(
1926 stackedThreadToast({
1927 type: "error",
1928 title: "Could not choose environment",
1929 description: error instanceof Error ? error.message : "An error occurred.",
1930 }),
2779 if (clicked._tag === "Failure") return;
2780 if (clicked.value?.startsWith("snooze:")) {
2781 const preset = snoozePresets.find(
2782 (candidate) => `snooze:${candidate.id}` === clicked.value,
19312783 );
2784 if (preset) attemptSnooze(threadRef, preset);
19322785 return;
19332786 }
1934 const clicked = clickedResult.value;
1935 if (!clicked) {
1936 return;
2787 switch (clicked.value) {
2788 case "new-thread-on-branch": {
2789 // Explicit branch carry-over: reuse the thread's worktree when it
2790 // has one, otherwise its branch on the local checkout.
2791 const result = await settlePromise(() =>
2792 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
2793 branch: thread.branch,
2794 worktreePath: thread.worktreePath,
2795 envMode: thread.worktreePath ? "worktree" : "local",
2796 startFromOrigin: false,
2797 }),
2798 );
2799 if (result._tag === "Failure") {
2800 const error = squashAtomCommandFailure(result);
2801 toastManager.add(
2802 stackedThreadToast({
2803 type: "error",
2804 title: "Could not create thread",
2805 description: error instanceof Error ? error.message : "An error occurred.",
2806 }),
2807 );
2808 }
2809 return;
2810 }
2811 case "settle":
2812 attemptSettle(threadRef);
2813 return;
2814 case "unsettle":
2815 attemptUnsettle(threadRef);
2816 return;
2817 case "unsnooze":
2818 attemptUnsnooze(threadRef);
2819 return;
2820 case "pin":
2821 attemptPin(threadRef);
2822 return;
2823 case "unpin":
2824 attemptUnpin(threadRef);
2825 return;
2826 case "rename":
2827 startThreadRename(threadRef, thread.title);
2828 return;
2829 case "regenerate-title": {
2830 if (isRegeneratingTitle) return;
2831 const result = await updateThreadMetadata({
2832 environmentId: threadRef.environmentId,
2833 input: { threadId: threadRef.threadId, regenerateTitle: true },
2834 });
2835 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2836 const error = squashAtomCommandFailure(result);
2837 toastManager.add(
2838 stackedThreadToast({
2839 type: "error",
2840 title: "Failed to regenerate thread title",
2841 description: error instanceof Error ? error.message : "An error occurred.",
2842 }),
2843 );
2844 }
2845 return;
2846 }
2847 case "mark-unread":
2848 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2849 return;
2850 case "copy-path":
2851 if (!threadWorkspacePath) {
2852 toastManager.add(
2853 stackedThreadToast({
2854 type: "error",
2855 title: "Path unavailable",
2856 description: "This thread does not have a workspace path to copy.",
2857 }),
2858 );
2859 return;
2860 }
2861 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2862 return;
2863 case "copy-branch":
2864 if (thread.branch) {
2865 copyBranchToClipboard(thread.branch, { branch: thread.branch });
2866 }
2867 return;
2868 case "delete": {
2869 if (confirmThreadDelete) {
2870 const confirmed = await settlePromise(() =>
2871 api.dialogs.confirm(
2872 [
2873 `Delete thread "${thread.title}"?`,
2874 "This permanently clears conversation history for this thread.",
2875 ].join("\n"),
2876 ),
2877 );
2878 if (confirmed._tag === "Failure" || !confirmed.value) return;
2879 }
2880 const result = await deleteThread(threadRef);
2881 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2882 const error = squashAtomCommandFailure(result);
2883 toastManager.add(
2884 stackedThreadToast({
2885 type: "error",
2886 title: "Failed to delete thread",
2887 description: error instanceof Error ? error.message : "An error occurred.",
2888 }),
2889 );
2890 return;
2891 }
2892 return;
2893 }
2894 default:
2895 return;
19372896 }
1938 const targetMember = project.memberProjects.find(
1939 (member) => member.physicalProjectKey === clicked,
1940 );
1941 if (!targetMember) {
1942 return;
1943 }
1944 createThreadForProjectMember(targetMember);
1945 })();
1946 },
1947 [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects],
1948 );
1949
1950 const attemptArchiveThread = useCallback(
1951 async (threadRef: ScopedThreadRef) => {
1952 const result = await archiveThread(threadRef);
1953 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1954 const error = squashAtomCommandFailure(result);
1955 toastManager.add(
1956 stackedThreadToast({
1957 type: "error",
1958 title: "Failed to archive thread",
1959 description: error instanceof Error ? error.message : "An error occurred.",
1960 }),
1961 );
1962 }
1963 },
1964 [archiveThread],
1965 );
1966
1967 const cancelRename = useCallback(() => {
1968 setRenamingThreadKey(null);
1969 renamingInputRef.current = null;
1970 }, []);
1971
1972 const startThreadRename = useCallback((threadKey: string, title: string) => {
1973 setRenamingThreadKey(threadKey);
1974 setRenamingTitle(title);
1975 renamingCommittedRef.current = false;
1976 }, []);
1977
1978 const commitRename = useCallback(
1979 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
1980 const threadKey = scopedThreadKey(threadRef);
1981 const finishRename = () => {
1982 setRenamingThreadKey((current) => {
1983 if (current !== threadKey) return current;
1984 renamingInputRef.current = null;
1985 return null;
1986 });
1987 };
1988
1989 const trimmed = newTitle.trim();
1990 if (trimmed.length === 0) {
1991 toastManager.add({
1992 type: "warning",
1993 title: "Thread title cannot be empty",
1994 });
1995 finishRename();
1996 return;
1997 }
1998 if (trimmed === originalTitle) {
1999 finishRename();
2000 return;
2001 }
2002 const result = await updateThreadMetadata({
2003 environmentId: threadRef.environmentId,
2004 input: {
2005 threadId: threadRef.threadId,
2006 title: trimmed,
2007 },
2008 });
2009 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2010 const error = squashAtomCommandFailure(result);
2011 toastManager.add(
2012 stackedThreadToast({
2013 type: "error",
2014 title: "Failed to rename thread",
2015 description: error instanceof Error ? error.message : "An error occurred.",
2016 }),
2017 );
2018 }
2019 finishRename();
2020 },
2021 [updateThreadMetadata],
2022 );
2023
2024 const closeProjectRenameDialog = useCallback(() => {
2025 setProjectRenameTarget(null);
2026 setProjectRenameTitle("");
2027 }, []);
2028
2029 const submitProjectRename = useCallback(async () => {
2030 if (!projectRenameTarget) {
2031 return;
2032 }
2033
2034 const trimmed = projectRenameTitle.trim();
2035 if (trimmed.length === 0) {
2036 toastManager.add({
2037 type: "warning",
2038 title: "Project title cannot be empty",
2039 });
2040 return;
2041 }
2042
2043 if (trimmed === projectRenameTarget.title) {
2044 closeProjectRenameDialog();
2045 return;
2046 }
2047
2048 const result = await updateProject({
2049 environmentId: projectRenameTarget.environmentId,
2050 input: {
2051 projectId: projectRenameTarget.id,
2052 title: trimmed,
2053 },
2054 });
2055 if (result._tag === "Success") {
2056 closeProjectRenameDialog();
2057 } else if (!isAtomCommandInterrupted(result)) {
2058 const error = squashAtomCommandFailure(result);
2059 toastManager.add(
2060 stackedThreadToast({
2061 type: "error",
2062 title: "Failed to rename project",
2063 description: error instanceof Error ? error.message : "An error occurred.",
2064 }),
2065 );
2066 }
2067 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]);
2068
2069 const closeProjectGroupingDialog = useCallback(() => {
2070 setProjectGroupingTarget(null);
2071 setProjectGroupingSelection("inherit");
2072 }, []);
2073
2074 const saveProjectGroupingPreference = useCallback(() => {
2075 if (!projectGroupingTarget) {
2076 return;
2077 }
2078
2079 const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget);
2080 const nextOverrides = {
2081 ...projectGroupingSettings.sidebarProjectGroupingOverrides,
2082 };
2083 if (projectGroupingSelection === "inherit") {
2084 delete nextOverrides[overrideKey];
2085 } else {
2086 nextOverrides[overrideKey] = projectGroupingSelection;
2087 }
2088 updateSettings({
2089 sidebarProjectGroupingOverrides: nextOverrides,
2090 });
2091 closeProjectGroupingDialog();
2092 }, [
2093 closeProjectGroupingDialog,
2094 projectGroupingSelection,
2095 projectGroupingSettings.sidebarProjectGroupingOverrides,
2096 projectGroupingTarget,
2097 updateSettings,
2098 ]);
2099
2100 const handleThreadContextMenu = useCallback(
2101 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
2102 const api = readLocalApi();
2103 if (!api) return;
2104 const threadKey = scopedThreadKey(threadRef);
2105 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
2106 if (!thread) return;
2107 const threadProject = memberProjectByScopedKey.get(
2108 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
2109 );
2110 const threadWorkspacePath =
2111 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2112 const clicked = await api.contextMenu.show(
2113 [
2114 ...(thread.branch
2115 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
2116 : []),
2117 { id: "rename", label: "Rename thread" },
2118 { id: "mark-unread", label: "Mark unread" },
2119 { id: "copy-path", label: "Copy Path" },
2120 { id: "copy-thread-id", label: "Copy Thread ID" },
2121 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
2122 ],
2123 position,
2124 );
2125
2126 if (clicked === "new-thread-on-branch") {
2127 // Explicit branch carry-over: reuse the thread's worktree when it
2128 // has one, otherwise its branch on the local checkout.
2129 const result = await settlePromise(() =>
2130 handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), {
2131 branch: thread.branch,
2132 worktreePath: thread.worktreePath,
2133 envMode: thread.worktreePath ? "worktree" : "local",
2134 startFromOrigin: false,
2135 }),
2136 );
2137 if (result._tag === "Failure") {
2138 const error = squashAtomCommandFailure(result);
2139 toastManager.add(
2140 stackedThreadToast({
2141 type: "error",
2142 title: "Could not create thread",
2143 description: error instanceof Error ? error.message : "An error occurred.",
2144 }),
2145 );
2146 }
2147 return;
2148 }
2149
2150 if (clicked === "rename") {
2151 startThreadRename(threadKey, thread.title);
2152 return;
2153 }
2154
2155 if (clicked === "mark-unread") {
2156 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2157 return;
2158 }
2159 if (clicked === "copy-path") {
2160 if (!threadWorkspacePath) {
2161 toastManager.add(
2162 stackedThreadToast({
2163 type: "error",
2164 title: "Path unavailable",
2165 description: "This thread does not have a workspace path to copy.",
2166 }),
2167 );
2168 return;
2169 }
2170 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2171 return;
2172 }
2173 if (clicked === "copy-thread-id") {
2174 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
2175 return;
2176 }
2177 if (clicked !== "delete") return;
2178 if (appSettingsConfirmThreadDelete) {
2179 const confirmed = await api.dialogs.confirm(
2180 [
2181 `Delete thread "${thread.title}"?`,
2182 "This permanently clears conversation history for this thread.",
2183 ].join("\n"),
2184 );
2185 if (!confirmed) {
2186 return;
2187 }
2188 }
2189 const result = await deleteThread(threadRef);
2190 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2191 const error = squashAtomCommandFailure(result);
2192 toastManager.add(
2193 stackedThreadToast({
2194 type: "error",
2195 title: "Failed to delete thread",
2196 description: error instanceof Error ? error.message : "An error occurred.",
2197 }),
2198 );
2199 }
2200 },
2201 [
2202 appSettingsConfirmThreadDelete,
2203 copyPathToClipboard,
2204 copyThreadIdToClipboard,
2205 deleteThread,
2206 handleNewThread,
2207 markThreadUnread,
2208 memberProjectByScopedKey,
2209 project.workspaceRoot,
2210 startThreadRename,
2211 ],
2212 );
2213
2214 return (
2215 <>
2216 <div className="group/project-header relative">
2217 <SidebarMenuButton
2218 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
2219 className={`pr-8 group-hover/project-header:bg-sidebar-row-hover group-hover/project-header:text-sidebar-foreground max-sm:pr-14 ${
2220 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : ""
2221 }`}
2222 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
2223 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
2224 onPointerDownCapture={handleProjectButtonPointerDownCapture}
2225 onClick={handleProjectButtonClick}
2226 onKeyDown={handleProjectButtonKeyDown}
2227 onContextMenu={handleProjectButtonContextMenu}
2228 >
2229 {!projectExpanded && projectStatus ? (
2230 <Tooltip>
2231 <TooltipTrigger
2232 render={
2233 <span
2234 aria-label={projectStatus.label}
2235 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2236 />
2237 }
2238 >
2239 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2240 <span
2241 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2242 projectStatus.pulse ? "animate-status-pulse" : ""
2243 }`}
2244 />
2245 </span>
2246 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2247 </TooltipTrigger>
2248 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
2249 </Tooltip>
2250 ) : (
2251 <ChevronRightIcon
2252 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
2253 projectExpanded ? "rotate-90" : ""
2254 }`}
2255 />
2256 )}
2257 <ProjectFavicon environmentId={project.environmentId} cwd={project.workspaceRoot} />
2258 <span className="flex min-w-0 flex-1 items-center gap-2">
2259 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2260 {project.displayName}
2261 </span>
2262 {project.groupedProjectCount > 1 ? (
2263 <span className="shrink-0 text-secondary-label text-[10px]">
2264 {project.groupedProjectCount} projects
2265 </span>
2266 ) : null}
2267 </span>
2268 </SidebarMenuButton>
2269 {/* Environment badge – visible by default, crossfades with the
2270 "new thread" button on hover using the same pointer-events +
2271 opacity pattern as the thread row archive/timestamp swap. */}
2272 {project.environmentPresence === "remote-only" && (
2273 <Tooltip>
2274 <TooltipTrigger
2275 render={
2276 <span
2277 aria-label={
2278 project.allRemoteMembersAreDesktopLocal
2279 ? "Local sandbox project"
2280 : "Remote project"
2281 }
2282 className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100"
2283 />
2284 }
2285 >
2286 {project.allRemoteMembersAreDesktopLocal ? (
2287 <ContainerIcon className="size-3" />
2288 ) : (
2289 <CloudIcon className="size-3" />
2290 )}
2291 </TooltipTrigger>
2292 <TooltipPopup side="top">
2293 {project.allRemoteMembersAreDesktopLocal
2294 ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}`
2295 : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`}
2296 </TooltipPopup>
2297 </Tooltip>
2298 )}
2299 <Tooltip>
2300 <TooltipTrigger
2301 render={
2302 <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
2303 <button
2304 type="button"
2305 aria-label={`Create new thread in ${project.displayName}`}
2306 data-testid="new-thread-button"
2307 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
2308 onClick={handleCreateThreadClick}
2309 >
2310 <SquarePenIcon className="size-3.5" />
2311 </button>
2312 </div>
2313 }
2314 />
2315 <TooltipPopup side="top">
2316 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
2317 </TooltipPopup>
2318 </Tooltip>
2319 </div>
2320
2321 <SidebarProjectThreadList
2322 projectKey={project.projectKey}
2323 projectExpanded={projectExpanded}
2324 hasOverflowingThreads={hasOverflowingThreads}
2325 hiddenThreadStatus={hiddenThreadStatus}
2326 orderedProjectThreadKeys={orderedProjectThreadKeys}
2327 renderedThreads={renderedThreads}
2328 showEmptyThreadState={showEmptyThreadState}
2329 shouldShowThreadPanel={shouldShowThreadPanel}
2330 isThreadListExpanded={isThreadListExpanded}
2331 projectCwd={project.workspaceRoot}
2332 activeRouteThreadKey={activeRouteThreadKey}
2333 threadJumpLabelByKey={threadJumpLabelByKey}
2334 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
2335 renamingThreadKey={renamingThreadKey}
2336 renamingTitle={renamingTitle}
2337 setRenamingTitle={setRenamingTitle}
2338 startThreadRename={startThreadRename}
2339 renamingInputRef={renamingInputRef}
2340 renamingCommittedRef={renamingCommittedRef}
2341 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
2342 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
2343 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
2344 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2345 handleThreadClick={handleThreadClick}
2346 navigateToThread={navigateToThread}
2347 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
2348 handleThreadContextMenu={handleThreadContextMenu}
2349 clearSelection={clearSelection}
2350 commitRename={commitRename}
2351 cancelRename={cancelRename}
2352 attemptArchiveThread={attemptArchiveThread}
2353 openPrLink={openPrLink}
2354 expandThreadListForProject={expandThreadListForProject}
2355 collapseThreadListForProject={collapseThreadListForProject}
2356 />
2357
2358 <Dialog
2359 open={projectRenameTarget !== null}
2360 onOpenChange={(open) => {
2361 if (!open) {
2362 closeProjectRenameDialog();
2363 }
2364 }}
2365 >
2366 <DialogPopup className="max-w-lg">
2367 <DialogHeader>
2368 <DialogTitle>Rename project</DialogTitle>
2369 <DialogDescription>
2370 {projectRenameTarget
2371 ? `Update the title for ${projectRenameTarget.workspaceRoot}.`
2372 : "Update the project title."}
2373 </DialogDescription>
2374 </DialogHeader>
2375 <DialogPanel className="space-y-4">
2376 <div className="grid gap-1.5">
2377 <span className="text-xs font-medium text-foreground">Project title</span>
2378 <Input
2379 aria-label="Project title"
2380 value={projectRenameTitle}
2381 onChange={(event) => setProjectRenameTitle(event.target.value)}
2382 onKeyDown={(event) => {
2383 if (event.key === "Enter") {
2384 event.preventDefault();
2385 void submitProjectRename();
2386 }
2387 }}
2388 />
2389 </div>
2390 {projectRenameTarget?.environmentLabel ? (
2391 <p className="text-xs text-muted-foreground">
2392 Environment: {projectRenameTarget.environmentLabel}
2393 </p>
2394 ) : null}
2395 </DialogPanel>
2396 <DialogFooter>
2397 <Button variant="outline" onClick={closeProjectRenameDialog}>
2398 Cancel
2399 </Button>
2400 <Button onClick={() => void submitProjectRename()}>Save</Button>
2401 </DialogFooter>
2402 </DialogPopup>
2403 </Dialog>
2404
2405 <Dialog
2406 open={projectGroupingTarget !== null}
2407 onOpenChange={(open) => {
2408 if (!open) {
2409 closeProjectGroupingDialog();
2410 }
2411 }}
2412 >
2413 <DialogPopup className="max-w-lg">
2414 <DialogHeader>
2415 <DialogTitle>Project grouping</DialogTitle>
2416 <DialogDescription>
2417 {projectGroupingTarget
2418 ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.`
2419 : "Choose how this project should be grouped in the sidebar."}
2420 </DialogDescription>
2421 </DialogHeader>
2422 <DialogPanel className="space-y-4">
2423 <div className="grid gap-1.5">
2424 <span className="text-xs font-medium text-foreground">Grouping rule</span>
2425 <Select
2426 value={projectGroupingSelection}
2427 onValueChange={(value) => {
2428 if (
2429 value === "inherit" ||
2430 value === "repository" ||
2431 value === "repository_path" ||
2432 value === "separate"
2433 ) {
2434 setProjectGroupingSelection(value);
2435 }
2436 }}
2437 >
2438 <SelectTrigger className="w-full" aria-label="Project grouping rule">
2439 <SelectValue>
2440 {projectGroupingSelection === "inherit"
2441 ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
2442 : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]}
2443 </SelectValue>
2444 </SelectTrigger>
2445 <SelectPopup align="end" alignItemWithTrigger={false}>
2446 <SelectItem hideIndicator value="inherit">
2447 Use global default
2448 </SelectItem>
2449 <SelectItem hideIndicator value="repository">
2450 {PROJECT_GROUPING_MODE_LABELS.repository}
2451 </SelectItem>
2452 <SelectItem hideIndicator value="repository_path">
2453 {PROJECT_GROUPING_MODE_LABELS.repository_path}
2454 </SelectItem>
2455 <SelectItem hideIndicator value="separate">
2456 {PROJECT_GROUPING_MODE_LABELS.separate}
2457 </SelectItem>
2458 </SelectPopup>
2459 </Select>
2460 </div>
2461 <p className="text-xs text-muted-foreground">
2462 {projectGroupingSelection === "inherit"
2463 ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode)
2464 : projectGroupingModeDescription(projectGroupingSelection)}
2465 </p>
2466 </DialogPanel>
2467 <DialogFooter>
2468 <Button variant="outline" onClick={closeProjectGroupingDialog}>
2469 Cancel
2470 </Button>
2471 <Button onClick={saveProjectGroupingPreference}>Save</Button>
2472 </DialogFooter>
2473 </DialogPopup>
2474 </Dialog>
2475 </>
2476 );
2477});
2478
2479const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
2480 return (
2481 <SidebarMenuItem className="rounded-md">
2482 <SidebarProjectItem {...props} />
2483 </SidebarMenuItem>
2484 );
2485});
2486
2487function LocalSecondaryStatus() {
2488 const { environments } = useEnvironments();
2489 // The desktop reports which local secondary backends (e.g. the WSL backend)
2490 // exist; the hook polls because the bridge has no change event. A backend that
2491 // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we
2492 // surface "Connecting" straight from the bootstrap list and clear it once the
2493 // matching environment reports a connected phase.
2494 const secondaries = useDesktopLocalBootstraps();
2495
2496 // Connected desktop-local environments keyed by their backend URL so we can
2497 // match a bootstrap (which only knows the URL) to its connection phase.
2498 const localEnvByUrl = useMemo(() => {
2499 const map = new Map<string, { phase: string; error: string | null }>();
2500 for (const environment of environments) {
2501 if (
2502 isDesktopLocalConnectionTarget(environment.entry.target) &&
2503 environment.displayUrl !== null
2504 ) {
2505 map.set(environment.displayUrl, {
2506 phase: environment.connection.phase,
2507 error: environment.connection.error,
2508 });
2509 }
2510 }
2511 return map;
2512 }, [environments]);
2513
2514 const connecting: string[] = [];
2515 const failed: Array<{ label: string; error: string | null }> = [];
2516 for (const bootstrap of secondaries) {
2517 const env =
2518 bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined;
2519 if (env?.phase === "connected") {
2520 continue;
2521 }
2522 if (env?.phase === "error") {
2523 failed.push({ label: bootstrap.label, error: env.error });
2524 continue;
2525 }
2526 connecting.push(bootstrap.label);
2527 }
2528
2529 if (connecting.length === 0 && failed.length === 0) {
2530 return null;
2531 }
2532
2533 return (
2534 <SidebarGroup className="px-2 pt-2 pb-0">
2535 {connecting.length > 0 ? (
2536 <Alert
2537 variant="default"
2538 className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground"
2539 >
2540 <LoaderIcon className="animate-spin" />
2541 <AlertTitle className="text-xs font-medium text-foreground">
2542 Connecting {connecting.join(", ")}
2543 </AlertTitle>
2544 </Alert>
2545 ) : null}
2546 {failed.length > 0 ? (
2547 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2548 <TriangleAlertIcon />
2549 <AlertTitle>Couldn't connect {failed.map((entry) => entry.label).join(", ")}</AlertTitle>
2550 <AlertDescription>
2551 {failed
2552 .map((entry) => entry.error)
2553 .filter(Boolean)
2554 .join("; ") || "The backend didn't respond."}
2555 </AlertDescription>
2556 </Alert>
2557 ) : null}
2558 </SidebarGroup>
2559 );
2560}
2561
2562type SortableProjectHandleProps = Pick<
2563 ReturnType<typeof useSortable>,
2564 "attributes" | "listeners" | "setActivatorNodeRef"
2565>;
2566
2567function ProjectSortMenu({
2568 projectSortOrder,
2569 threadSortOrder,
2570 threadPreviewCount,
2571 onProjectSortOrderChange,
2572 onThreadSortOrderChange,
2573 onThreadPreviewCountChange,
2574}: {
2575 projectSortOrder: SidebarProjectSortOrder;
2576 threadSortOrder: SidebarThreadSortOrder;
2577 threadPreviewCount: SidebarThreadPreviewCount;
2578 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
2579 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2580 onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void;
2581}) {
2582 const handleThreadPreviewCountChange = useCallback(
2583 (nextValue: number | null) => {
2584 if (nextValue === null) {
2585 return;
2586 }
2587
2588 const clampedValue = clampSidebarThreadPreviewCount(nextValue);
2589 if (clampedValue !== threadPreviewCount) {
2590 onThreadPreviewCountChange(clampedValue);
2591 }
2592 },
2593 [onThreadPreviewCountChange, threadPreviewCount],
2594 );
2595
2596 return (
2597 <Menu>
2598 <Tooltip>
2599 <TooltipTrigger
2600 render={
2601 <MenuTrigger className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground" />
2602 }
2603 >
2604 <ArrowUpDownIcon className="size-3.5" />
2605 </TooltipTrigger>
2606 <TooltipPopup side="right">Sidebar options</TooltipPopup>
2607 </Tooltip>
2608 <MenuPopup align="end" side="bottom" className="min-w-52">
2609 <MenuGroup>
2610 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
2611 Sort projects
2612 </div>
2613 <MenuRadioGroup
2614 value={projectSortOrder}
2615 onValueChange={(value) => {
2616 onProjectSortOrderChange(value as SidebarProjectSortOrder);
2617 }}
2618 >
2619 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
2620 ([value, label]) => (
2621 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
2622 {label}
2623 </MenuRadioItem>
2624 ),
2625 )}
2626 </MenuRadioGroup>
2627 </MenuGroup>
2628 <MenuGroup>
2629 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
2630 Sort threads
2631 </div>
2632 <MenuRadioGroup
2633 value={threadSortOrder}
2634 onValueChange={(value) => {
2635 onThreadSortOrderChange(value as SidebarThreadSortOrder);
2636 }}
2637 >
2638 {(
2639 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
2640 ).map(([value, label]) => (
2641 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
2642 {label}
2643 </MenuRadioItem>
2644 ))}
2645 </MenuRadioGroup>
2646 </MenuGroup>
2647 <MenuGroup>
2648 <div className="px-2 pt-2 pb-1 text-muted-foreground sm:text-xs font-medium">
2649 Visible threads
2650 </div>
2651 <div className="px-2 py-1">
2652 <NumberField
2653 aria-label="Visible thread count"
2654 className="w-28 gap-0"
2655 max={MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
2656 min={MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
2657 onValueChange={handleThreadPreviewCountChange}
2658 size="sm"
2659 step={1}
2660 value={threadPreviewCount}
2661 >
2662 <NumberFieldGroup className="h-7 rounded-md sm:h-6.5">
2663 <NumberFieldDecrement
2664 aria-label="Decrease visible thread count"
2665 className="px-2 sm:px-2 [&_svg]:size-3.5"
2666 />
2667 <NumberFieldInput
2668 aria-label="Visible thread count"
2669 className="h-7 w-9 grow-0 px-0 text-xs leading-7 sm:h-6.5 sm:leading-6.5"
2670 inputMode="numeric"
2671 onKeyDownCapture={(event) => {
2672 event.stopPropagation();
2673 }}
2674 />
2675 <NumberFieldIncrement
2676 aria-label="Increase visible thread count"
2677 className="px-2 sm:px-2 [&_svg]:size-3.5"
2678 />
2679 </NumberFieldGroup>
2680 </NumberField>
2681 </div>
2682 </MenuGroup>
2683 </MenuPopup>
2684 </Menu>
2685 );
2686}
2687
2688function SortableProjectItem({
2689 projectId,
2690 disabled = false,
2691 children,
2692}: {
2693 projectId: string;
2694 disabled?: boolean;
2695 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
2696}) {
2697 const {
2698 attributes,
2699 listeners,
2700 setActivatorNodeRef,
2701 setNodeRef,
2702 transform,
2703 transition,
2704 isDragging,
2705 isOver,
2706 } = useSortable({ id: projectId, disabled });
2707 return (
2708 <li
2709 ref={setNodeRef}
2710 style={{
2711 transform: CSS.Translate.toString(transform),
2712 transition,
2713 }}
2714 className={`group/menu-item relative rounded-md ${
2715 isDragging ? "z-20 opacity-80" : ""
2716 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
2717 data-sidebar="menu-item"
2718 data-slot="sidebar-menu-item"
2719 >
2720 {children({ attributes, listeners, setActivatorNodeRef })}
2721 </li>
2722 );
2723}
2724
2725interface SidebarProjectsContentProps {
2726 showArm64IntelBuildWarning: boolean;
2727 arm64IntelBuildWarningDescription: string | null;
2728 desktopUpdateButtonAction: "download" | "install" | "none";
2729 desktopUpdateButtonDisabled: boolean;
2730 handleDesktopUpdateButtonClick: () => void;
2731 projectSortOrder: SidebarProjectSortOrder;
2732 threadSortOrder: SidebarThreadSortOrder;
2733 threadPreviewCount: SidebarThreadPreviewCount;
2734 updateSettings: ReturnType<typeof useUpdateClientSettings>;
2735 openAddProject: () => void;
2736 isManualProjectSorting: boolean;
2737 projectDnDSensors: ReturnType<typeof useSensors>;
2738 projectCollisionDetection: CollisionDetection;
2739 handleProjectDragStart: (event: DragStartEvent) => void;
2740 handleProjectDragEnd: (event: DragEndEvent) => void;
2741 handleProjectDragCancel: (event: DragCancelEvent) => void;
2742 handleNewThread: ReturnType<typeof useNewThreadHandler>;
2743 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
2744 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
2745 sortedProjects: readonly SidebarProjectSnapshot[];
2746 expandedThreadListsByProject: ReadonlySet<string>;
2747 activeRouteProjectKey: string | null;
2748 routeThreadKey: string | null;
2749 newThreadShortcutLabel: string | null;
2750 commandPaletteShortcutLabel: string | null;
2751 threadJumpLabelByKey: ReadonlyMap<string, string>;
2752 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
2753 expandThreadListForProject: (projectKey: string) => void;
2754 collapseThreadListForProject: (projectKey: string) => void;
2755 dragInProgressRef: React.RefObject<boolean>;
2756 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
2757 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
2758 attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
2759 projectsLength: number;
2760}
2761
2762const SidebarProjectsContent = memo(function SidebarProjectsContent(
2763 props: SidebarProjectsContentProps,
2764) {
2765 const {
2766 showArm64IntelBuildWarning,
2767 arm64IntelBuildWarningDescription,
2768 desktopUpdateButtonAction,
2769 desktopUpdateButtonDisabled,
2770 handleDesktopUpdateButtonClick,
2771 projectSortOrder,
2772 threadSortOrder,
2773 threadPreviewCount,
2774 updateSettings,
2775 openAddProject,
2776 isManualProjectSorting,
2777 projectDnDSensors,
2778 projectCollisionDetection,
2779 handleProjectDragStart,
2780 handleProjectDragEnd,
2781 handleProjectDragCancel,
2782 handleNewThread,
2783 archiveThread,
2784 deleteThread,
2785 sortedProjects,
2786 expandedThreadListsByProject,
2787 activeRouteProjectKey,
2788 routeThreadKey,
2789 newThreadShortcutLabel,
2790 commandPaletteShortcutLabel,
2791 threadJumpLabelByKey,
2792 attachThreadListAutoAnimateRef,
2793 expandThreadListForProject,
2794 collapseThreadListForProject,
2795 dragInProgressRef,
2796 suppressProjectClickAfterDragRef,
2797 suppressProjectClickForContextMenuRef,
2798 attachProjectListAutoAnimateRef,
2799 projectsLength,
2800 } = props;
2801
2802 const handleProjectSortOrderChange = useCallback(
2803 (sortOrder: SidebarProjectSortOrder) => {
2804 updateSettings({ sidebarProjectSortOrder: sortOrder });
2805 },
2806 [updateSettings],
2807 );
2808 const handleThreadSortOrderChange = useCallback(
2809 (sortOrder: SidebarThreadSortOrder) => {
2810 updateSettings({ sidebarThreadSortOrder: sortOrder });
2811 },
2812 [updateSettings],
2813 );
2814 const handleThreadPreviewCountChange = useCallback(
2815 (count: SidebarThreadPreviewCount) => {
2816 updateSettings({ sidebarThreadPreviewCount: count });
2817 },
2818 [updateSettings],
2819 );
2820
2821 return (
2822 <SidebarContent
2823 className="gap-0"
2824 fixedHeader={
2825 // Lifted above the stage backdrop, whose fade bleeds below the
2826 // header and would otherwise paint across the search row's outline.
2827 <SidebarGroup className="relative z-[1] px-2 pt-2 pb-1">
2828 <SidebarMenu>
2829 <SidebarMenuItem>
2830 <CommandDialogTrigger
2831 render={
2832 <SidebarMenuButton
2833 className="focus-visible:ring-0"
2834 data-testid="command-palette-trigger"
2835 />
2836 }
2837 >
2838 <SearchIcon />
2839 <span className="flex-1 truncate">Search</span>
2840 {commandPaletteShortcutLabel ? (
2841 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
2842 {commandPaletteShortcutLabel}
2843 </Kbd>
2844 ) : null}
2845 </CommandDialogTrigger>
2846 </SidebarMenuItem>
2847 </SidebarMenu>
2848 </SidebarGroup>
2849 }
2850 >
2851 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2852 <SidebarGroup className="px-2 pt-2 pb-0">
2853 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2854 <TriangleAlertIcon />
2855 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2856 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2857 {desktopUpdateButtonAction !== "none" ? (
2858 <AlertAction>
2859 <Button
2860 size="xs"
2861 variant="outline"
2862 disabled={desktopUpdateButtonDisabled}
2863 onClick={handleDesktopUpdateButtonClick}
2864 >
2865 {desktopUpdateButtonAction === "download"
2866 ? "Download ARM build"
2867 : "Install ARM build"}
2868 </Button>
2869 </AlertAction>
2870 ) : null}
2871 </Alert>
2872 </SidebarGroup>
2873 ) : null}
2874 <LocalSecondaryStatus />
2875 <SidebarGroup className="px-2 py-2">
2876 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2877 <span className="text-xs font-medium text-sidebar-muted-foreground/80">Projects</span>
2878 <div className="flex items-center gap-1">
2879 <ProjectSortMenu
2880 projectSortOrder={projectSortOrder}
2881 threadSortOrder={threadSortOrder}
2882 threadPreviewCount={threadPreviewCount}
2883 onProjectSortOrderChange={handleProjectSortOrderChange}
2884 onThreadSortOrderChange={handleThreadSortOrderChange}
2885 onThreadPreviewCountChange={handleThreadPreviewCountChange}
2886 />
2887 <Tooltip>
2888 <TooltipTrigger
2889 render={
2890 <button
2891 type="button"
2892 aria-label="Add project"
2893 data-testid="sidebar-add-project-trigger"
2894 className="inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted transition-colors hover:bg-accent hover:text-foreground"
2895 onClick={openAddProject}
2896 />
2897 }
2898 >
2899 <FolderPlusIcon className="size-3.5" />
2900 </TooltipTrigger>
2901 <TooltipPopup side="right">Add project</TooltipPopup>
2902 </Tooltip>
2903 </div>
2904 </div>
2905
2906 {isManualProjectSorting ? (
2907 <DndContext
2908 sensors={projectDnDSensors}
2909 collisionDetection={projectCollisionDetection}
2910 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2911 onDragStart={handleProjectDragStart}
2912 onDragEnd={handleProjectDragEnd}
2913 onDragCancel={handleProjectDragCancel}
2914 >
2915 <SidebarMenu>
2916 <SortableContext
2917 items={sortedProjects.map((project) => project.projectKey)}
2918 strategy={verticalListSortingStrategy}
2919 >
2920 {sortedProjects.map((project) => (
2921 <SortableProjectItem key={project.projectKey} projectId={project.projectKey}>
2922 {(dragHandleProps) => (
2923 <SidebarProjectItem
2924 project={project}
2925 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2926 activeRouteThreadKey={
2927 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2928 }
2929 newThreadShortcutLabel={newThreadShortcutLabel}
2930 handleNewThread={handleNewThread}
2931 archiveThread={archiveThread}
2932 deleteThread={deleteThread}
2933 threadJumpLabelByKey={threadJumpLabelByKey}
2934 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2935 expandThreadListForProject={expandThreadListForProject}
2936 collapseThreadListForProject={collapseThreadListForProject}
2937 dragInProgressRef={dragInProgressRef}
2938 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2939 suppressProjectClickForContextMenuRef={
2940 suppressProjectClickForContextMenuRef
2941 }
2942 isManualProjectSorting={isManualProjectSorting}
2943 dragHandleProps={dragHandleProps}
2944 />
2945 )}
2946 </SortableProjectItem>
2947 ))}
2948 </SortableContext>
2949 </SidebarMenu>
2950 </DndContext>
2951 ) : (
2952 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2953 {sortedProjects.map((project) => (
2954 <SidebarProjectListRow
2955 key={project.projectKey}
2956 project={project}
2957 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2958 activeRouteThreadKey={
2959 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2960 }
2961 newThreadShortcutLabel={newThreadShortcutLabel}
2962 handleNewThread={handleNewThread}
2963 archiveThread={archiveThread}
2964 deleteThread={deleteThread}
2965 threadJumpLabelByKey={threadJumpLabelByKey}
2966 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2967 expandThreadListForProject={expandThreadListForProject}
2968 collapseThreadListForProject={collapseThreadListForProject}
2969 dragInProgressRef={dragInProgressRef}
2970 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2971 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2972 isManualProjectSorting={isManualProjectSorting}
2973 dragHandleProps={null}
2974 />
2975 ))}
2976 </SidebarMenu>
2977 )}
2978
2979 {projectsLength === 0 && (
2980 <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div>
2981 )}
2982 </SidebarGroup>
2983 </SidebarContent>
2984 );
2985});
2986
2987export default function Sidebar() {
2988 const projects = useProjects();
2989 const sidebarThreads = useThreadShells();
2990 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
2991 const projectOrder = useUiStateStore((store) => store.projectOrder);
2992 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
2993 const navigate = useNavigate();
2994 const pathname = useLocation({ select: (loc) => loc.pathname });
2995 const isOnSettings = pathname.startsWith("/settings");
2996 const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
2997 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
2998 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
2999 const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount);
3000 const updateSettings = useUpdateClientSettings();
3001 const handleNewThread = useNewThreadHandler();
3002 const { archiveThread, deleteThread } = useThreadActions();
3003 const { isMobile, setOpenMobile } = useSidebar();
3004 const routeTarget = useParams({
3005 strict: false,
3006 select: (params) => resolveThreadRouteTarget(params),
3007 });
3008 const routeDraftThread = useComposerDraftStore((store) =>
3009 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
3010 );
3011 const routeThreadRef = useMemo(
3012 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
3013 [routeDraftThread, routeTarget],
3014 );
3015 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
3016 const routeTerminalOpen = useTerminalUiStateStore((state) =>
3017 routeThreadRef
3018 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
3019 : false,
3020 );
3021 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3022 const openAddProjectCommandPalette = useCallback(
3023 () => openCommandPalette({ open: "add-project" }),
3024 [],
3025 );
3026 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
3027 ReadonlySet<string>
3028 >(() => new Set());
3029 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
3030 const dragInProgressRef = useRef(false);
3031 const suppressProjectClickAfterDragRef = useRef(false);
3032 const suppressProjectClickForContextMenuRef = useRef(false);
3033 const desktopUpdateState = useDesktopUpdateState();
3034 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
3035 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
3036 const platform = navigator.platform;
3037 const shortcutModifiers = useShortcutModifierState();
3038 const { environments } = useEnvironments();
3039 const primaryEnvironmentId = usePrimaryEnvironmentId();
3040 const environmentLabelById = useMemo(
3041 () =>
3042 new Map(
3043 environments.map((environment) => [environment.environmentId, environment.label] as const),
3044 ),
3045 [environments],
3046 );
3047 const desktopLocalEnvironmentIds = useMemo(
3048 () =>
3049 new Set(
3050 environments
3051 .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target))
3052 .map((environment) => environment.environmentId),
3053 ),
3054 [environments],
3055 );
3056 const orderedProjects = useMemo(() => {
3057 return orderItemsByPreferredIds({
3058 items: projects,
3059 preferredIds: projectOrder,
3060 getId: getProjectOrderKey,
3061 getPreferenceIds: (project) => [
3062 getProjectOrderKey(project),
3063 legacyProjectCwdPreferenceKey(project.workspaceRoot),
3064 ],
3065 });
3066 }, [projectOrder, projects]);
3067
3068 // Build a mapping from physical project key → logical project key for
3069 // cross-environment grouping. Projects that share a repositoryIdentity
3070 // canonicalKey are treated as one logical project in the sidebar.
3071 const physicalToLogicalKey = useMemo(() => {
3072 return buildPhysicalToLogicalProjectKeyMap({
3073 projects: orderedProjects,
3074 settings: projectGroupingSettings,
3075 primaryEnvironmentId,
3076 });
3077 }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
3078 const projectPhysicalKeyByScopedRef = useMemo(
3079 () =>
3080 new Map(
3081 orderedProjects.map((project) => [
3082 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
3083 derivePhysicalProjectKey(project),
3084 ]),
3085 ),
3086 [orderedProjects],
3087 );
3088
3089 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
3090 return buildSidebarProjectSnapshots({
3091 projects: orderedProjects,
3092 settings: projectGroupingSettings,
3093 primaryEnvironmentId,
3094 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
3095 isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId),
3096 });
3097 }, [
3098 environmentLabelById,
3099 desktopLocalEnvironmentIds,
3100 orderedProjects,
3101 projectGroupingSettings,
3102 primaryEnvironmentId,
3103 ]);
3104
3105 const sidebarProjectByKey = useMemo(
3106 () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
3107 [sidebarProjects],
3108 );
3109 const sidebarThreadByKey = useMemo(
3110 () =>
3111 new Map(
3112 sidebarThreads.map(
3113 (thread) =>
3114 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
3115 ),
3116 ),
3117 [sidebarThreads],
3118 );
3119 // Resolve the active route's project key to a logical key so it matches the
3120 // sidebar's grouped project entries.
3121 const activeRouteProjectKey = useMemo(() => {
3122 if (!routeThreadKey) {
3123 return null;
3124 }
3125 const activeThread = sidebarThreadByKey.get(routeThreadKey);
3126 if (!activeThread) return null;
3127 const physicalKey =
3128 projectPhysicalKeyByScopedRef.get(
3129 scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
3130 ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId));
3131 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3132 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3133
3134 // Group threads by logical project key so all threads from grouped projects
3135 // are displayed together.
3136 const threadsByProjectKey = useMemo(() => {
3137 const next = new Map<string, SidebarThreadSummary[]>();
3138 for (const thread of sidebarThreads) {
3139 const physicalKey =
3140 projectPhysicalKeyByScopedRef.get(
3141 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3142 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3143 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3144 const existing = next.get(logicalKey);
3145 if (existing) {
3146 existing.push(thread);
3147 } else {
3148 next.set(logicalKey, [thread]);
3149 }
3150 }
3151 return next;
3152 }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3153 const getCurrentSidebarShortcutContext = useCallback(
3154 () => ({
3155 terminalFocus: isTerminalFocused(),
3156 terminalOpen: routeTerminalOpen,
3157 modelPickerOpen: isModelPickerOpen(),
3158 }),
3159 [routeTerminalOpen],
3160 );
3161 const newThreadShortcutLabelOptions = useMemo(
3162 () => ({
3163 platform,
3164 context: {
3165 terminalFocus: false,
3166 terminalOpen: false,
3167 },
3168 }),
3169 [platform],
3170 );
3171 const newThreadShortcutLabel =
3172 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
3173 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
3174
3175 const navigateToThread = useCallback(
3176 (threadRef: ScopedThreadRef) => {
3177 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
3178 clearSelection();
3179 }
3180 setSelectionAnchor(scopedThreadKey(threadRef));
3181 if (isMobile) {
3182 setOpenMobile(false);
3183 }
3184 void navigate({
3185 to: "/$environmentId/$threadId",
3186 params: buildThreadRouteParams(threadRef),
3187 });
3188 },
3189 [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor],
3190 );
3191
3192 const projectDnDSensors = useSensors(
3193 useSensor(PointerSensor, {
3194 activationConstraint: { distance: 6 },
3195 }),
3196 );
3197 const projectCollisionDetection = useCallback<CollisionDetection>((args) => {
3198 const pointerCollisions = pointerWithin(args);
3199 if (pointerCollisions.length > 0) {
3200 return pointerCollisions;
3201 }
3202
3203 return closestCorners(args);
3204 }, []);
3205
3206 const handleProjectDragEnd = useCallback(
3207 (event: DragEndEvent) => {
3208 if (sidebarProjectSortOrder !== "manual") {
3209 dragInProgressRef.current = false;
3210 return;
3211 }
3212 dragInProgressRef.current = false;
3213 const { active, over } = event;
3214 if (!over || active.id === over.id) return;
3215 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
3216 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
3217 if (!activeProject || !overProject) return;
3218 const activeMemberKeys = activeProject.memberProjects.map(
3219 (member) => member.physicalProjectKey,
3220 );
3221 const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
3222 reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys);
3223 },
3224 [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects],
3225 );
3226
3227 const handleProjectDragStart = useCallback(
3228 (_event: DragStartEvent) => {
3229 if (sidebarProjectSortOrder !== "manual") {
3230 return;
3231 }
3232 dragInProgressRef.current = true;
3233 suppressProjectClickAfterDragRef.current = true;
2897 })();
32342898 },
3235 [sidebarProjectSortOrder],
3236 );
3237
3238 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
3239 dragInProgressRef.current = false;
3240 }, []);
3241
3242 const animatedProjectListsRef = useRef(new WeakSet<HTMLElement>());
3243 const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3244 if (!node || animatedProjectListsRef.current.has(node)) {
3245 return;
3246 }
3247 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3248 animatedProjectListsRef.current.add(node);
3249 }, []);
3250
3251 const animatedThreadListsRef = useRef(new WeakSet<HTMLElement>());
3252 const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3253 if (!node || animatedThreadListsRef.current.has(node)) {
3254 return;
3255 }
3256 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3257 animatedThreadListsRef.current.add(node);
3258 }, []);
3259
3260 const visibleThreads = useMemo(
3261 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
3262 [sidebarThreads],
3263 );
3264 const sortedProjects = useMemo(() => {
3265 const sortableProjects = sidebarProjects.map((project) => ({
3266 ...project,
3267 id: project.projectKey,
3268 }));
3269 const sortableThreads = visibleThreads.map((thread) => {
3270 const physicalKey =
3271 projectPhysicalKeyByScopedRef.get(
3272 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3273 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3274 return {
3275 ...thread,
3276 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
3277 };
3278 });
3279 return sortProjectsForSidebar(
3280 sortableProjects,
3281 sortableThreads,
3282 sidebarProjectSortOrder,
3283 ).flatMap((project) => {
3284 const resolvedProject = sidebarProjectByKey.get(project.id);
3285 return resolvedProject ? [resolvedProject] : [];
3286 });
3287 }, [
3288 sidebarProjectSortOrder,
3289 physicalToLogicalKey,
3290 projectPhysicalKeyByScopedRef,
3291 sidebarProjectByKey,
3292 sidebarProjects,
3293 visibleThreads,
3294 ]);
3295 const isManualProjectSorting = sidebarProjectSortOrder === "manual";
3296 const visibleSidebarThreadKeys = useMemo(
3297 () =>
3298 sortedProjects.flatMap((project) => {
3299 const projectThreads = sortThreads(
3300 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
3301 (thread) => thread.archivedAt === null,
3302 ),
3303 sidebarThreadSortOrder,
3304 );
3305 const projectExpanded = resolveProjectExpanded(
3306 projectExpandedById,
3307 projectExpansionPreferenceKeys(project),
3308 );
3309 const activeThreadKey = routeThreadKey ?? undefined;
3310 const pinnedCollapsedThread =
3311 !projectExpanded && activeThreadKey
3312 ? (projectThreads.find(
3313 (thread) =>
3314 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
3315 activeThreadKey,
3316 ) ?? null)
3317 : null;
3318 const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
3319 if (!shouldShowThreadPanel) {
3320 return [];
3321 }
3322 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
3323 const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
3324 const previewThreads =
3325 isThreadListExpanded || !hasOverflowingThreads
3326 ? projectThreads
3327 : projectThreads.slice(0, sidebarThreadPreviewCount);
3328 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
3329 return renderedThreads.map((thread) =>
3330 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3331 );
3332 }),
33332899 [
3334 sidebarThreadSortOrder,
3335 sidebarThreadPreviewCount,
3336 expandedThreadListsByProject,
3337 projectExpandedById,
3338 routeThreadKey,
3339 sortedProjects,
3340 threadsByProjectKey,
2900 attemptPin,
2901 attemptSettle,
2902 attemptSnooze,
2903 attemptUnpin,
2904 attemptUnsettle,
2905 attemptUnsnooze,
2906 confirmThreadDelete,
2907 copyBranchToClipboard,
2908 copyPathToClipboard,
2909 deleteThread,
2910 handleMultiSelectContextMenu,
2911 markThreadUnread,
2912 projectCwdByKey,
2913 serverConfigs,
2914 startThreadRename,
2915 updateThreadMetadata,
2916 timestampFormat,
33412917 ],
33422918 );
3343 const threadJumpCommandByKey = useMemo(() => {
3344 const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
3345 for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
3346 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
3347 if (!jumpCommand) {
3348 return mapping;
3349 }
3350 mapping.set(threadKey, jumpCommand);
3351 }
33522919
3353 return mapping;
3354 }, [visibleSidebarThreadKeys]);
3355 const threadJumpThreadKeys = useMemo(
3356 () => [...threadJumpCommandByKey.keys()],
3357 [threadJumpCommandByKey],
3358 );
3359 const sidebarShortcutContext = {
3360 terminalFocus: false,
3361 terminalOpen: routeTerminalOpen,
3362 modelPickerOpen: isModelPickerOpen(),
3363 };
3364 const threadJumpLabelByKey = useMemo(
3365 () =>
3366 buildThreadJumpLabelMap({
3367 keybindings,
3368 platform,
3369 terminalOpen: sidebarShortcutContext.terminalOpen,
3370 threadJumpCommandByKey,
3371 }),
3372 [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey],
3373 );
3374 const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
3375 shortcutModifiers,
3376 keybindings,
3377 {
3378 platform,
3379 context: sidebarShortcutContext,
3380 },
3381 );
3382 const visibleThreadJumpLabelByKey = showThreadJumpHints
3383 ? threadJumpLabelByKey
3384 : EMPTY_THREAD_JUMP_LABELS;
3385 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
3386 const prewarmedSidebarThreadKeys = useMemo(
3387 () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys),
3388 [visibleSidebarThreadKeys],
3389 );
3390 const prewarmedSidebarThreadRefs = useMemo(
3391 () =>
3392 prewarmedSidebarThreadKeys.flatMap((threadKey) => {
3393 const ref = parseScopedThreadKey(threadKey);
3394 return ref ? [ref] : [];
3395 }),
3396 [prewarmedSidebarThreadKeys],
2920 // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
2921 // v1 — the keybinding layer is shared, only the ordered list differs.
2922 const routeTerminalOpen = useTerminalUiStateStore((state) =>
2923 routeThreadRef
2924 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
2925 : false,
33972926 );
3398
3399 useEffect(() => {
3400 updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow);
3401 }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]);
3402
34032927 useEffect(() => {
3404 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
3405 const shortcutContext = getCurrentSidebarShortcutContext();
3406
3407 if (event.defaultPrevented || event.repeat) {
3408 return;
3409 }
3410
2928 const onWindowKeyDown = (event: KeyboardEvent) => {
2929 if (event.defaultPrevented || event.repeat) return;
34112930 const command = resolveShortcutCommand(event, keybindings, {
3412 platform,
3413 context: shortcutContext,
2931 platform: navigator.platform,
2932 context: {
2933 terminalFocus: isTerminalFocused(),
2934 terminalOpen: routeTerminalOpen,
2935 modelPickerOpen: isModelPickerOpen(),
2936 },
34142937 });
3415 const traversalDirection = threadTraversalDirectionFromCommand(command);
3416 if (traversalDirection !== null) {
3417 const targetThreadKey = resolveAdjacentThreadId({
3418 threadIds: orderedSidebarThreadKeys,
3419 currentThreadId: routeThreadKey,
3420 direction: traversalDirection,
3421 });
3422 if (!targetThreadKey) {
3423 return;
3424 }
3425 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3426 if (!targetThread) {
3427 return;
3428 }
3429
2938 const navigateToThreadKey = (targetThreadKey: string | null) => {
2939 if (!targetThreadKey) return false;
2940 const targetThread = threadByKey.get(targetThreadKey);
2941 if (!targetThread) return false;
34302942 event.preventDefault();
34312943 event.stopPropagation();
34322944 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
2945 return true;
2946 };
2947 const traversalDirection = threadTraversalDirectionFromCommand(command);
2948 if (traversalDirection !== null) {
2949 navigateToThreadKey(
2950 resolveAdjacentThreadId({
2951 threadIds: orderedThreadKeys,
2952 currentThreadId: routeThreadKey,
2953 direction: traversalDirection,
2954 }),
2955 );
34332956 return;
34342957 }
3435
34362958 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
3437 if (jumpIndex === null) {
3438 return;
3439 }
3440
3441 const targetThreadKey = threadJumpThreadKeys[jumpIndex];
3442 if (!targetThreadKey) {
3443 return;
3444 }
3445 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3446 if (!targetThread) {
3447 return;
3448 }
3449
3450 event.preventDefault();
3451 event.stopPropagation();
3452 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
2959 if (jumpIndex === null) return;
2960 navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null);
34532961 };
3454
34552962 window.addEventListener("keydown", onWindowKeyDown);
3456
3457 return () => {
3458 window.removeEventListener("keydown", onWindowKeyDown);
3459 };
2963 return () => window.removeEventListener("keydown", onWindowKeyDown);
34602964 }, [
3461 getCurrentSidebarShortcutContext,
34622965 keybindings,
34632966 navigateToThread,
3464 orderedSidebarThreadKeys,
3465 platform,
2967 orderedThreadKeys,
2968 routeTerminalOpen,
34662969 routeThreadKey,
3467 sidebarThreadByKey,
3468 threadJumpThreadKeys,
2970 threadByKey,
34692971 ]);
34702972
3471 useEffect(() => {
3472 const onMouseDown = (event: globalThis.MouseEvent) => {
3473 if (!useThreadSelectionStore.getState().hasSelection()) return;
3474 const target = event.target instanceof HTMLElement ? event.target : null;
3475 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
3476 clearSelection();
3477 };
3478
3479 window.addEventListener("mousedown", onMouseDown);
3480 return () => {
3481 window.removeEventListener("mousedown", onMouseDown);
3482 };
3483 }, [clearSelection]);
3484
3485 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
3486 const desktopUpdateButtonAction = desktopUpdateState
3487 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
3488 : "none";
3489 const showArm64IntelBuildWarning =
3490 isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
3491 const arm64IntelBuildWarningDescription =
3492 desktopUpdateState && showArm64IntelBuildWarning
3493 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
3494 : null;
3495 const commandPaletteShortcutLabel = shortcutLabelForCommand(
2973 // Same predicate as v1: hints show only while the held modifiers exactly
2974 // match a thread-jump binding. Adding Shift (screenshots) or Alt no
2975 // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4.
2976 const shortcutModifiers = useShortcutModifierState();
2977 const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
2978 shortcutModifiers,
34962979 keybindings,
3497 "commandPalette.toggle",
3498 newThreadShortcutLabelOptions,
3499 );
3500 const handleDesktopUpdateButtonClick = useCallback(() => {
3501 const bridge = window.desktopBridge;
3502 if (!bridge || !desktopUpdateState) return;
3503 if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return;
3504
3505 if (desktopUpdateButtonAction === "download") {
3506 void bridge
3507 .downloadUpdate()
3508 .then((result) => {
3509 if (result.completed) {
3510 showDesktopUpdateDownloadedToast(bridge, result.state);
3511 }
3512 if (!shouldToastDesktopUpdateActionResult(result)) return;
3513 const actionError = getDesktopUpdateActionError(result);
3514 if (!actionError) return;
3515 toastManager.add(
3516 stackedThreadToast({
3517 type: "error",
3518 title: "Could not download update",
3519 description: actionError,
3520 }),
3521 );
3522 })
3523 .catch((error) => {
3524 toastManager.add(
3525 stackedThreadToast({
3526 type: "error",
3527 title: "Could not start update download",
3528 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3529 }),
3530 );
3531 });
3532 return;
3533 }
3534
3535 if (desktopUpdateButtonAction === "install") {
3536 const confirmed = window.confirm(
3537 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform),
3538 );
3539 if (!confirmed) return;
3540 void bridge
3541 .installUpdate()
3542 .then((result) => {
3543 if (!shouldToastDesktopUpdateActionResult(result)) return;
3544 const actionError = getDesktopUpdateActionError(result);
3545 if (!actionError) return;
3546 toastManager.add(
3547 stackedThreadToast({
3548 type: "error",
3549 title: "Could not install update",
3550 description: actionError,
3551 }),
3552 );
3553 })
3554 .catch((error) => {
3555 toastManager.add(
3556 stackedThreadToast({
3557 type: "error",
3558 title: "Could not install update",
3559 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3560 }),
3561 );
3562 });
3563 }
3564 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
3565
3566 const expandThreadListForProject = useCallback((projectKey: string) => {
3567 setExpandedThreadListsByProject((current) => {
3568 if (current.has(projectKey)) return current;
3569 const next = new Set(current);
3570 next.add(projectKey);
3571 return next;
3572 });
3573 }, []);
2980 { platform: navigator.platform },
2981 );
2982 useEffect(() => {
2983 setShowJumpHints(shouldShowJumpHintsNow);
2984 }, [shouldShowJumpHintsNow]);
35742985
3575 const collapseThreadListForProject = useCallback((projectKey: string) => {
3576 setExpandedThreadListsByProject((current) => {
3577 if (!current.has(projectKey)) return current;
3578 const next = new Set(current);
3579 next.delete(projectKey);
3580 return next;
3581 });
2986 const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
2987 if (!node) return;
2988 autoAnimate(node, { duration: 150, easing: "ease-out" });
35822989 }, []);
35832990
2991 // New thread defaults to the project you're in (active thread's project,
2992 // falling back to the top project) — same resolution the command palette
2993 // uses. The command palette already offers a "New thread in..." submenu
2994 // for multi-project setups.
2995 const handleNewThreadClick = useCallback(() => {
2996 // One project: nothing to pick, create immediately.
2997 if (projectGroups.length <= 1) {
2998 if (isMobile) setOpenMobile(false);
2999 void startNewThreadFromContext({
3000 activeDraftThread: newThreadContext.activeDraftThread,
3001 activeThread: newThreadContext.activeThread ?? undefined,
3002 defaultProjectRef: newThreadContext.defaultProjectRef,
3003 handleNewThread: newThreadContext.handleNewThread,
3004 });
3005 return;
3006 }
3007 if (isMobile) setOpenMobile(false);
3008 openCommandPalette({ open: "new-thread-in" });
3009 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
3010
3011 // The button mirrors chat.new: in multi-project setups both route through
3012 // the command palette's "New thread in..." picker, and in single-project
3013 // setups both create immediately. chat.newLocal always creates directly, so
3014 // it is only a correct label when chat.new is unbound.
3015 const newThreadShortcutLabel =
3016 shortcutLabelForCommand(keybindings, "chat.new") ??
3017 shortcutLabelForCommand(keybindings, "chat.newLocal");
35843018 return (
35853019 <>
3586 {prewarmedSidebarThreadRefs.map((threadRef) => (
3587 <SidebarThreadDetailPrewarmer key={scopedThreadKey(threadRef)} threadRef={threadRef} />
3588 ))}
35893020 <SidebarChromeHeader isElectron={isElectron} />
3590
3591 {isOnSettings ? (
3592 <SettingsSidebarNav pathname={pathname} />
3593 ) : (
3594 <>
3595 <SidebarProjectsContent
3596 showArm64IntelBuildWarning={showArm64IntelBuildWarning}
3597 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
3598 desktopUpdateButtonAction={desktopUpdateButtonAction}
3599 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
3600 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
3601 projectSortOrder={sidebarProjectSortOrder}
3602 threadSortOrder={sidebarThreadSortOrder}
3603 threadPreviewCount={sidebarThreadPreviewCount}
3604 updateSettings={updateSettings}
3605 openAddProject={openAddProjectCommandPalette}
3606 isManualProjectSorting={isManualProjectSorting}
3607 projectDnDSensors={projectDnDSensors}
3608 projectCollisionDetection={projectCollisionDetection}
3609 handleProjectDragStart={handleProjectDragStart}
3610 handleProjectDragEnd={handleProjectDragEnd}
3611 handleProjectDragCancel={handleProjectDragCancel}
3612 handleNewThread={handleNewThread}
3613 archiveThread={archiveThread}
3614 deleteThread={deleteThread}
3615 sortedProjects={sortedProjects}
3616 expandedThreadListsByProject={expandedThreadListsByProject}
3617 activeRouteProjectKey={activeRouteProjectKey}
3618 routeThreadKey={routeThreadKey}
3619 newThreadShortcutLabel={newThreadShortcutLabel}
3620 commandPaletteShortcutLabel={commandPaletteShortcutLabel}
3621 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
3622 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
3623 expandThreadListForProject={expandThreadListForProject}
3624 collapseThreadListForProject={collapseThreadListForProject}
3625 dragInProgressRef={dragInProgressRef}
3626 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
3627 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
3628 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
3629 projectsLength={projects.length}
3630 />
3631 <SidebarChromeFooter />
3632 </>
3633 )}
3021 <SidebarContent
3022 className="gap-0"
3023 fixedHeader={
3024 // Lifted above the stage backdrop, whose fade bleeds below the
3025 // header and would otherwise paint across the search row's outline.
3026 <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]">
3027 <div className="flex items-center gap-1">
3028 <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
3029 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
3030 <Input
3031 ref={threadSearchInputRef}
3032 nativeInput
3033 unstyled
3034 type="search"
3035 value={threadSearchQuery}
3036 onChange={(event) => {
3037 setThreadSearchQuery(event.currentTarget.value);
3038 setActiveSearchResultIndex(0);
3039 }}
3040 onKeyDown={handleThreadSearchKeyDown}
3041 placeholder="Search"
3042 aria-label="Search threads"
3043 role="combobox"
3044 aria-autocomplete="list"
3045 aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
3046 aria-controls={
3047 isSearchingThreads && threadSearchResults.length > 0
3048 ? "sidebar-thread-search-results"
3049 : undefined
3050 }
3051 aria-activedescendant={
3052 isSearchingThreads && threadSearchResults[activeSearchResultIndex]
3053 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
3054 : undefined
3055 }
3056 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"
3057 />
3058 {isSearchingThreads ? (
3059 <Button
3060 type="button"
3061 size="icon-xs"
3062 variant="ghost"
3063 className="size-5 shrink-0 rounded-sm text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
3064 aria-label="Clear thread search"
3065 onClick={() => {
3066 clearThreadSearch();
3067 threadSearchInputRef.current?.focus();
3068 }}
3069 >
3070 <XIcon className="size-3" />
3071 </Button>
3072 ) : null}
3073 </div>
3074 <div className="shrink-0">
3075 <Tooltip>
3076 <TooltipTrigger
3077 render={
3078 <SidebarMenuButton
3079 size="icon"
3080 type="button"
3081 className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3082 onClick={handleNewThreadClick}
3083 disabled={projects.length === 0}
3084 aria-label="New thread"
3085 />
3086 }
3087 >
3088 <SquarePenIcon />
3089 <span
3090 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
3091 aria-hidden="true"
3092 />
3093 </TooltipTrigger>
3094 <TooltipPopup side="right">
3095 {newThreadShortcutLabel
3096 ? `New thread (${newThreadShortcutLabel})`
3097 : "New thread"}
3098 </TooltipPopup>
3099 </Tooltip>
3100 </div>
3101 </div>
3102 {projectGroups.length > 0 ? (
3103 <div className="flex items-center gap-1">
3104 <Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}>
3105 <MenuTrigger
3106 render={
3107 <SidebarMenuButton
3108 aria-label="Filter threads by project"
3109 className="min-w-0 flex-1 ps-[calc(var(--sidebar-row-content-inset)-1px)] focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3110 />
3111 }
3112 >
3113 {scopedProjectGroup ? (
3114 <ProjectFavicon
3115 environmentId={scopedProjectGroup.environmentId}
3116 cwd={scopedProjectGroup.workspaceRoot}
3117 className="size-4 shrink-0"
3118 />
3119 ) : (
3120 <FolderIcon className="size-4 shrink-0" />
3121 )}
3122 <span className="min-w-0 flex-1 truncate">
3123 {scopedProjectGroup?.displayName ?? "All projects"}
3124 </span>
3125 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
3126 </MenuTrigger>
3127 <MenuPopup align="start" className="w-(--anchor-width)">
3128 <MenuRadioGroup
3129 value={projectScopeKey ?? "all"}
3130 onValueChange={(value) =>
3131 setProjectScopeKey(value === "all" ? null : (value as string))
3132 }
3133 >
3134 <MenuRadioItem
3135 value="all"
3136 closeOnClick
3137 className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
3138 >
3139 <FolderIcon className="size-4 shrink-0" />
3140 <span className="min-w-0 truncate text-sm">All projects</span>
3141 </MenuRadioItem>
3142 {projectGroups.map((project) => {
3143 const scopeKey = project.projectKey;
3144 return (
3145 <MenuRadioItem
3146 key={scopeKey}
3147 value={scopeKey}
3148 closeOnClick
3149 className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
3150 >
3151 <ProjectFavicon
3152 environmentId={project.environmentId}
3153 cwd={project.workspaceRoot}
3154 className="size-4 shrink-0"
3155 />
3156 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
3157 <button
3158 type="button"
3159 aria-label={`Project actions for ${project.displayName}`}
3160 title={`Project actions for ${project.displayName}`}
3161 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
3162 onPointerDown={(event) => event.stopPropagation()}
3163 onClick={(event) => {
3164 void handleProjectActions(event, project);
3165 }}
3166 >
3167 <EllipsisIcon className="size-3.5" />
3168 </button>
3169 </MenuRadioItem>
3170 );
3171 })}
3172 </MenuRadioGroup>
3173 </MenuPopup>
3174 </Menu>
3175 <Tooltip>
3176 <TooltipTrigger
3177 render={
3178 <SidebarMenuButton
3179 size="icon"
3180 className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3181 onClick={openAddProjectCommandPalette}
3182 type="button"
3183 aria-label="New project"
3184 />
3185 }
3186 >
3187 <FolderPlusIcon />
3188 <span
3189 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
3190 aria-hidden="true"
3191 />
3192 </TooltipTrigger>
3193 <TooltipPopup side="right">New project</TooltipPopup>
3194 </Tooltip>
3195 </div>
3196 ) : null}
3197 </SidebarGroup>
3198 }
3199 >
3200 <SidebarGroup className="ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0">
3201 {isSearchingThreads ? (
3202 threadSearchResults.length > 0 ? (
3203 <TooltipProvider
3204 key="sidebar-thread-search-tooltips-150"
3205 delay={150}
3206 closeDelay={0}
3207 timeout={400}
3208 >
3209 <ul
3210 id="sidebar-thread-search-results"
3211 role="listbox"
3212 aria-label="Thread search results"
3213 className="flex flex-col gap-px"
3214 >
3215 {threadSearchResults.map((thread, index) => {
3216 const threadKey = scopedThreadKey(
3217 scopeThreadRef(thread.environmentId, thread.id),
3218 );
3219 return (
3220 <SidebarSearchResultRow
3221 key={threadKey}
3222 thread={thread}
3223 projectCwd={
3224 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3225 }
3226 projectTitle={
3227 projectDisplayNameByKey.get(
3228 `${thread.environmentId}:${thread.projectId}`,
3229 ) ?? null
3230 }
3231 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3232 providerEntryByInstanceId={providerEntryByInstanceId}
3233 isHighlighted={activeSearchResultIndex === index}
3234 isRouteActive={routeThreadKey === threadKey}
3235 resultId={`sidebar-thread-search-result-${index}`}
3236 onHighlight={() => setActiveSearchResultIndex(index)}
3237 onSelect={() => selectThreadSearchResult(thread)}
3238 />
3239 );
3240 })}
3241 </ul>
3242 </TooltipProvider>
3243 ) : (
3244 <p
3245 role="status"
3246 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
3247 >
3248 No threads found
3249 </p>
3250 )
3251 ) : null}
3252 {!isSearchingThreads ? (
3253 <TooltipProvider
3254 key="sidebar-thread-tooltips-150"
3255 delay={150}
3256 closeDelay={0}
3257 timeout={400}
3258 >
3259 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
3260 {(() => {
3261 const renderThreadRow = (
3262 thread: EnvironmentThreadShell,
3263 section: "pinned" | "active" | "snoozed" | "settled",
3264 sortable?: SortablePinnedRowBag,
3265 ) => {
3266 const threadKey = scopedThreadKey(
3267 scopeThreadRef(thread.environmentId, thread.id),
3268 );
3269 // Settled and snoozed are the ONLY things that collapse a
3270 // row: every other thread is a full card. Density comes
3271 // from users (or the auto rules) actually parking work,
3272 // not from the sidebar second-guessing what still matters.
3273 const isCard = section === "active" || section === "pinned";
3274 const rowVariant = isCard ? "card" : "slim";
3275 return (
3276 <SidebarThreadRow
3277 // Keyed per variant on purpose: when a thread settles,
3278 // the card fades out in place and the slim row fades
3279 // in at its settled position instead of one element
3280 // FLIP-sliding through every row in between (rows here
3281 // are translucent, so a crossing row reads as text
3282 // painted over text).
3283 key={`${threadKey}:${rowVariant}`}
3284 thread={thread}
3285 variant={rowVariant}
3286 // Snoozed rows wake; settled rows un-settle (explicit
3287 // settles clear the override, auto-settled rows get
3288 // pinned active); cards settle.
3289 variantAction={
3290 section === "snoozed"
3291 ? "unsnooze"
3292 : section === "settled"
3293 ? "unsettle"
3294 : "settle"
3295 }
3296 settlementSupported={
3297 serverConfigs.get(thread.environmentId)?.environment.capabilities
3298 .threadSettlement === true
3299 }
3300 snoozeSupported={
3301 serverConfigs.get(thread.environmentId)?.environment.capabilities
3302 .threadSnooze === true
3303 }
3304 pinningSupported={
3305 serverConfigs.get(thread.environmentId)?.environment.capabilities
3306 .threadPinning === true
3307 }
3308 isPinned={section === "pinned"}
3309 sortable={sortable}
3310 snoozeWakeLabelText={
3311 section === "snoozed" && thread.snoozedUntil != null
3312 ? snoozeWakeLabel(thread.snoozedUntil, {
3313 now: new Date().toISOString(),
3314 })
3315 : null
3316 }
3317 // All sections: a woken thread can classify straight
3318 // into the settled tail (PR merged while snoozed), and
3319 // the wake signal must survive the trip. Still-snoozed
3320 // rows resolve to null on their own.
3321 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
3322 isActive={routeThreadKey === threadKey}
3323 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
3324 currentEnvironmentId={primaryEnvironmentId}
3325 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3326 projectCwd={
3327 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3328 }
3329 projectTitle={
3330 projectDisplayNameByKey.get(
3331 `${thread.environmentId}:${thread.projectId}`,
3332 ) ?? null
3333 }
3334 providerEntryByInstanceId={providerEntryByInstanceId}
3335 timestampFormat={timestampFormat}
3336 onThreadClick={handleThreadClick}
3337 onThreadActivate={navigateToThread}
3338 onStartRename={startThreadRename}
3339 onRenameTitleChange={setRenamingTitle}
3340 onCommitRename={commitThreadRename}
3341 onCancelRename={cancelThreadRename}
3342 isRenaming={renamingThreadKey === threadKey}
3343 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
3344 onContextMenu={handleThreadContextMenu}
3345 onSettle={attemptSettle}
3346 onUnsettle={attemptUnsettle}
3347 onSnooze={attemptSnooze}
3348 onUnsnooze={attemptUnsnooze}
3349 onUnpin={attemptUnpin}
3350 onAcknowledgeWoke={acknowledgeWoke}
3351 onChangeRequestState={handleChangeRequestState}
3352 />
3353 );
3354 };
3355 // Pinned block: full cards above the inbox, closed by a
3356 // thin divider (the pin glyphs carry the meaning, so no
3357 // header text). Vanishes entirely at count 0.
3358 // Rows render in the one shared pinned order; only
3359 // reorder-capable rows register as sortable (legacy-server
3360 // pins render in place as plain rows).
3361 const items: ReactNode[] = [
3362 <DndContext
3363 key="pinned-dnd"
3364 sensors={pinnedDndSensors}
3365 collisionDetection={closestCenter}
3366 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
3367 onDragEnd={handlePinnedDragEnd}
3368 >
3369 <SortableContext
3370 items={orderedPinnedThreads
3371 .map((thread) =>
3372 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3373 )
3374 .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
3375 strategy={verticalListSortingStrategy}
3376 >
3377 {orderedPinnedThreads.map((thread) => {
3378 const threadKey = scopedThreadKey(
3379 scopeThreadRef(thread.environmentId, thread.id),
3380 );
3381 if (!reorderablePinnedKeys.has(threadKey)) {
3382 return renderThreadRow(thread, "pinned");
3383 }
3384 return (
3385 <SortablePinnedThreadRow key={threadKey} id={threadKey}>
3386 {(bag) => renderThreadRow(thread, "pinned", bag)}
3387 </SortablePinnedThreadRow>
3388 );
3389 })}
3390 </SortableContext>
3391 </DndContext>,
3392 ];
3393 if (pinnedThreads.length > 0) {
3394 items.push(
3395 <li
3396 key="pinned-divider"
3397 aria-hidden
3398 data-testid="sidebar-pinned-divider"
3399 className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
3400 />,
3401 );
3402 }
3403 for (const thread of activeThreads) {
3404 items.push(renderThreadRow(thread, "active"));
3405 }
3406 // Snoozed shelf: between the inbox and Settled — out of the
3407 // way, never gone. The header always renders while anything
3408 // is snoozed (the count is the whole footprint when
3409 // collapsed); rows only when expanded. Vanishes entirely at
3410 // count 0.
3411 if (snoozedThreads.length > 0) {
3412 items.push(
3413 <li
3414 key="snoozed-shelf-header"
3415 data-thread-selection-safe
3416 className="list-none"
3417 >
3418 <button
3419 type="button"
3420 onClick={toggleSnoozedShelf}
3421 aria-expanded={snoozedShelfExpanded}
3422 data-testid="sidebar-snoozed-shelf-toggle"
3423 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
3424 >
3425 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
3426 {snoozedShelfExpanded
3427 ? "Snoozed"
3428 : `Snoozed (${snoozedThreads.length})`}
3429 </span>
3430 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
3431 <ChevronDownIcon
3432 aria-hidden
3433 className={cn(
3434 "size-3 text-blue-600 transition-transform dark:text-blue-400",
3435 snoozedShelfExpanded && "rotate-180",
3436 )}
3437 />
3438 </button>
3439 </li>,
3440 );
3441 for (const thread of visibleSnoozedThreads) {
3442 items.push(renderThreadRow(thread, "snoozed"));
3443 }
3444 }
3445 if (settledThreads.length > 0) {
3446 items.push(
3447 <li
3448 key="settled-shelf-header"
3449 data-thread-selection-safe
3450 className="list-none"
3451 >
3452 <button
3453 type="button"
3454 onClick={toggleSettledShelf}
3455 aria-expanded={settledShelfExpanded}
3456 data-testid="sidebar-settled-shelf-toggle"
3457 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
3458 >
3459 <span className="text-xs font-medium text-muted-foreground/50">
3460 {settledShelfExpanded
3461 ? "Settled"
3462 : `Settled (${settledThreads.length})`}
3463 </span>
3464 <span className="h-px flex-1 bg-sidebar-border/60" />
3465 <ChevronDownIcon
3466 aria-hidden
3467 className={cn(
3468 "size-3 text-muted-foreground/50 transition-transform",
3469 settledShelfExpanded && "rotate-180",
3470 )}
3471 />
3472 </button>
3473 </li>,
3474 );
3475 }
3476 for (const thread of renderedSettledThreads) {
3477 items.push(renderThreadRow(thread, "settled"));
3478 }
3479 return items;
3480 })()}
3481 {settledShelfExpanded && hiddenSettledCount > 0 ? (
3482 <li className="list-none">
3483 <button
3484 type="button"
3485 onClick={showMoreSettled}
3486 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
3487 >
3488 <PlusIcon aria-hidden className="size-4 shrink-0" />
3489 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
3490 </button>
3491 </li>
3492 ) : null}
3493 </ul>
3494 </TooltipProvider>
3495 ) : null}
3496 {!isSearchingThreads &&
3497 pinnedThreads.length +
3498 activeThreads.length +
3499 snoozedThreads.length +
3500 settledThreads.length ===
3501 0 ? (
3502 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
3503 {projects.length === 0 ? (
3504 <>
3505 <span>No projects yet</span>
3506 <button
3507 type="button"
3508 onClick={openAddProjectCommandPalette}
3509 className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
3510 >
3511 <PlusIcon className="-mx-0.5 size-3" />
3512 Add project
3513 </button>
3514 </>
3515 ) : scopedProjectGroup ? (
3516 `No threads in ${scopedProjectGroup.displayName} yet`
3517 ) : (
3518 "No threads yet"
3519 )}
3520 </div>
3521 ) : null}
3522 </SidebarGroup>
3523 </SidebarContent>
3524 <Dialog
3525 open={projectActionsTarget !== null}
3526 onOpenChange={(open) => {
3527 if (!open) setProjectActionsTarget(null);
3528 }}
3529 >
3530 <DialogPopup className="max-w-xl">
3531 <DialogHeader className="gap-3 pb-1!">
3532 <DialogTitle className="text-balance">Project settings</DialogTitle>
3533 <DialogDescription className="sr-only">
3534 Manage project names, grouping rules, and environments.
3535 </DialogDescription>
3536 <div className="grid gap-1.5 text-base text-muted-foreground">
3537 {projectActionsTarget?.memberProjects.map((member) => (
3538 <div key={member.physicalProjectKey} className="flex min-w-0 items-center gap-3">
3539 <span className="flex min-w-0 items-center gap-1">
3540 <FolderIcon className="size-3.5 shrink-0 opacity-60" />
3541 <span className="min-w-0 truncate font-mono">{member.workspaceRoot}</span>
3542 <Button
3543 size="icon-xs"
3544 variant="ghost"
3545 className="size-4 shrink-0 rounded-sm"
3546 aria-label="Copy project path"
3547 title="Copy project path"
3548 onClick={() =>
3549 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
3550 }
3551 >
3552 <CopyIcon className="size-3.5" />
3553 </Button>
3554 </span>
3555 <span className="flex min-w-0 shrink-0 items-center gap-1">
3556 <ServerIcon className="size-3.5 shrink-0 opacity-60" />
3557 <span className="min-w-0 truncate">
3558 {member.environmentLabel ?? "Current environment"}
3559 </span>
3560 </span>
3561 </div>
3562 ))}
3563 </div>
3564 </DialogHeader>
3565 <DialogPanel className="p-0">
3566 <div className="divide-y divide-border/60">
3567 {projectActionsTarget?.memberProjects.map((member) => (
3568 <section
3569 key={member.physicalProjectKey}
3570 className="grid min-w-0 gap-5 px-6 pb-5 pt-2 sm:gap-4 sm:pb-4 sm:pt-2"
3571 >
3572 <div className="grid gap-4 sm:grid-cols-2 sm:gap-3">
3573 <label className="grid min-w-0 gap-1.5">
3574 <span className="font-medium text-foreground">Project name</span>
3575 <Input
3576 key={`${member.physicalProjectKey}:${member.title}`}
3577 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
3578 defaultValue={member.title}
3579 onBlur={(event) => {
3580 void renameProjectMember(member, event.currentTarget.value);
3581 }}
3582 onKeyDown={(event) => {
3583 if (event.key === "Enter") event.currentTarget.blur();
3584 }}
3585 />
3586 </label>
3587 <label className="grid min-w-0 gap-1.5">
3588 <span className="font-medium text-foreground">Grouping rule</span>
3589 <Select
3590 value={
3591 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3592 deriveProjectGroupingOverrideKey(member)
3593 ] ?? "inherit"
3594 }
3595 onValueChange={(value) => {
3596 if (
3597 value === "inherit" ||
3598 value === "repository" ||
3599 value === "repository_path" ||
3600 value === "separate"
3601 ) {
3602 updateProjectGroupingPreference(member, value);
3603 }
3604 }}
3605 >
3606 <SelectTrigger
3607 className="w-full sm:min-h-7.5"
3608 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
3609 >
3610 <SelectValue>
3611 {(() => {
3612 const selection =
3613 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3614 deriveProjectGroupingOverrideKey(member)
3615 ] ?? "inherit";
3616 return selection === "inherit"
3617 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
3618 : PROJECT_GROUPING_MODE_LABELS[selection];
3619 })()}
3620 </SelectValue>
3621 </SelectTrigger>
3622 <SelectPopup align="start" alignItemWithTrigger={false}>
3623 <SelectItem hideIndicator value="inherit">
3624 Use global default
3625 </SelectItem>
3626 <SelectItem hideIndicator value="repository">
3627 {PROJECT_GROUPING_MODE_LABELS.repository}
3628 </SelectItem>
3629 <SelectItem hideIndicator value="repository_path">
3630 {PROJECT_GROUPING_MODE_LABELS.repository_path}
3631 </SelectItem>
3632 <SelectItem hideIndicator value="separate">
3633 {PROJECT_GROUPING_MODE_LABELS.separate}
3634 </SelectItem>
3635 </SelectPopup>
3636 </Select>
3637 </label>
3638 </div>
3639 {projectActionsTarget.memberProjects.length > 1 ? (
3640 <div className="flex justify-end">
3641 <Button
3642 size="sm"
3643 variant="ghost"
3644 className="text-destructive-foreground hover:bg-destructive/8 hover:text-destructive-foreground"
3645 onClick={() => {
3646 const projectGroup = projectActionsTarget;
3647 setProjectActionsTarget(null);
3648 void handleRemoveProjectMembers(projectGroup, [member]);
3649 }}
3650 >
3651 <Trash2Icon />
3652 Remove project
3653 </Button>
3654 </div>
3655 ) : null}
3656 </section>
3657 ))}
3658 </div>
3659 {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? (
3660 <div className="flex flex-col gap-3 border-t border-border/60 bg-muted/32 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
3661 <div className="min-w-0">
3662 <p className="text-base font-medium text-foreground sm:text-sm">
3663 Remove this project everywhere
3664 </p>
3665 <p className="text-base text-pretty text-muted-foreground sm:text-sm">
3666 Deletes all grouped entries and their conversation history.
3667 </p>
3668 </div>
3669 <Button
3670 size="sm"
3671 variant="destructive-outline"
3672 className="shrink-0"
3673 onClick={() => {
3674 const projectGroup = projectActionsTarget;
3675 setProjectActionsTarget(null);
3676 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3677 }}
3678 >
3679 <Trash2Icon />
3680 Remove all entries
3681 </Button>
3682 </div>
3683 ) : null}
3684 </DialogPanel>
3685 <DialogFooter
3686 variant="bare"
3687 className={cn(
3688 projectActionsTarget?.memberProjects.length === 1 && "sm:justify-between",
3689 )}
3690 >
3691 {projectActionsTarget?.memberProjects.length === 1 ? (
3692 <Button
3693 variant="destructive-outline"
3694 onClick={() => {
3695 const projectGroup = projectActionsTarget;
3696 setProjectActionsTarget(null);
3697 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3698 }}
3699 >
3700 <Trash2Icon />
3701 Remove project
3702 </Button>
3703 ) : null}
3704 <Button onClick={() => setProjectActionsTarget(null)}>Close</Button>
3705 </DialogFooter>
3706 </DialogPopup>
3707 </Dialog>
3708 <SidebarChromeFooter />
36343709 </>
36353710 );
36363711}
apps/web/src/components/SidebarV2.tsx deleted-3711
......@@ -1,3711 +0,0 @@
1import { autoAnimate } from "@formkit/auto-animate";
2import { useAtomValue } from "@effect/atom-react";
3import {
4 DndContext,
5 PointerSensor,
6 closestCenter,
7 useSensor,
8 useSensors,
9 type DragEndEvent,
10} from "@dnd-kit/core";
11import {
12 SortableContext,
13 arrayMove,
14 useSortable,
15 verticalListSortingStrategy,
16} from "@dnd-kit/sortable";
17import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
18import { CSS } from "@dnd-kit/utilities";
19import {
20 canSnooze,
21 effectiveSettled,
22 effectiveSnoozed,
23 threadWokeAt,
24} from "@t3tools/client-runtime/state/thread-settled";
25import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
26import {
27 scopeProjectRef,
28 scopeThreadRef,
29 scopedThreadKey,
30} from "@t3tools/client-runtime/environment";
31import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
32import type { TimestampFormat } from "@t3tools/contracts/settings";
33import {
34 AlarmClockIcon,
35 AlarmClockOffIcon,
36 CheckIcon,
37 ChevronDownIcon,
38 CircleAlertIcon,
39 CircleCheckIcon,
40 CircleDashedIcon,
41 ClockIcon,
42 CopyIcon,
43 FolderIcon,
44 FolderPlusIcon,
45 GitBranchIcon,
46 EllipsisIcon,
47 MessageSquareIcon,
48 PinIcon,
49 PlusIcon,
50 SearchIcon,
51 ServerIcon,
52 SquarePenIcon,
53 TerminalIcon,
54 Trash2Icon,
55 Undo2Icon,
56 XIcon,
57} from "lucide-react";
58import {
59 memo,
60 useCallback,
61 useEffect,
62 useMemo,
63 useRef,
64 useState,
65 type KeyboardEvent as ReactKeyboardEvent,
66 type MouseEvent as ReactMouseEvent,
67 type ReactNode,
68} from "react";
69import { useParams, useRouter } from "@tanstack/react-router";
70
71import {
72 isAtomCommandInterrupted,
73 settlePromise,
74 squashAtomCommandFailure,
75} from "@t3tools/client-runtime/state/runtime";
76import { isElectron } from "../env";
77import {
78 resolveShortcutCommand,
79 shortcutLabelForCommand,
80 shouldShowThreadJumpHintsForModifiers,
81 threadJumpCommandForIndex,
82 threadJumpIndexFromCommand,
83 threadTraversalDirectionFromCommand,
84} from "../keybindings";
85import { useShortcutModifierState } from "../shortcutModifierState";
86import { isTerminalFocused } from "../lib/terminalFocus";
87import { isModelPickerOpen } from "../modelPickerVisibility";
88import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
89import { isMacPlatform } from "~/lib/utils";
90import { useOpenPrLink } from "../lib/openPullRequestLink";
91import { readLocalApi } from "../localApi";
92import {
93 deriveProjectGroupingOverrideKey,
94 getProjectOrderKey,
95 selectProjectGroupingSettings,
96} from "../logicalProject";
97import {
98 buildSidebarProjectSnapshots,
99 type SidebarProjectGroupMember,
100 type SidebarProjectSnapshot,
101} from "../sidebarProjectGrouping";
102import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
103import { useThreadSelectionStore } from "../threadSelectionStore";
104import { useThreadActions } from "../hooks/useThreadActions";
105import { useHandleNewThread } from "../hooks/useHandleNewThread";
106import { openCommandPalette } from "../commandPaletteBus";
107import { startNewThreadFromContext } from "../lib/chatThreadActions";
108import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
109import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
110import { useNowMinute } from "../hooks/useNowMinute";
111import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
112import { useProjects, useThreadShells } from "../state/entities";
113import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
114import { vcsEnvironment } from "../state/vcs";
115import { threadEnvironment } from "../state/threads";
116import { projectEnvironment } from "../state/projects";
117import { useEnvironmentQuery } from "../state/query";
118import { useAtomCommand } from "../state/use-atom-command";
119import {
120 buildThreadRouteParams,
121 resolveActiveThreadRouteRef,
122 resolveThreadRouteTarget,
123} from "../threadRoutes";
124import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
125import type { SidebarThreadSummary } from "../types";
126import { cn } from "~/lib/utils";
127import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
128import {
129 buildBulkTitleRegenerationContextMenuItem,
130 formatWorkingDurationLabel,
131 firstValidTimestampMs,
132 hasUnseenCompletion,
133 isTrailingDoubleClick,
134 orderItemsByPreferredIds,
135 planPinnedReorder,
136 resolveAdjacentThreadId,
137 resolveSettledTimestamp,
138 resolveSidebarV2Status,
139 searchSidebarThreadsByTitle,
140 resolveWorkingStartedAt,
141 shouldNavigateAfterProjectRemoval,
142 sortLogicalProjectsForSidebar,
143 sortPinnedThreadsForSidebarV2,
144 sortSettledThreadsForSidebarV2,
145 sortThreadsForSidebarV2,
146} from "./Sidebar.logic";
147import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
148import {
149 prStatusIndicator,
150 resolveThreadPr,
151 settledPrHoverColorClass,
152 terminalStatusFromRunningIds,
153 type TerminalStatusIndicator,
154} from "./ThreadStatusIndicators";
155import {
156 resolveSnoozePresets,
157 snoozeWakeDescription,
158 snoozeWakeLabel,
159 type SnoozePreset,
160} from "./Sidebar.snooze";
161import { ProjectFavicon } from "./ProjectFavicon";
162import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
163import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
164import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
165import { primaryServerProvidersAtom } from "../state/server";
166import { useThreadRunningTerminalIds } from "../state/terminalSessions";
167import { stackedThreadToast, toastManager } from "./ui/toast";
168import { Button } from "./ui/button";
169import {
170 Dialog,
171 DialogDescription,
172 DialogFooter,
173 DialogHeader,
174 DialogPanel,
175 DialogPopup,
176 DialogTitle,
177} from "./ui/dialog";
178import { Input } from "./ui/input";
179import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
180import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
181import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
182import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
183import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
184import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
185import { useComposerDraftStore } from "../composerDraftStore";
186
187// Settled-tail paging: recent history is the common lookup; the deep tail
188// stays behind an explicit Show more.
189const SETTLED_TAIL_INITIAL_COUNT = 10;
190const SETTLED_TAIL_PAGE_COUNT = 25;
191const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
192 repository: "Group by repository",
193 repository_path: "Group by repository path",
194 separate: "Keep separate",
195};
196
197function compactSidebarTimeLabel(label: string): string {
198 if (label === "just now") return "now";
199 return label.endsWith(" ago") ? label.slice(0, -4) : label;
200}
201
202function threadTimeLabel(thread: SidebarThreadSummary): string {
203 const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;
204 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
205}
206
207// Settled rows read "how long ago did this wrap up", matching their sort
208// key: both go through resolveSettledTimestamp so label and order can't
209// disagree.
210function settledTimeLabel(thread: SidebarThreadSummary): string {
211 const timestamp = resolveSettledTimestamp(thread);
212 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
213}
214
215// Floats at the row's right edge, vertically centered, while the jump
216// modifier is held. An overlay pill instead of an inline slot: the hint
217// must neither displace the status/time label (holding ⌘ used to blank
218// out "Working") nor shift any layout when it appears. pointer-events-none
219// so it never swallows clicks meant for the settle/un-settle buttons it
220// can overlap.
221function JumpHintBadge(props: { label: string }) {
222 return (
223 <span
224 aria-hidden
225 className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
226 >
227 {props.label}
228 </span>
229 );
230}
231
232// Self-ticking so only this span re-renders each second, not the whole row.
233function WorkingDuration(props: { startedAt: string | null }) {
234 const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
235 const [, setTick] = useState(0);
236 useEffect(() => {
237 if (Number.isNaN(startedMs)) return;
238 const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000);
239 return () => window.clearInterval(id);
240 }, [startedMs]);
241 if (Number.isNaN(startedMs)) return null;
242 return (
243 <span className="font-mono tabular-nums">
244 {formatWorkingDurationLabel(Date.now() - startedMs)}
245 </span>
246 );
247}
248
249function terminalProcessLabel(count: number): string {
250 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
251}
252
253function SidebarV2ThreadTooltip({
254 thread,
255 projectTitle,
256 projectCwd,
257 environmentLabel,
258 driverKind,
259 modelInstanceId,
260 modelLabel,
261 branchMismatch,
262 terminalStatus,
263 terminalProcessCount,
264}: {
265 thread: SidebarThreadSummary;
266 projectTitle: string | null;
267 projectCwd: string | null;
268 environmentLabel: string | null;
269 driverKind: ProviderInstanceEntry["driverKind"] | null;
270 modelInstanceId: string;
271 modelLabel: string;
272 branchMismatch: {
273 threadBranch: string;
274 currentBranch: string;
275 } | null;
276 terminalStatus: TerminalStatusIndicator | null;
277 terminalProcessCount: number;
278}) {
279 return (
280 <TooltipPopup
281 side="right"
282 align="start"
283 sideOffset={4}
284 variant="glass"
285 className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0"
286 >
287 <div className="flex min-w-0 max-w-80 flex-col gap-2 p-[var(--floating-content-inset)]">
288 <div className="min-w-0 truncate text-xs leading-none font-medium text-foreground">
289 {thread.title}
290 </div>
291 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
292 {projectTitle ? (
293 <div className="flex min-w-0 items-center gap-2">
294 <ProjectFavicon
295 environmentId={thread.environmentId}
296 cwd={projectCwd ?? ""}
297 className="size-3 shrink-0 stroke-muted-foreground"
298 />
299 <div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
300 </div>
301 ) : null}
302 {environmentLabel ? (
303 <div className="flex min-w-0 items-center gap-2">
304 <ServerIcon className="size-3 shrink-0 stroke-muted-foreground" />
305 <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div>
306 </div>
307 ) : null}
308 {thread.branch ? (
309 <div className="flex min-w-0 items-center gap-2">
310 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />
311 <div className="min-w-0 truncate text-foreground/75">{thread.branch}</div>
312 </div>
313 ) : null}
314 {branchMismatch ? (
315 <div className="flex min-w-0 items-start gap-2 text-warning">
316 <CircleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" />
317 <div className="min-w-0 flex-1 wrap-break-word leading-5">
318 You're currently checked out on another branch.
319 </div>
320 </div>
321 ) : null}
322 {driverKind ? (
323 <div className="flex min-w-0 items-center gap-2">
324 <ProviderInstanceIcon
325 driverKind={driverKind}
326 displayName={thread.session?.providerName ?? modelInstanceId}
327 iconClassName="size-3 shrink-0 grayscale opacity-60"
328 />
329 <div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>
330 </div>
331 ) : null}
332 {terminalStatus ? (
333 <div className="flex min-w-0 items-center gap-2">
334 <TerminalIcon
335 aria-hidden
336 className={cn("size-3 shrink-0", terminalStatus.colorClass)}
337 />
338 <div className="min-w-0 truncate text-foreground/75">
339 {terminalProcessLabel(terminalProcessCount)}
340 </div>
341 </div>
342 ) : null}
343 {thread.session?.lastError ? (
344 <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">
345 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />
346 <div className="min-w-0 truncate">Error occurred</div>
347 </div>
348 ) : null}
349 </div>
350 </div>
351 </TooltipPopup>
352 );
353}
354
355/**
356 * Hover entry point for snooze: a clock button opening the preset menu.
357 * Controlled by the row (which also uses the open state to pin its hover
358 * actions while the menu is up).
359 */
360function SnoozePopoverButton(props: {
361 open: boolean;
362 onOpenChange: (open: boolean) => void;
363 onSnooze: (preset: SnoozePreset) => void;
364 timestampFormat: TimestampFormat;
365}) {
366 const { open, onOpenChange, onSnooze, timestampFormat } = props;
367 // Presets resolve at open time so "In 1 hour" is relative to the click,
368 // not to when the row mounted.
369 const presets = useMemo(
370 () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []),
371 [open, timestampFormat],
372 );
373 return (
374 <Popover open={open} onOpenChange={onOpenChange}>
375 <PopoverTrigger
376 render={
377 <button
378 type="button"
379 aria-label="Snooze thread"
380 onClick={(event) => event.stopPropagation()}
381 onDoubleClick={(event) => event.stopPropagation()}
382 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"
383 />
384 }
385 >
386 <ClockIcon className="size-3" />
387 </PopoverTrigger>
388 <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
389 {presets.map((preset) => (
390 <button
391 key={preset.id}
392 type="button"
393 onClick={(event) => {
394 event.stopPropagation();
395 onOpenChange(false);
396 onSnooze(preset);
397 }}
398 className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground"
399 >
400 <span className="flex-1">{preset.label}</span>
401 <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">
402 {preset.whenLabel}
403 </span>
404 </button>
405 ))}
406 </PopoverPopup>
407 </Popover>
408 );
409}
410
411// Subset of useSortable applied to a pinned card's root <li>. Listeners go
412// on the whole card (no dedicated handle): the pointer sensor's distance
413// constraint keeps plain clicks working, and we skip dnd-kit's aria
414// attributes since there is no keyboard sensor and the card body already
415// carries its own button semantics.
416type SortablePinnedRowBag = Pick<
417 ReturnType<typeof useSortable>,
418 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
419>;
420
421function SortablePinnedThreadRow(props: {
422 id: string;
423 children: (bag: SortablePinnedRowBag) => ReactNode;
424}) {
425 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
426 id: props.id,
427 });
428 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
429}
430
431const SidebarV2Row = memo(function SidebarV2Row(props: {
432 thread: SidebarThreadSummary;
433 variant: "card" | "slim";
434 // Slim rows are either settled (action: un-settle) or merely quiet
435 // (seen Ready threads — action: settle).
436 variantAction: "settle" | "unsettle" | "unsnooze";
437 // False on environments whose server predates thread.settle/unsettle:
438 // the lifecycle affordances hide entirely rather than fail on click.
439 settlementSupported: boolean;
440 // Same contract for thread.snooze/unsnooze.
441 snoozeSupported: boolean;
442 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
443 // actions: settling clears the pin server-side, and snoozing hides the
444 // card until wake with the pin intact underneath. The glyph is also the
445 // in-row pin state cue (the pinned block has no header), so it always
446 // shows while pinned; it only becomes a clickable unpin quick-action once
447 // the pinning capability is confirmed, and stays a passive marker while
448 // the descriptor is not loaded. Pinning itself lives in the context menu.
449 pinningSupported: boolean;
450 isPinned: boolean;
451 // Present only on pinned cards whose server supports reordering: dnd-kit
452 // sortable bag applied to the card root so the whole card drags (the
453 // pointer sensor's distance constraint keeps plain clicks working).
454 sortable?: SortablePinnedRowBag | undefined;
455 // Compact wake countdown ("2h") for rows in the snoozed shelf.
456 snoozeWakeLabelText: string | null;
457 // When a snooze ended (timer or early wake); drives the Woke pill until
458 // the user visits the thread.
459 wokeAt: string | null;
460 isActive: boolean;
461 jumpLabel: string | null;
462 currentEnvironmentId: string | null;
463 environmentLabel: string | null;
464 projectCwd: string | null;
465 projectTitle: string | null;
466 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
467 timestampFormat: TimestampFormat;
468 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
469 onThreadActivate: (threadRef: ScopedThreadRef) => void;
470 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
471 onRenameTitleChange: (title: string) => void;
472 onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;
473 onCancelRename: () => void;
474 isRenaming: boolean;
475 renamingTitle: string;
476 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
477 onSettle: (threadRef: ScopedThreadRef) => void;
478 onUnsettle: (threadRef: ScopedThreadRef) => void;
479 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
480 onUnsnooze: (threadRef: ScopedThreadRef) => void;
481 onUnpin: (threadRef: ScopedThreadRef) => void;
482 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
483 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
484}) {
485 const {
486 isRenaming,
487 onChangeRequestState,
488 onCancelRename,
489 onCommitRename,
490 onContextMenu,
491 onAcknowledgeWoke,
492 onRenameTitleChange,
493 onSettle,
494 onSnooze,
495 onStartRename,
496 onThreadActivate,
497 onThreadClick,
498 onUnsettle,
499 onUnsnooze,
500 onUnpin,
501 renamingTitle,
502 thread,
503 variant,
504 variantAction,
505 } = props;
506 const threadRef = useMemo(
507 () => scopeThreadRef(thread.environmentId, thread.id),
508 [thread.environmentId, thread.id],
509 );
510 const threadKey = scopedThreadKey(threadRef);
511 const isRegeneratingTitle = thread.titleRegeneration != null;
512 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
513 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
514 const openPrLink = useOpenPrLink();
515 const runningTerminalIds = useThreadRunningTerminalIds({
516 environmentId: thread.environmentId,
517 threadId: thread.id,
518 });
519 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
520 const terminalProcessCount = runningTerminalIds.length;
521
522 const gitCwd = thread.worktreePath ?? props.projectCwd;
523 const gitStatus = useEnvironmentQuery(
524 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
525 ? vcsEnvironment.status({
526 environmentId: thread.environmentId,
527 input: { cwd: gitCwd },
528 })
529 : null,
530 );
531 const pr = resolveThreadPr({
532 threadBranch: thread.branch,
533 gitStatus: gitStatus.data,
534 });
535 const prState = pr?.state ?? null;
536
537 // Same semantics as v1 (never-visited counts as read): flipping the beta
538 // flag must not light up every historical thread as unread.
539 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
540 const status = resolveSidebarV2Status(thread);
541 // A woken thread reappears at its original position (the sort is
542 // deliberately static), so the pill has to carry the weight. Snoozing is
543 // an explicit act, so the pill clears only when the user re-engages:
544 // reading a completion-triggered wake, clicking the pill, sending a
545 // message, settling, archiving — or finishing the work outright (merged
546 // or closed PR). Timer wakes survive a mere visit. An unparseable visit
547 // timestamp counts as never-visited — corrupt local data must not eat
548 // the wake signal.
549 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
550 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
551 const isWoke =
552 wokeAtDate !== null &&
553 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
554 prState !== "merged" &&
555 prState !== "closed";
556 // In-flight rows (working, or waiting on approval/input) fade as a whole:
557 // there is nothing for the user to do yet, so prominence is reserved for
558 // rows that need a human — done (unread), read-but-unsettled, failed, and
559 // freshly woken. The status label keeps its hue, so waiting rows stay
560 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:
561 // working threads aren't your problem yet) — only the colored status label
562 // stands out.
563 const isInFlight =
564 status === "working" || status === "monitoring" || status === "approval" || status === "input";
565 const shouldRecede =
566 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
567 // Status hues follow the system-wide convention set by sidebar v1 and the
568 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
569 // so a thread reads the same color everywhere it surfaces.
570 const topStatus =
571 status === "working"
572 ? {
573 label: "Working",
574 icon: "working" as const,
575 // No shimmer: a label that animates forever is noise in a sidebar
576 // full of them (and repaints every vsync on high-refresh displays).
577 // Working is a background state, so it rests at the dim end of what
578 // the old pulse cycled through; only the thread you have open gets
579 // the label at full strength.
580 className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"),
581 }
582 : status === "monitoring"
583 ? {
584 // Monitoring is calm background presence, not active progress
585 // (monitoring-pill D6), so it keeps the label at full strength.
586 label: "Monitoring",
587 icon: null,
588 className: "text-sky-600 dark:text-sky-400",
589 }
590 : status === "approval"
591 ? {
592 label: "Approval",
593 icon: null,
594 className: "text-amber-700 dark:text-amber-300",
595 }
596 : status === "input"
597 ? {
598 label: "Input",
599 icon: null,
600 className: "text-indigo-600 dark:text-indigo-300",
601 }
602 : status === "failed"
603 ? {
604 label: "Failed",
605 icon: null,
606 className: "text-red-700 dark:text-red-300",
607 }
608 : isWoke
609 ? {
610 label: "Woke",
611 icon: "woke" as const,
612 className: "text-amber-700 dark:text-amber-300",
613 }
614 : isUnread
615 ? {
616 label: "Done",
617 icon: "done" as const,
618 className: "text-emerald-700 dark:text-emerald-300",
619 }
620 : null;
621 const isWokeStatus = topStatus?.icon === "woke";
622
623 const branchMismatch = resolveLocalCheckoutBranchMismatch({
624 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
625 activeWorktreePath: thread.worktreePath,
626 activeThreadBranch: thread.branch,
627 currentGitBranch: gitStatus.data?.refName ?? null,
628 });
629 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
630 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
631 // Report the PR state up: the parent partitions rows with effectiveSettled,
632 // and a merged/closed PR auto-settles a thread — data only rows have.
633 useEffect(() => {
634 onChangeRequestState(threadKey, prState);
635 }, [onChangeRequestState, prState, threadKey]);
636
637 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
638 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
639 const driverKind = providerEntry?.driverKind ?? null;
640 const selectedModel = providerEntry?.models.find(
641 (model) => model.slug === thread.modelSelection.model,
642 );
643 const modelLabel = selectedModel
644 ? getTriggerDisplayModelLabel(selectedModel)
645 : thread.modelSelection.model;
646
647 const isRemote =
648 props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
649
650 const detailsTooltip = (
651 <SidebarV2ThreadTooltip
652 thread={thread}
653 projectTitle={props.projectTitle}
654 projectCwd={props.projectCwd}
655 environmentLabel={props.environmentLabel}
656 driverKind={driverKind}
657 modelInstanceId={modelInstanceId}
658 modelLabel={modelLabel}
659 branchMismatch={branchMismatch}
660 terminalStatus={terminalStatus}
661 terminalProcessCount={terminalProcessCount}
662 />
663 );
664
665 const handleClick = useCallback(
666 (event: ReactMouseEvent) => {
667 onThreadClick(event, threadRef);
668 },
669 [onThreadClick, threadRef],
670 );
671 const handleAcknowledgeWokeClick = useCallback(
672 (event: ReactMouseEvent) => {
673 event.preventDefault();
674 event.stopPropagation();
675 if (props.wokeAt === null) return;
676 onAcknowledgeWoke(threadRef, props.wokeAt);
677 },
678 [onAcknowledgeWoke, props.wokeAt, threadRef],
679 );
680 const handleContextMenu = useCallback(
681 (event: ReactMouseEvent) => {
682 event.preventDefault();
683 onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
684 },
685 [onContextMenu, threadRef],
686 );
687 const handleKeyDown = useCallback(
688 (event: ReactKeyboardEvent) => {
689 if (event.target !== event.currentTarget) return;
690 if (event.key !== "Enter" && event.key !== " ") return;
691 event.preventDefault();
692 onThreadActivate(threadRef);
693 },
694 [onThreadActivate, threadRef],
695 );
696 const handleDoubleClick = useCallback(
697 (event: ReactMouseEvent) => {
698 if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
699 return;
700 }
701 if ((event.target as HTMLElement).closest("button, a, input")) return;
702 event.preventDefault();
703 onStartRename(threadRef, thread.title);
704 },
705 [isRenaming, onStartRename, thread.title, threadRef],
706 );
707 const renameCommittedRef = useRef(false);
708 useEffect(() => {
709 if (isRenaming) renameCommittedRef.current = false;
710 }, [isRenaming]);
711 const handleRenameKeyDown = useCallback(
712 (event: ReactKeyboardEvent<HTMLInputElement>) => {
713 event.stopPropagation();
714 if (event.key === "Enter") {
715 event.preventDefault();
716 renameCommittedRef.current = true;
717 onCommitRename(threadRef, renamingTitle, thread.title);
718 } else if (event.key === "Escape") {
719 event.preventDefault();
720 renameCommittedRef.current = true;
721 onCancelRename();
722 }
723 },
724 [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
725 );
726 const handleRenameBlur = useCallback(() => {
727 if (!renameCommittedRef.current) {
728 onCommitRename(threadRef, renamingTitle, thread.title);
729 }
730 }, [onCommitRename, renamingTitle, thread.title, threadRef]);
731 const handleSettleClick = useCallback(
732 (event: ReactMouseEvent) => {
733 event.preventDefault();
734 event.stopPropagation();
735 onSettle(threadRef);
736 },
737 [onSettle, threadRef],
738 );
739 const handleUnsettleClick = useCallback(
740 (event: ReactMouseEvent) => {
741 event.preventDefault();
742 event.stopPropagation();
743 onUnsettle(threadRef);
744 },
745 [onUnsettle, threadRef],
746 );
747 const handleUnsnoozeClick = useCallback(
748 (event: ReactMouseEvent) => {
749 event.preventDefault();
750 event.stopPropagation();
751 onUnsnooze(threadRef);
752 },
753 [onUnsnooze, threadRef],
754 );
755 const handleUnpinClick = useCallback(
756 (event: ReactMouseEvent) => {
757 event.preventDefault();
758 event.stopPropagation();
759 onUnpin(threadRef);
760 },
761 [onUnpin, threadRef],
762 );
763 const handleSnoozePreset = useCallback(
764 (preset: SnoozePreset) => {
765 onSnooze(threadRef, preset);
766 },
767 [onSnooze, threadRef],
768 );
769 // While the snooze popover is open the pointer leaves the row, which
770 // would fade the hover actions out from under the open menu; pin them.
771 const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false);
772 // Snooze is offered only where it can succeed: capability-gated and never
773 // on blocked-on-you work or queued turns (the server rejects both).
774 const showSnoozeButton =
775 props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
776 // If the thread becomes blocked while the popover is open, the button
777 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
778 // stale true from permanently hiding the status label / pinning the
779 // hover actions, and the effect clears the raw state so the popover
780 // doesn't resurrect if the button later remounts.
781 const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton;
782 useEffect(() => {
783 if (!showSnoozeButton) setSnoozeMenuOpen(false);
784 }, [showSnoozeButton]);
785 const handlePrClick = useCallback(
786 (event: ReactMouseEvent<HTMLElement>) => {
787 if (pr?.url) openPrLink(event, pr.url);
788 },
789 [openPrLink, pr],
790 );
791
792 // All Sidebar V2 rows share one surface model. Live threads used to look
793 // like elevated cards while settled threads were plain rows, leaving neither
794 // a useful hierarchy nor a reliable hover cue. Status now lives in the row
795 // content; surface is reserved for interaction (hover, multi-select, route).
796 const rowSurfaceClassName = cn(
797 "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none",
798 props.isActive
799 ? "bg-sidebar-row-active text-sidebar-foreground"
800 : isSelected
801 ? "bg-sidebar-row-selected text-sidebar-foreground"
802 : shouldRecede
803 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
804 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
805 isInFlight &&
806 !props.isActive &&
807 !isSelected &&
808 "opacity-70 transition-opacity hover:opacity-100",
809 );
810
811 const title = isRenaming ? (
812 <input
813 autoFocus
814 value={renamingTitle}
815 aria-label="Thread title"
816 onChange={(event) => onRenameTitleChange(event.target.value)}
817 onFocus={(event) => event.currentTarget.select()}
818 onKeyDown={handleRenameKeyDown}
819 onBlur={handleRenameBlur}
820 onClick={(event) => event.stopPropagation()}
821 onDoubleClick={(event) => event.stopPropagation()}
822 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"
823 />
824 ) : (
825 <span
826 className={cn(
827 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
828 shouldRecede ? "font-normal" : "font-medium",
829 variant === "card"
830 ? cn(
831 "truncate",
832 isUnread || isWoke
833 ? "text-foreground"
834 : shouldRecede
835 ? "text-secondary-label"
836 : status === "failed"
837 ? "text-foreground/95"
838 : "text-foreground/90",
839 )
840 : cn(
841 "truncate group-hover/v2-row:text-foreground",
842 props.isActive || isWoke
843 ? "text-foreground"
844 : isUnread
845 ? "text-muted-foreground"
846 : "text-secondary-label/70",
847 ),
848 isRegeneratingTitle && "opacity-[0.55]",
849 )}
850 >
851 {thread.title}
852 </span>
853 );
854
855 const prBadge =
856 prStatus && pr ? (
857 <button
858 type="button"
859 onClick={handlePrClick}
860 className={cn(
861 // Sidebar chrome follows the interface font; tabular digits keep the
862 // number from reflowing as PR states stream in.
863 "shrink-0 text-xs tabular-nums hover:underline",
864 variant === "slim" && variantAction === "unsettle"
865 ? props.isActive
866 ? "text-secondary-label"
867 : cn("text-secondary-label transition-colors", settledPrHoverClass)
868 : prStatus.colorClass,
869 )}
870 aria-label={prStatus.tooltip}
871 >
872 #{pr.number}
873 </button>
874 ) : null;
875 const terminalStatusIcon = terminalStatus ? (
876 <span
877 role="img"
878 aria-label={terminalProcessLabel(terminalProcessCount)}
879 data-testid={`sidebar-v2-terminal-status-${thread.id}`}
880 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
881 >
882 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
883 </span>
884 ) : null;
885
886 if (variant === "slim") {
887 return (
888 <li
889 data-thread-item
890 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
891 >
892 <Tooltip>
893 <TooltipTrigger
894 render={
895 <div
896 role="button"
897 tabIndex={0}
898 data-testid="sidebar-v2-row-slim"
899 aria-busy={isRegeneratingTitle || undefined}
900 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
901 onClick={handleClick}
902 onDoubleClick={handleDoubleClick}
903 onKeyDown={handleKeyDown}
904 onContextMenu={handleContextMenu}
905 />
906 }
907 >
908 {/* Settled history recedes: dimmed favicon at rest, restored on
909 hover so the tail stays scannable when you're hunting. */}
910 <span
911 className={cn(
912 "shrink-0 transition-opacity",
913 !props.isActive &&
914 "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0",
915 )}
916 >
917 <ProjectFavicon
918 environmentId={thread.environmentId}
919 cwd={props.projectCwd ?? ""}
920 className="size-4"
921 fallbackIcon={MessageSquareIcon}
922 />
923 </span>
924 {title}
925 {terminalStatusIcon}
926 {isRegeneratingTitle ? (
927 <span role="status" className="sr-only">
928 Regenerating title
929 </span>
930 ) : null}
931 {/* The PR badge stays outside the hover-fading slot: it must
932 remain visible AND clickable while the row is hovered. Only
933 the time/jump label yields to the settle affordance. */}
934 {prBadge}
935 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
936 <span
937 className={cn(
938 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
939 !isWoke && "group-hover/v2-row:opacity-0",
940 )}
941 >
942 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
943 // Snoozed rows show when they come BACK, not when they were
944 // last touched — the return ticket is the row's whole story.
945 <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
946 {props.snoozeWakeLabelText}
947 </span>
948 ) : isWoke ? (
949 // A wake can land straight in the settled tail (e.g. PR
950 // merged while snoozed); the signal must survive the trip.
951 <button
952 type="button"
953 aria-label="Dismiss Woke notification"
954 title="Dismiss Woke notification"
955 onClick={handleAcknowledgeWokeClick}
956 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"
957 >
958 <AlarmClockIcon aria-hidden className="size-3" />
959 <span role="status">Woke</span>
960 </button>
961 ) : (
962 <span className="text-xs">
963 {variantAction === "unsettle"
964 ? settledTimeLabel(thread)
965 : threadTimeLabel(thread)}
966 </span>
967 )}
968 </span>
969 {variantAction === "unsnooze" ? (
970 !props.snoozeSupported ? null : (
971 <button
972 type="button"
973 aria-label="Wake thread now"
974 onClick={handleUnsnoozeClick}
975 className={cn(
976 "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",
977 isWoke && "group-hover/v2-row:static",
978 )}
979 >
980 <AlarmClockOffIcon className="size-3" />
981 </button>
982 )
983 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
984 <button
985 type="button"
986 aria-label="Un-settle thread"
987 onClick={handleUnsettleClick}
988 className={cn(
989 "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",
990 isWoke && "group-hover/v2-row:static",
991 )}
992 >
993 <Undo2Icon className="mb-px size-3.5" />
994 </button>
995 ) : (
996 <button
997 type="button"
998 aria-label="Settle thread"
999 onClick={handleSettleClick}
1000 className={cn(
1001 "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",
1002 isWoke && "group-hover/v2-row:static",
1003 )}
1004 >
1005 <CheckIcon className="size-3" />
1006 </button>
1007 )}
1008 </span>
1009 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1010 </TooltipTrigger>
1011 {detailsTooltip}
1012 </Tooltip>
1013 </li>
1014 );
1015 }
1016
1017 const diff = latestTurnDiff(thread);
1018
1019 const sortable = props.sortable;
1020 return (
1021 <li
1022 data-thread-item
1023 ref={sortable?.setNodeRef}
1024 style={
1025 sortable
1026 ? {
1027 transform: CSS.Translate.toString(sortable.transform),
1028 transition: sortable.transition,
1029 }
1030 : undefined
1031 }
1032 {...(sortable?.listeners ?? {})}
1033 className={cn(
1034 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
1035 sortable?.isDragging && "z-20 opacity-80",
1036 )}
1037 >
1038 <Tooltip>
1039 <TooltipTrigger
1040 render={
1041 <div
1042 role="button"
1043 tabIndex={0}
1044 data-testid="sidebar-v2-row-card"
1045 aria-busy={isRegeneratingTitle || undefined}
1046 className={rowSurfaceClassName}
1047 onClick={handleClick}
1048 onDoubleClick={handleDoubleClick}
1049 onKeyDown={handleKeyDown}
1050 onContextMenu={handleContextMenu}
1051 />
1052 }
1053 >
1054 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
1055 <div className="flex h-5 min-w-0 items-center gap-1.5">
1056 <ProjectFavicon
1057 environmentId={thread.environmentId}
1058 cwd={props.projectCwd ?? ""}
1059 className="size-4 shrink-0"
1060 />
1061 {props.projectTitle ? (
1062 <span
1063 className={cn(
1064 "min-w-0 flex-1 truncate text-secondary-label text-xs",
1065 shouldRecede ? "font-normal" : "font-medium",
1066 )}
1067 >
1068 {props.projectTitle}
1069 </span>
1070 ) : (
1071 <span className="flex-1" />
1072 )}
1073 {props.isPinned ? (
1074 props.pinningSupported ? (
1075 <button
1076 type="button"
1077 aria-label="Unpin thread"
1078 title="Unpin thread"
1079 onClick={handleUnpinClick}
1080 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1081 >
1082 <PinIcon aria-hidden className="size-3 shrink-0" />
1083 </button>
1084 ) : (
1085 <PinIcon
1086 aria-label="Pinned"
1087 role="img"
1088 className="size-3 shrink-0 text-muted-foreground/65"
1089 />
1090 )
1091 ) : null}
1092 {/* The visible state owns this slot's width: status at rest,
1093 actions on hover/keyboard focus or while the popover is open. Keeping
1094 the hidden state out of flow lets the project label reclaim
1095 space without either state overlapping it. */}
1096 <span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1097 {/* Read-only status labels yield to the hover actions. Woke is
1098 itself an action, so it stays pointer-enabled and visible
1099 while the other controls appear beside it. */}
1100 <span
1101 className={cn(
1102 isWokeStatus
1103 ? "pointer-events-auto"
1104 : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
1105 "self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1106 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1107 )}
1108 >
1109 {topStatus ? (
1110 isWokeStatus ? (
1111 <button
1112 type="button"
1113 aria-label="Dismiss Woke notification"
1114 title="Dismiss Woke notification"
1115 onClick={handleAcknowledgeWokeClick}
1116 className={cn(
1117 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1118 topStatus.className,
1119 )}
1120 >
1121 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1122 <span role="status">{topStatus.label}</span>
1123 </button>
1124 ) : (
1125 <span
1126 className={cn(
1127 "inline-flex items-center gap-1 font-medium",
1128 topStatus.className,
1129 )}
1130 >
1131 {topStatus.icon === "working" ? (
1132 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1133 ) : topStatus.icon === "done" ? (
1134 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1135 ) : null}
1136 {/* The label alone is the live region: a role="status"
1137 wrapper around the ticking duration would make
1138 screen readers announce every second. */}
1139 <span role="status">{topStatus.label}</span>
1140 {status === "working" ? (
1141 <span aria-hidden>
1142 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1143 </span>
1144 ) : null}
1145 </span>
1146 )
1147 ) : (
1148 threadTimeLabel(thread)
1149 )}
1150 </span>
1151 {props.settlementSupported || showSnoozeButton ? (
1152 <span
1153 className={cn(
1154 // focus-visible, not focus-within: a mouse click leaves
1155 // the Settle button focused, and a plain focus-within
1156 // would keep the controls pinned over the status label
1157 // once the pointer moves away (e.g. after a failed
1158 // settle) instead of cross-fading back.
1159 "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",
1160 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1161 )}
1162 >
1163 {showSnoozeButton ? (
1164 <SnoozePopoverButton
1165 open={snoozeMenuOpen}
1166 onOpenChange={setSnoozeMenuOpen}
1167 onSnooze={handleSnoozePreset}
1168 timestampFormat={props.timestampFormat}
1169 />
1170 ) : null}
1171 {props.settlementSupported ? (
1172 <button
1173 type="button"
1174 aria-label="Settle thread"
1175 onClick={handleSettleClick}
1176 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1177 >
1178 <CheckIcon className="size-3.5" />
1179 Settle
1180 </button>
1181 ) : null}
1182 </span>
1183 ) : null}
1184 </span>
1185 </div>
1186 <div className="mt-1 flex min-w-0">
1187 {title}
1188 {isRegeneratingTitle ? (
1189 <span role="status" className="sr-only">
1190 Regenerating title
1191 </span>
1192 ) : null}
1193 </div>
1194 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
1195 {/* While working, the current plan step outranks the branch:
1196 it's the one line that says what the thread is doing. */}
1197 {status === "working" && thread.planProgress ? (
1198 <span className="min-w-0 flex-1 truncate whitespace-nowrap">
1199 {thread.planProgress.step}
1200 {/* Completed count, matching the transcript chip's n/m. */}
1201 <span className="text-icon-muted tabular-nums">
1202 {" "}
1203 {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps}
1204 </span>
1205 </span>
1206 ) : thread.branch ? (
1207 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
1208 ) : (
1209 <span className="flex-1" />
1210 )}
1211 {terminalStatusIcon}
1212 {prBadge}
1213 {diff ? (
1214 <span className="shrink-0 font-mono">
1215 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
1216 <span className="text-red-600 dark:text-red-400">−{diff.deletions}</span>
1217 </span>
1218 ) : null}
1219 <span
1220 aria-hidden
1221 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
1222 >
1223 {isRemote ? (
1224 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
1225 <ServerIcon aria-hidden className="size-3.5" />
1226 </span>
1227 ) : null}
1228 {driverKind ? (
1229 <span className="inline-flex shrink-0 items-center opacity-60">
1230 <ProviderInstanceIcon
1231 driverKind={driverKind}
1232 displayName={thread.session?.providerName ?? modelInstanceId}
1233 iconClassName="size-3.5"
1234 />
1235 </span>
1236 ) : null}
1237 </span>
1238 </div>
1239 </div>
1240 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1241 </TooltipTrigger>
1242 {detailsTooltip}
1243 </Tooltip>
1244 </li>
1245 );
1246});
1247
1248function latestTurnDiff(
1249 thread: SidebarThreadSummary,
1250): { insertions: number; deletions: number } | null {
1251 // Shells don't carry checkpoint summaries; diff stats render only when the
1252 // shell projection grows them. Kept as a seam so the row layout is ready.
1253 void thread;
1254 return null;
1255}
1256
1257const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: {
1258 thread: SidebarThreadSummary;
1259 projectCwd: string | null;
1260 projectTitle: string | null;
1261 environmentLabel: string | null;
1262 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
1263 isHighlighted: boolean;
1264 isRouteActive: boolean;
1265 resultId: string;
1266 onHighlight: () => void;
1267 onSelect: () => void;
1268}) {
1269 const { thread } = props;
1270 // Same details tooltip as the regular rows: a search hit is still a thread,
1271 // and the hover card is how you disambiguate identically-titled results.
1272 const gitCwd = thread.worktreePath ?? props.projectCwd;
1273 const gitStatus = useEnvironmentQuery(
1274 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1275 ? vcsEnvironment.status({
1276 environmentId: thread.environmentId,
1277 input: { cwd: gitCwd },
1278 })
1279 : null,
1280 );
1281 const branchMismatch = resolveLocalCheckoutBranchMismatch({
1282 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
1283 activeWorktreePath: thread.worktreePath,
1284 activeThreadBranch: thread.branch,
1285 currentGitBranch: gitStatus.data?.refName ?? null,
1286 });
1287 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
1288 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
1289 const driverKind = providerEntry?.driverKind ?? null;
1290 const selectedModel = providerEntry?.models.find(
1291 (model) => model.slug === thread.modelSelection.model,
1292 );
1293 const modelLabel = selectedModel
1294 ? getTriggerDisplayModelLabel(selectedModel)
1295 : thread.modelSelection.model;
1296 const runningTerminalIds = useThreadRunningTerminalIds({
1297 environmentId: thread.environmentId,
1298 threadId: thread.id,
1299 });
1300 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
1301 return (
1302 <li role="presentation" className="list-none">
1303 <Tooltip>
1304 <TooltipTrigger
1305 render={
1306 <button
1307 id={props.resultId}
1308 type="button"
1309 role="option"
1310 // aria-activedescendant options: focus stays on the search input,
1311 // which owns all keyboard interaction for the listbox.
1312 tabIndex={-1}
1313 aria-selected={props.isHighlighted}
1314 aria-current={props.isRouteActive ? "page" : undefined}
1315 aria-label={
1316 props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title
1317 }
1318 onMouseMove={props.onHighlight}
1319 onClick={props.onSelect}
1320 className={cn(
1321 "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
1322 props.isHighlighted || props.isRouteActive
1323 ? "bg-sidebar-row-active text-sidebar-foreground"
1324 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
1325 )}
1326 />
1327 }
1328 >
1329 <ProjectFavicon
1330 environmentId={thread.environmentId}
1331 cwd={props.projectCwd ?? ""}
1332 className="size-4 shrink-0"
1333 fallbackIcon={MessageSquareIcon}
1334 />
1335 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
1336 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
1337 {threadTimeLabel(thread)}
1338 </span>
1339 </TooltipTrigger>
1340 <SidebarV2ThreadTooltip
1341 thread={thread}
1342 projectTitle={props.projectTitle}
1343 projectCwd={props.projectCwd}
1344 environmentLabel={props.environmentLabel}
1345 driverKind={driverKind}
1346 modelInstanceId={modelInstanceId}
1347 modelLabel={modelLabel}
1348 branchMismatch={branchMismatch}
1349 terminalStatus={terminalStatus}
1350 terminalProcessCount={runningTerminalIds.length}
1351 />
1352 </Tooltip>
1353 </li>
1354 );
1355});
1356
1357export default function SidebarV2() {
1358 const projects = useProjects();
1359 const projectOrder = useUiStateStore((store) => store.projectOrder);
1360 const threads = useThreadShells();
1361 const router = useRouter();
1362 const { isMobile, setOpenMobile } = useSidebar();
1363 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1364 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1365 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1366 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1367 const timestampFormat = useClientSettings((s) => s.timestampFormat);
1368 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1369 const {
1370 settleThread,
1371 unsettleThread,
1372 snoozeThread,
1373 unsnoozeThread,
1374 pinThread,
1375 unpinThread,
1376 reorderPinnedThread,
1377 deleteThread,
1378 } = useThreadActions();
1379 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1380 reportFailure: false,
1381 });
1382 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1383 reportFailure: false,
1384 });
1385 const updateProject = useAtomCommand(projectEnvironment.update, {
1386 reportFailure: false,
1387 });
1388 const updateSettings = useUpdateClientSettings();
1389 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
1390 onCopy: ({ path }) => {
1391 toastManager.add({
1392 type: "success",
1393 title: "Path copied",
1394 description: path,
1395 });
1396 },
1397 onError: (error) => {
1398 toastManager.add(
1399 stackedThreadToast({
1400 type: "error",
1401 title: "Failed to copy path",
1402 description: error instanceof Error ? error.message : "An error occurred.",
1403 }),
1404 );
1405 },
1406 });
1407 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
1408 target: "branch name",
1409 onCopy: ({ branch }) => {
1410 toastManager.add({
1411 type: "success",
1412 title: "Branch copied",
1413 description: branch,
1414 });
1415 },
1416 onError: (error) => {
1417 toastManager.add(
1418 stackedThreadToast({
1419 type: "error",
1420 title: "Failed to copy branch",
1421 description: error instanceof Error ? error.message : "An error occurred.",
1422 }),
1423 );
1424 },
1425 });
1426 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
1427 null,
1428 );
1429 const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
1430 const newThreadContext = useHandleNewThread();
1431 const openAddProjectCommandPalette = useCallback(
1432 () => openCommandPalette({ open: "add-project" }),
1433 [],
1434 );
1435 const { environments } = useEnvironments();
1436 const primaryEnvironmentId = usePrimaryEnvironmentId();
1437 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
1438 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
1439 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
1440 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
1441 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
1442 const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
1443 const acknowledgeWoke = useCallback(
1444 (threadRef: ScopedThreadRef, visitedAt: string) => {
1445 markThreadVisited(scopedThreadKey(threadRef), visitedAt);
1446 },
1447 [markThreadVisited],
1448 );
1449 const routeTarget = useParams({
1450 strict: false,
1451 select: (params) => resolveThreadRouteTarget(params),
1452 });
1453 const routeDraftThread = useComposerDraftStore((store) =>
1454 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
1455 );
1456 const routeThreadRef = useMemo(
1457 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
1458 [routeDraftThread, routeTarget],
1459 );
1460 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
1461 const routeTargetRef = useRef(routeTarget);
1462 routeTargetRef.current = routeTarget;
1463 // Post-settle navigation validates against the CURRENT route, not the one
1464 // captured when the settle started: if the user navigated elsewhere while
1465 // the command was in flight, completing it must not yank them away.
1466 const routeThreadKeyRef = useRef(routeThreadKey);
1467 routeThreadKeyRef.current = routeThreadKey;
1468
1469 const environmentLabelById = useMemo(
1470 () =>
1471 new Map(
1472 environments.map((environment) => [environment.environmentId, environment.label] as const),
1473 ),
1474 [environments],
1475 );
1476 const orderedProjects = useMemo(
1477 () =>
1478 orderItemsByPreferredIds({
1479 items: projects,
1480 preferredIds: projectOrder,
1481 getId: getProjectOrderKey,
1482 getPreferenceIds: (project) => [
1483 getProjectOrderKey(project),
1484 legacyProjectCwdPreferenceKey(project.workspaceRoot),
1485 ],
1486 }),
1487 [projectOrder, projects],
1488 );
1489 const unsortedProjectGroups = useMemo(
1490 () =>
1491 buildSidebarProjectSnapshots({
1492 projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
1493 settings: projectGroupingSettings,
1494 primaryEnvironmentId,
1495 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
1496 }),
1497 [
1498 environmentLabelById,
1499 orderedProjects,
1500 primaryEnvironmentId,
1501 projectGroupingSettings,
1502 projects,
1503 sidebarProjectSortOrder,
1504 ],
1505 );
1506 const projectGroups = useMemo(
1507 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
1508 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
1509 );
1510 const serverProviders = useAtomValue(primaryServerProvidersAtom);
1511 const providerEntryByInstanceId = useMemo(
1512 () =>
1513 new Map(
1514 deriveProviderInstanceEntries(serverProviders).map(
1515 (entry) => [entry.instanceId as string, entry] as const,
1516 ),
1517 ),
1518 [serverProviders],
1519 );
1520 const projectCwdByKey = useMemo(
1521 () =>
1522 new Map(
1523 projects.map((project) => [
1524 `${project.environmentId}:${project.id}`,
1525 project.workspaceRoot,
1526 ]),
1527 ),
1528 [projects],
1529 );
1530 const projectDisplayNameByKey = useMemo(
1531 () =>
1532 new Map(
1533 projectGroups.flatMap((group) =>
1534 group.memberProjects.map(
1535 (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
1536 ),
1537 ),
1538 ),
1539 [projectGroups],
1540 );
1541
1542 // now is quantized to the minute so effectiveSettled memoization doesn't
1543 // churn on every render; auto-settle thresholds are day-granular anyway.
1544 const nowMinute = useNowMinute();
1545 // Snooze wake times are second-precise, so classifying with the quantized
1546 // minute would hold a woken thread on the shelf for up to a minute. The
1547 // tick is a plain counter bumped exactly at the next wake boundary (armed
1548 // below, after the partition knows the boundary); the partition reads a
1549 // fresh clock whenever it recomputes.
1550 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
1551
1552 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
1553 // closed PR auto-settles its thread on the next partition.
1554 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
1555 ReadonlyMap<string, "open" | "closed" | "merged">
1556 >(() => new Map());
1557 const handleChangeRequestState = useCallback(
1558 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
1559 setChangeRequestStateByKey((current) => {
1560 if ((current.get(threadKey) ?? null) === state) return current;
1561 const next = new Map(current);
1562 if (state === null) {
1563 next.delete(threadKey);
1564 } else {
1565 next.set(threadKey, state);
1566 }
1567 return next;
1568 });
1569 },
1570 [],
1571 );
1572
1573 // Project scope: one menu above the list. Scoping filters the list without
1574 // making the header width depend on the number or length of project names.
1575 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
1576 const scopedProjectGroup = useMemo(
1577 () =>
1578 projectScopeKey === null
1579 ? null
1580 : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
1581 [projectGroups, projectScopeKey],
1582 );
1583 const scopedProjectKeys = useMemo(
1584 () =>
1585 scopedProjectGroup === null
1586 ? null
1587 : new Set(
1588 scopedProjectGroup.memberProjectRefs.map(
1589 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
1590 ),
1591 ),
1592 [scopedProjectGroup],
1593 );
1594 useEffect(() => {
1595 if (projectScopeKey !== null && scopedProjectGroup === null) {
1596 setProjectScopeKey(null);
1597 }
1598 }, [projectScopeKey, scopedProjectGroup]);
1599 // Scope flips drop the selection: rows selected under the old scope may be
1600 // hidden now, and bulk actions must never count or touch invisible rows.
1601 useEffect(() => {
1602 clearSelection();
1603 }, [clearSelection, projectScopeKey]);
1604
1605 const handleRemoveProjectMembers = useCallback(
1606 async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => {
1607 const api = readLocalApi();
1608 if (!api) return;
1609
1610 const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`));
1611 const projectThreads = threads.filter((thread) =>
1612 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
1613 );
1614 const isWholeGroup = members.length === projectGroup.memberProjects.length;
1615 const singleMember = members.length === 1 ? members[0]! : null;
1616 const targetLabel = singleMember?.title ?? projectGroup.displayName;
1617 const confirmed = await settlePromise(() =>
1618 api.dialogs.confirm(
1619 projectThreads.length > 0
1620 ? [
1621 `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
1622 ...(singleMember
1623 ? [
1624 `Path: ${singleMember.workspaceRoot}`,
1625 ...(singleMember.environmentLabel
1626 ? [`Environment: ${singleMember.environmentLabel}`]
1627 : []),
1628 ]
1629 : [`This removes ${members.length} grouped project entries.`]),
1630 "This permanently clears conversation history for those threads.",
1631 isWholeGroup
1632 ? "This removes only the project entries, not the files on disk."
1633 : "Other entries in this grouped project are unaffected.",
1634 "This action cannot be undone.",
1635 ].join("\n")
1636 : [
1637 `Remove project "${targetLabel}"?`,
1638 ...(singleMember
1639 ? [
1640 `Path: ${singleMember.workspaceRoot}`,
1641 ...(singleMember.environmentLabel
1642 ? [`Environment: ${singleMember.environmentLabel}`]
1643 : []),
1644 ]
1645 : [`This removes ${members.length} grouped project entries.`]),
1646 isWholeGroup
1647 ? "This removes only the project entries, not the files on disk."
1648 : "Other entries in this grouped project are unaffected.",
1649 ].join("\n"),
1650 ),
1651 );
1652 if (confirmed._tag === "Failure" || !confirmed.value) return;
1653
1654 const draftStore = useComposerDraftStore.getState();
1655 let shouldNavigate = false;
1656 for (const project of members) {
1657 const memberThreads = projectThreads.filter(
1658 (thread) =>
1659 thread.environmentId === project.environmentId && thread.projectId === project.id,
1660 );
1661 const projectRef = scopeProjectRef(project.environmentId, project.id);
1662 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
1663 const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({
1664 routeTarget: routeTargetRef.current,
1665 projectThreads: memberThreads,
1666 projectDraftId: projectDraftThread?.draftId ?? null,
1667 });
1668
1669 const result = await deleteProject({
1670 environmentId: project.environmentId,
1671 input: {
1672 projectId: project.id,
1673 ...(memberThreads.length > 0 ? { force: true } : {}),
1674 },
1675 });
1676 if (result._tag === "Failure") {
1677 if (!isAtomCommandInterrupted(result)) {
1678 const error = squashAtomCommandFailure(result);
1679 toastManager.add(
1680 stackedThreadToast({
1681 type: "error",
1682 title: `Failed to remove "${project.title}"`,
1683 description: error instanceof Error ? error.message : "An error occurred.",
1684 }),
1685 );
1686 }
1687 if (shouldNavigate) {
1688 void router.navigate({ to: "/" });
1689 }
1690 return;
1691 }
1692
1693 shouldNavigate ||= memberRemovalNeedsNavigation;
1694 if (projectDraftThread) {
1695 draftStore.clearDraftThread(projectDraftThread.draftId);
1696 }
1697 draftStore.clearProjectDraftThreadId(projectRef);
1698 }
1699
1700 if (shouldNavigate) {
1701 void router.navigate({ to: "/" });
1702 }
1703 },
1704 [deleteProject, router, threads],
1705 );
1706
1707 const renameProjectMember = useCallback(
1708 async (member: SidebarProjectGroupMember, nextTitle: string) => {
1709 const title = nextTitle.trim();
1710 if (!title) {
1711 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
1712 return;
1713 }
1714 if (title === member.title) return;
1715 const result = await updateProject({
1716 environmentId: member.environmentId,
1717 input: { projectId: member.id, title },
1718 });
1719 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1720 const error = squashAtomCommandFailure(result);
1721 toastManager.add(
1722 stackedThreadToast({
1723 type: "error",
1724 title: "Failed to rename project",
1725 description: error instanceof Error ? error.message : "An error occurred.",
1726 }),
1727 );
1728 }
1729 },
1730 [updateProject],
1731 );
1732
1733 const updateProjectGroupingPreference = useCallback(
1734 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
1735 const overrideKey = deriveProjectGroupingOverrideKey(member);
1736 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
1737 if (selection === "inherit") {
1738 delete nextOverrides[overrideKey];
1739 } else {
1740 nextOverrides[overrideKey] = selection;
1741 }
1742 updateSettings({ sidebarProjectGroupingOverrides: nextOverrides });
1743 },
1744 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings],
1745 );
1746
1747 const handleProjectActions = useCallback(
1748 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
1749 event.preventDefault();
1750 event.stopPropagation();
1751 setProjectScopeMenuOpen(false);
1752 window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup));
1753 },
1754 [],
1755 );
1756
1757 // Settled threads stay in the live shell stream (settled ≠ archived), so
1758 // the partition works directly off live shells: no archived-snapshot
1759 // merging, no optimistic holds. Archived threads remain hidden here —
1760 // archive keeps its original "remove from sidebar" meaning.
1761 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1762 const {
1763 pinnedThreads,
1764 reorderablePinnedKeys,
1765 activeThreads,
1766 snoozedThreads,
1767 settledThreads,
1768 snoozeNow,
1769 } = useMemo(() => {
1770 const now = `${nowMinute}:00.000Z`;
1771 // Snooze classification uses a REAL clock, not the quantized minute:
1772 // wake times are second-precise and a woken thread must not linger on
1773 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1774 // memo exactly at the next wake boundary.
1775 void snoozeWakeTick;
1776 const preciseNow = new Date().toISOString();
1777 const visible = threads.filter(
1778 (thread) =>
1779 thread.archivedAt === null &&
1780 (scopedProjectKeys === null ||
1781 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1782 );
1783 const pinned: EnvironmentThreadShell[] = [];
1784 const active: EnvironmentThreadShell[] = [];
1785 const snoozed: EnvironmentThreadShell[] = [];
1786 const settled: EnvironmentThreadShell[] = [];
1787 for (const thread of visible) {
1788 // Threads on servers without the settlement capability (old server,
1789 // or descriptor not loaded yet) never classify as settled: the user
1790 // could neither un-settle nor pin them, so auto-settling them would
1791 // strand rows in a tail with no working affordances.
1792 const supportsSettlement =
1793 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1794 const supportsSnooze =
1795 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1796 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1797 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1798 // Snooze outranks everything, including a pin: "hide until Tuesday"
1799 // temporarily suspends "keep on top". The pin survives underneath —
1800 // and so does its pinOrderKey, so on wake the thread reappears at
1801 // its exact slot in the pinned block. (For unpinned threads
1802 // this is also the snooze-beats-auto-settle rule: the wake time is a
1803 // stronger statement about when the thread matters again.)
1804 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1805 snoozed.push(thread);
1806 // A pin otherwise overrides the lifecycle: pinned threads never
1807 // auto-settle out of sight. (The decider clears settled state on
1808 // pin and the pin on settle, so pin-vs-settled conflicts only
1809 // arise from stale or raced writes.)
1810 } else if (thread.pinnedAt != null) {
1811 pinned.push(thread);
1812 } else if (
1813 supportsSettlement &&
1814 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1815 ) {
1816 settled.push(thread);
1817 } else {
1818 active.push(thread);
1819 }
1820 }
1821 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
1822 // user-arranged keys first, keyless threads in creation order below.
1823 // Server capability only gates DRAGGING — it must not influence the
1824 // sort, or mixed-version fleets would render different pinned orders on
1825 // web and mobile from the same data.
1826 return {
1827 pinnedThreads: sortPinnedThreadsForSidebarV2(pinned),
1828 reorderablePinnedKeys: new Set(
1829 pinned
1830 .filter(
1831 (thread) =>
1832 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
1833 true,
1834 )
1835 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1836 ),
1837 activeThreads: sortThreadsForSidebarV2(active),
1838 // Soonest wake first: "what comes back next" is the shelf's question.
1839 snoozedThreads: snoozed.toSorted(
1840 (left, right) =>
1841 firstValidTimestampMs(left.snoozedUntil ?? null) -
1842 firstValidTimestampMs(right.snoozedUntil ?? null),
1843 ),
1844 settledThreads: sortSettledThreadsForSidebarV2(settled),
1845 snoozeNow: preciseNow,
1846 };
1847 }, [
1848 autoSettleAfterDays,
1849 changeRequestStateByKey,
1850 nowMinute,
1851 scopedProjectKeys,
1852 serverConfigs,
1853 snoozeWakeTick,
1854 threads,
1855 ]);
1856
1857 const threadSearchInputRef = useRef<HTMLInputElement>(null);
1858 const [threadSearchQuery, setThreadSearchQuery] = useState("");
1859 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
1860 const isSearchingThreads = threadSearchQuery.trim().length > 0;
1861 const searchableThreads = useMemo(
1862 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
1863 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
1864 );
1865 const threadSearchResults = useMemo(
1866 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
1867 [searchableThreads, threadSearchQuery],
1868 );
1869 const threadSearchResultOrderKey = threadSearchResults
1870 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
1871 .join("\0");
1872
1873 useEffect(() => {
1874 setActiveSearchResultIndex(0);
1875 }, [threadSearchResultOrderKey]);
1876
1877 useEffect(() => {
1878 if (!isSearchingThreads) return;
1879 document
1880 .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`)
1881 ?.scrollIntoView({ block: "nearest" });
1882 }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]);
1883
1884 // Arm a timeout for the earliest upcoming wake so the shelf empties the
1885 // moment a snooze expires instead of on the next minute tick. Sorted
1886 // soonest-first, so entry 0 is the boundary.
1887 useEffect(() => {
1888 const nextWakeAtMs =
1889 snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null
1890 ? Date.parse(snoozedThreads[0].snoozedUntil)
1891 : Number.NaN;
1892 if (Number.isNaN(nextWakeAtMs)) return;
1893 // setTimeout delays are signed 32-bit: anything larger overflows and
1894 // fires immediately, turning a far-future wake (event-condition snoozes
1895 // synced from elsewhere) into a tight re-arm loop. Clamped, the timer
1896 // just re-arms every ~24.8 days until the wake is in range.
1897 const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647);
1898 const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
1899 return () => window.clearTimeout(id);
1900 }, [snoozedThreads]);
1901
1902 // The settled tail renders in pages: history shouldn't dominate the
1903 // sidebar, and the common lookups are recent. Expansion resets when the
1904 // filter context changes so a scope/search flip never inherits a deep
1905 // page state.
1906 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
1907 const settledResetKey = projectScopeKey ?? "all";
1908 const lastSettledResetKeyRef = useRef(settledResetKey);
1909 if (lastSettledResetKeyRef.current !== settledResetKey) {
1910 lastSettledResetKeyRef.current = settledResetKey;
1911 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
1912 }
1913 const visibleSettledThreads = useMemo(() => {
1914 if (settledThreads.length <= settledVisibleCount) return settledThreads;
1915 const visible = settledThreads.slice(0, settledVisibleCount);
1916 // The open thread must never hide under "Show more": navigating into a
1917 // deep settled thread (search, deep link) pulls its row into the visible
1918 // tail so the highlight and the un-settle affordance stay reachable.
1919 if (routeThreadKey !== null) {
1920 const routeThread = settledThreads
1921 .slice(settledVisibleCount)
1922 .find(
1923 (thread) =>
1924 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1925 );
1926 if (routeThread !== undefined) visible.push(routeThread);
1927 }
1928 return visible;
1929 }, [routeThreadKey, settledThreads, settledVisibleCount]);
1930 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;
1931 const showMoreSettled = useCallback(
1932 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
1933 [],
1934 );
1935 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
1936 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
1937 const renderedSettledThreads = useMemo(() => {
1938 if (settledShelfExpanded) return visibleSettledThreads;
1939 if (routeThreadKey === null) return [];
1940 const routeThread = visibleSettledThreads.find(
1941 (thread) =>
1942 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1943 );
1944 return routeThread === undefined ? [] : [routeThread];
1945 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
1946
1947 // The snoozed shelf is collapsed by default: out of the way, never gone.
1948 // Collapsed threads don't render (and so don't participate in jump
1949 // shortcuts or multi-select), matching the settled tail's paging model.
1950 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
1951 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
1952 const visibleSnoozedThreads = useMemo(() => {
1953 if (snoozedShelfExpanded) return snoozedThreads;
1954 // The open thread must never vanish behind the collapsed shelf: a
1955 // snoozed thread reached by route (deep link, open before snoozing
1956 // elsewhere) keeps its row — with highlight and wake affordance — same
1957 // exception the settled tail's "Show more" makes.
1958 if (routeThreadKey === null) return [];
1959 const routeThread = snoozedThreads.find(
1960 (thread) =>
1961 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1962 );
1963 return routeThread === undefined ? [] : [routeThread];
1964 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
1965
1966 const orderedThreads = useMemo(
1967 () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1968 [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],
1969 );
1970 const orderedThreadKeys = useMemo(
1971 () =>
1972 orderedThreads.map((thread) =>
1973 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1974 ),
1975 [orderedThreads],
1976 );
1977 // Rows call back into the click handler without carrying the ordered list as
1978 // a prop — a fresh array identity per shell update would defeat every row's
1979 // memoization. The ref keeps shift-range-select working against the list as
1980 // rendered at click time.
1981 const orderedThreadKeysRef = useRef(orderedThreadKeys);
1982 orderedThreadKeysRef.current = orderedThreadKeys;
1983 const threadByKey = useMemo(
1984 () =>
1985 new Map(
1986 orderedThreads.map(
1987 (thread) =>
1988 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1989 ),
1990 ),
1991 [orderedThreads],
1992 );
1993 // Handlers read these through refs: depending on per-update Map/Set
1994 // identities would give every row a fresh callback prop on each shell
1995 // event and defeat row memoization during streaming.
1996 const threadByKeyRef = useRef(threadByKey);
1997 threadByKeyRef.current = threadByKey;
1998 // handleNewThread is inherently unstable (depends on the projects list);
1999 // a ref keeps it out of attemptSettle's dependency array.
2000 const handleNewThreadRef = useRef(newThreadContext.handleNewThread);
2001 handleNewThreadRef.current = newThreadContext.handleNewThread;
2002 const settledThreadKeys = useMemo(
2003 () =>
2004 new Set(
2005 settledThreads.map((thread) =>
2006 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2007 ),
2008 ),
2009 [settledThreads],
2010 );
2011 const settledThreadKeysRef = useRef(settledThreadKeys);
2012 settledThreadKeysRef.current = settledThreadKeys;
2013 const snoozedThreadKeys = useMemo(
2014 () =>
2015 new Set(
2016 snoozedThreads.map((thread) =>
2017 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2018 ),
2019 ),
2020 [snoozedThreads],
2021 );
2022 const snoozedThreadKeysRef = useRef(snoozedThreadKeys);
2023 snoozedThreadKeysRef.current = snoozedThreadKeys;
2024
2025 const jumpLabelByKey = useMemo(() => {
2026 const mapping = new Map<string, string>();
2027 for (const [index, threadKey] of orderedThreadKeys.entries()) {
2028 const jumpCommand = threadJumpCommandForIndex(index);
2029 if (!jumpCommand) break;
2030 const label = shortcutLabelForCommand(keybindings, jumpCommand);
2031 if (label) mapping.set(threadKey, label);
2032 }
2033 return mapping;
2034 }, [keybindings, orderedThreadKeys]);
2035 const [showJumpHints, setShowJumpHints] = useState(false);
2036
2037 // Settled threads are live shells, so opening one is plain navigation:
2038 // history stays readable without un-settling, and sending a message or
2039 // starting a session un-settles server-side.
2040 const navigateToThread = useCallback(
2041 (threadRef: ScopedThreadRef) => {
2042 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
2043 clearSelection();
2044 }
2045 setSelectionAnchor(scopedThreadKey(threadRef));
2046 if (isMobile) {
2047 setOpenMobile(false);
2048 }
2049 void router.navigate({
2050 to: "/$environmentId/$threadId",
2051 params: buildThreadRouteParams(threadRef),
2052 });
2053 },
2054 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
2055 );
2056
2057 const clearThreadSearch = useCallback(() => {
2058 setThreadSearchQuery("");
2059 setActiveSearchResultIndex(0);
2060 }, []);
2061 const selectThreadSearchResult = useCallback(
2062 (thread: EnvironmentThreadShell) => {
2063 clearThreadSearch();
2064 navigateToThread(scopeThreadRef(thread.environmentId, thread.id));
2065 },
2066 [clearThreadSearch, navigateToThread],
2067 );
2068 const handleThreadSearchKeyDown = useCallback(
2069 (event: ReactKeyboardEvent<HTMLInputElement>) => {
2070 // IME composition (Japanese/Chinese input) uses the same keys; committing
2071 // a candidate must not move the highlight or navigate away mid-compose.
2072 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2073 if (event.key === "Escape" && isSearchingThreads) {
2074 event.preventDefault();
2075 event.stopPropagation();
2076 clearThreadSearch();
2077 return;
2078 }
2079 if (threadSearchResults.length === 0) return;
2080 if (event.key === "ArrowDown") {
2081 event.preventDefault();
2082 setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length);
2083 return;
2084 }
2085 if (event.key === "ArrowUp") {
2086 event.preventDefault();
2087 setActiveSearchResultIndex(
2088 (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length,
2089 );
2090 return;
2091 }
2092 if (event.key === "Enter") {
2093 event.preventDefault();
2094 const result = threadSearchResults[activeSearchResultIndex];
2095 if (result) selectThreadSearchResult(result);
2096 }
2097 },
2098 [
2099 activeSearchResultIndex,
2100 clearThreadSearch,
2101 isSearchingThreads,
2102 selectThreadSearchResult,
2103 threadSearchResults,
2104 ],
2105 );
2106
2107 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
2108 const [renamingTitle, setRenamingTitle] = useState("");
2109 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
2110 setRenamingThreadKey(scopedThreadKey(threadRef));
2111 setRenamingTitle(title);
2112 }, []);
2113 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
2114 const commitThreadRename = useCallback(
2115 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
2116 void (async () => {
2117 const trimmed = title.trim();
2118 setRenamingThreadKey(null);
2119 if (trimmed.length === 0) {
2120 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
2121 return;
2122 }
2123 if (trimmed === originalTitle) return;
2124 const result = await updateThreadMetadata({
2125 environmentId: threadRef.environmentId,
2126 input: { threadId: threadRef.threadId, title: trimmed },
2127 });
2128 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2129 const error = squashAtomCommandFailure(result);
2130 toastManager.add(
2131 stackedThreadToast({
2132 type: "error",
2133 title: "Failed to rename thread",
2134 description: error instanceof Error ? error.message : "An error occurred.",
2135 }),
2136 );
2137 }
2138 })();
2139 },
2140 [updateThreadMetadata],
2141 );
2142
2143 const handleThreadClick = useCallback(
2144 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
2145 const isMac = isMacPlatform(navigator.platform);
2146 const isModClick = isMac ? event.metaKey : event.ctrlKey;
2147 const threadKey = scopedThreadKey(threadRef);
2148 if (isModClick) {
2149 event.preventDefault();
2150 toggleThreadSelection(threadKey);
2151 return;
2152 }
2153 if (event.shiftKey) {
2154 event.preventDefault();
2155 rangeSelectTo(threadKey, orderedThreadKeysRef.current);
2156 return;
2157 }
2158 if (isTrailingDoubleClick(event.detail)) {
2159 return;
2160 }
2161 navigateToThread(threadRef);
2162 },
2163 [navigateToThread, rangeSelectTo, toggleThreadSelection],
2164 );
2165
2166 // A settle per thread at a time: double clicks and repeated menu picks
2167 // must not dispatch a second settle that fails and toasts a false error.
2168 const settlingThreadKeysRef = useRef(new Set<string>());
2169 // Parking the thread you're looking at (settle or snooze) moves you
2170 // forward: the next remaining card (never a settled or snoozed row, never
2171 // one leaving in the same batch), or a fresh draft in this project when it
2172 // was the last active one. Callers snapshot the plan BEFORE the command
2173 // mutates the partition; background parks never navigate (null plan).
2174 const planForwardNavigation = useCallback(
2175 (threadKey: string, coParkingKeys?: ReadonlySet<string>): (() => void) | null => {
2176 if (routeThreadKeyRef.current !== threadKey) return null;
2177 const shell = threadByKeyRef.current.get(threadKey);
2178 const orderedKeys = orderedThreadKeysRef.current;
2179 const settledKeys = settledThreadKeysRef.current;
2180 const snoozedKeys = snoozedThreadKeysRef.current;
2181 const currentIndex = orderedKeys.indexOf(threadKey);
2182 const nextCardKey =
2183 currentIndex === -1
2184 ? null
2185 : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find(
2186 (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key),
2187 ) ?? null);
2188 const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
2189 return nextThread
2190 ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
2191 : shell
2192 ? () =>
2193 void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId))
2194 : () => void router.navigate({ to: "/" });
2195 },
2196 [navigateToThread, router],
2197 );
2198
2199 const attemptSettle = useCallback(
2200 (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => {
2201 void (async () => {
2202 const threadKey = scopedThreadKey(threadRef);
2203 if (settlingThreadKeysRef.current.has(threadKey)) return;
2204 settlingThreadKeysRef.current.add(threadKey);
2205 try {
2206 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
2207 const result = await settleThread(threadRef);
2208 if (result._tag === "Failure") {
2209 // Never navigate away from a thread that did not settle.
2210 if (!isAtomCommandInterrupted(result)) {
2211 const error = squashAtomCommandFailure(result);
2212 toastManager.add(
2213 stackedThreadToast({
2214 type: "error",
2215 title: "Failed to settle thread",
2216 description: error instanceof Error ? error.message : "An error occurred.",
2217 }),
2218 );
2219 }
2220 return;
2221 }
2222 // Only move forward if the user is still on the settled thread —
2223 // a navigation made during the await wins over ours.
2224 if (routeThreadKeyRef.current === threadKey) {
2225 navigateAfterSettle?.();
2226 }
2227 } finally {
2228 settlingThreadKeysRef.current.delete(threadKey);
2229 }
2230 })();
2231 },
2232 [planForwardNavigation, settleThread],
2233 );
2234 const attemptUnsettle = useCallback(
2235 (threadRef: ScopedThreadRef) => {
2236 void (async () => {
2237 const result = await unsettleThread(threadRef);
2238 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2239 const error = squashAtomCommandFailure(result);
2240 toastManager.add(
2241 stackedThreadToast({
2242 type: "error",
2243 title: "Failed to un-settle thread",
2244 description: error instanceof Error ? error.message : "An error occurred.",
2245 }),
2246 );
2247 }
2248 })();
2249 },
2250 [unsettleThread],
2251 );
2252 const attemptUnsnooze = useCallback(
2253 (threadRef: ScopedThreadRef) => {
2254 void (async () => {
2255 const result = await unsnoozeThread(threadRef);
2256 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2257 const error = squashAtomCommandFailure(result);
2258 toastManager.add(
2259 stackedThreadToast({
2260 type: "error",
2261 title: "Failed to wake thread",
2262 description: error instanceof Error ? error.message : "An error occurred.",
2263 }),
2264 );
2265 }
2266 })();
2267 },
2268 [unsnoozeThread],
2269 );
2270 // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
2271 // for the moved thread and sends it to that thread's own server (see
2272 // planPinnedReorder for the keyless-neighbor materialization case). The
2273 // optimistic order keeps the card where it was dropped until the
2274 // confirming event round-trips; canonical order matching it releases the
2275 // override, and a failed write clears it (the card snaps back) with a toast.
2276 // ANY membership change (new pin, unpin, snooze/wake) also releases it:
2277 // the override can't say where members it never saw belong, and holding it
2278 // would misplace them and launder the stale order into later drags.
2279 const pinnedDndSensors = useSensors(
2280 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
2281 );
2282 const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
2283 readonly order: readonly string[];
2284 /** pinOrderKey per thread as of the drop, so ANY landed write (ours
2285 confirming, or a concurrent one from another client) releases the
2286 override rather than fighting canonical state. */
2287 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2288 } | null>(null);
2289 const orderedPinnedThreads = useMemo(() => {
2290 if (optimisticPinnedOrder === null) return pinnedThreads;
2291 return orderItemsByPreferredIds({
2292 items: pinnedThreads,
2293 preferredIds: optimisticPinnedOrder.order,
2294 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2295 });
2296 }, [optimisticPinnedOrder, pinnedThreads]);
2297 useEffect(() => {
2298 if (optimisticPinnedOrder === null) return;
2299 const canonical = pinnedThreads.filter((thread) =>
2300 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2301 );
2302 const canonicalKeys = canonical.map((thread) =>
2303 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2304 );
2305 // The override represents one drop against one snapshot of the world.
2306 // Release it as soon as the world moves on in any way: membership
2307 // changed (pin/unpin/snooze/wake — the override can't say where members
2308 // it never saw belong), a key changed (our write confirming, or a
2309 // concurrent client's reorder that must win), or canonical already
2310 // matches. Holding it longer would misplace newcomers and launder the
2311 // stale order into later drags.
2312 const membershipChanged =
2313 canonicalKeys.length !== optimisticPinnedOrder.order.length ||
2314 canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
2315 const anyKeyLanded = canonical.some(
2316 (thread, index) =>
2317 optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !==
2318 (thread.pinOrderKey ?? null),
2319 );
2320 const orderConfirmed =
2321 !membershipChanged &&
2322 canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
2323 if (membershipChanged || anyKeyLanded || orderConfirmed) {
2324 setOptimisticPinnedOrder(null);
2325 }
2326 }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
2327 const attemptPin = useCallback(
2328 (threadRef: ScopedThreadRef) => {
2329 void (async () => {
2330 // Fresh pins take the top of the arranged run: pinThread computes a
2331 // key before the smallest key across ALL pinned shells — including
2332 // snoozed pins hidden from this list, whose keys are still part of
2333 // the run — so the new pin can't land beneath a hidden head.
2334 const result = await pinThread(threadRef);
2335 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2336 const error = squashAtomCommandFailure(result);
2337 toastManager.add(
2338 stackedThreadToast({
2339 type: "error",
2340 title: "Failed to pin thread",
2341 description: error instanceof Error ? error.message : "An error occurred.",
2342 }),
2343 );
2344 }
2345 })();
2346 },
2347 [pinThread],
2348 );
2349 const attemptUnpin = useCallback(
2350 (threadRef: ScopedThreadRef) => {
2351 void (async () => {
2352 const result = await unpinThread(threadRef);
2353 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2354 const error = squashAtomCommandFailure(result);
2355 toastManager.add(
2356 stackedThreadToast({
2357 type: "error",
2358 title: "Failed to unpin thread",
2359 description: error instanceof Error ? error.message : "An error occurred.",
2360 }),
2361 );
2362 }
2363 })();
2364 },
2365 [unpinThread],
2366 );
2367
2368 const handlePinnedDragEnd = useCallback(
2369 (event: DragEndEvent) => {
2370 const activeKey = String(event.active.id);
2371 const overKey = event.over === null ? null : String(event.over.id);
2372 if (overKey === null || activeKey === overKey) return;
2373 const reorderable = orderedPinnedThreads.filter((thread) =>
2374 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2375 );
2376 const keys = reorderable.map((thread) =>
2377 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2378 );
2379 const fromIndex = keys.indexOf(activeKey);
2380 const toIndex = keys.indexOf(overKey);
2381 if (fromIndex === -1 || toIndex === -1) return;
2382 const newOrder = arrayMove([...keys], fromIndex, toIndex);
2383 const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
2384 const keysAtDrop = new Map(
2385 reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
2386 );
2387 const assignments = planPinnedReorder({
2388 orderedIds: newOrder,
2389 keysById: keysAtDrop,
2390 movedId: activeKey,
2391 });
2392 if (assignments.length === 0) return;
2393 setOptimisticPinnedOrder({ order: newOrder, keysAtDrop });
2394 void (async () => {
2395 // Sequential, stop on first failure. There is deliberately no
2396 // rollback: every key write is a complete, valid placement on its
2397 // own, so a partial materialization leaves a sensible order (and
2398 // the next drag repairs the rest) — unwinding writes across
2399 // servers would trade that for real inconsistency windows.
2400 for (const assignment of assignments) {
2401 const thread = threadByKey.get(assignment.id);
2402 if (thread === undefined) continue;
2403 const result = await reorderPinnedThread(
2404 scopeThreadRef(thread.environmentId, thread.id),
2405 assignment.orderKey,
2406 );
2407 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2408 setOptimisticPinnedOrder(null);
2409 const error = squashAtomCommandFailure(result);
2410 toastManager.add(
2411 stackedThreadToast({
2412 type: "error",
2413 title: "Failed to reorder pinned threads",
2414 description: error instanceof Error ? error.message : "An error occurred.",
2415 }),
2416 );
2417 return;
2418 }
2419 }
2420 })();
2421 },
2422 [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
2423 );
2424 // One snooze per thread at a time — same double-dispatch guard as settle.
2425 const snoozingThreadKeysRef = useRef(new Set<string>());
2426 const performSnooze = useCallback(
2427 async (
2428 threadRef: ScopedThreadRef,
2429 preset: SnoozePreset,
2430 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
2431 ) => {
2432 const threadKey = scopedThreadKey(threadRef);
2433 if (snoozingThreadKeysRef.current.has(threadKey)) {
2434 return { status: "skipped" } as const;
2435 }
2436 snoozingThreadKeysRef.current.add(threadKey);
2437 try {
2438 // Snoozing the open thread moves you forward, same as settle —
2439 // both park the thread you're done with for now.
2440 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
2441 const result = await snoozeThread(threadRef, preset.snoozedUntil);
2442 if (result._tag === "Failure") {
2443 // Never navigate away from a thread that did not snooze.
2444 return isAtomCommandInterrupted(result)
2445 ? ({ status: "interrupted" } as const)
2446 : ({ status: "failure", error: squashAtomCommandFailure(result) } as const);
2447 }
2448 // Only move forward if the user is still on the snoozed thread —
2449 // a navigation made during the await wins over ours.
2450 if (routeThreadKeyRef.current === threadKey) {
2451 navigateAfterSnooze?.();
2452 }
2453 return { status: "success" } as const;
2454 } finally {
2455 snoozingThreadKeysRef.current.delete(threadKey);
2456 }
2457 },
2458 [planForwardNavigation, snoozeThread],
2459 );
2460 const attemptSnooze = useCallback(
2461 (
2462 threadRef: ScopedThreadRef,
2463 preset: SnoozePreset,
2464 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
2465 ) => {
2466 void (async () => {
2467 const outcome = await performSnooze(threadRef, preset, opts);
2468 if (outcome.status === "failure") {
2469 toastManager.add(
2470 stackedThreadToast({
2471 type: "error",
2472 title: "Failed to snooze thread",
2473 description:
2474 outcome.error instanceof Error ? outcome.error.message : "An error occurred.",
2475 }),
2476 );
2477 return;
2478 }
2479 if (outcome.status !== "success") return;
2480 // Snooze hides the row, so the toast is the only confirmation —
2481 // and the Undo is the escape hatch for a mis-click.
2482 toastManager.add(
2483 stackedThreadToast({
2484 type: "success",
2485 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
2486 timeout: 5_000,
2487 actionProps: {
2488 children: "Undo",
2489 onClick: () => attemptUnsnooze(threadRef),
2490 },
2491 }),
2492 );
2493 })();
2494 },
2495 [attemptUnsnooze, performSnooze, timestampFormat],
2496 );
2497
2498 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
2499 const handleMultiSelectContextMenu = useCallback(
2500 async (position: { x: number; y: number }) => {
2501 const api = readLocalApi();
2502 if (!api) return;
2503 // One exact actionable set: keys whose rows are actually rendered
2504 // right now. Selections can outlive their rows (settled-tail paging,
2505 // thread deletion elsewhere) and the menu labels must count only what
2506 // the actions will touch.
2507 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter(
2508 (threadKey) => threadByKeyRef.current.has(threadKey),
2509 );
2510 if (threadKeys.length === 0) return;
2511 const count = threadKeys.length;
2512 // Snooze (N) is offered when every selected thread can actually take
2513 // it — a mixed selection with blocked-on-you work would half-apply.
2514 const selectionNow = new Date();
2515 const selectedThreads = threadKeys.flatMap((threadKey) => {
2516 const thread = threadByKeyRef.current.get(threadKey);
2517 return thread ? [thread] : [];
2518 });
2519 const canSnoozeSelection = selectedThreads.every(
2520 (thread) =>
2521 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
2522 canSnooze(thread, { now: selectionNow.toISOString() }),
2523 );
2524 const titleRegenerationThreads = selectedThreads.filter(
2525 (thread) =>
2526 serverConfigs.get(thread.environmentId)?.environment.capabilities
2527 .threadTitleRegeneration === true,
2528 );
2529 const regeneratableTitleThreads = titleRegenerationThreads.filter(
2530 (thread) => thread.titleRegeneration == null,
2531 );
2532 const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({
2533 supportedCount: titleRegenerationThreads.length,
2534 actionableCount: regeneratableTitleThreads.length,
2535 });
2536 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2537 const clicked = await settlePromise(() =>
2538 api.contextMenu.show(
2539 [
2540 { id: "settle", label: `Settle (${count})` },
2541 ...(canSnoozeSelection
2542 ? [
2543 {
2544 id: "snooze",
2545 label: `Snooze (${count})`,
2546 children: snoozePresets.map((preset) => ({
2547 id: `snooze:${preset.id}`,
2548 label: `${preset.label} (${preset.whenLabel})`,
2549 })),
2550 },
2551 ]
2552 : []),
2553 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
2554 { id: "mark-unread", label: `Mark unread (${count})` },
2555 { id: "delete", label: `Delete (${count})`, destructive: true },
2556 ],
2557 position,
2558 ),
2559 );
2560 if (clicked._tag === "Failure") return;
2561 if (clicked.value?.startsWith("snooze:")) {
2562 const preset = snoozePresets.find(
2563 (candidate) => `snooze:${candidate.id}` === clicked.value,
2564 );
2565 if (preset) {
2566 // Post-snooze navigation must skip threads snoozing in this same
2567 // batch — they are all leaving the card block together.
2568 const coSnoozingKeys = new Set(threadKeys);
2569 clearSelection();
2570 const outcomes = await Promise.all(
2571 selectedThreads.map(async (thread) => {
2572 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
2573 const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys });
2574 return { outcome, threadRef };
2575 }),
2576 );
2577 const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) =>
2578 outcome.status === "success" ? [threadRef] : [],
2579 );
2580 const failures = outcomes.flatMap(({ outcome }) =>
2581 outcome.status === "failure" ? [outcome.error] : [],
2582 );
2583
2584 if (snoozedThreadRefs.length > 0) {
2585 const snoozedCount = snoozedThreadRefs.length;
2586 const failedCount = failures.length;
2587 toastManager.add(
2588 stackedThreadToast({
2589 type: failedCount > 0 ? "warning" : "success",
2590 title:
2591 failedCount > 0
2592 ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads`
2593 : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`,
2594 description:
2595 failedCount > 0
2596 ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.`
2597 : undefined,
2598 timeout: 5_000,
2599 actionProps: {
2600 children: "Undo",
2601 onClick: () => {
2602 for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef);
2603 },
2604 },
2605 }),
2606 );
2607 } else if (failures.length > 0) {
2608 const firstError = failures[0];
2609 toastManager.add(
2610 stackedThreadToast({
2611 type: "error",
2612 title: "Failed to snooze threads",
2613 description:
2614 firstError instanceof Error ? firstError.message : "An error occurred.",
2615 }),
2616 );
2617 }
2618 }
2619 return;
2620 }
2621 if (clicked.value === "regenerate-title") {
2622 for (const thread of regeneratableTitleThreads) {
2623 const result = await updateThreadMetadata({
2624 environmentId: thread.environmentId,
2625 input: { threadId: thread.id, regenerateTitle: true },
2626 });
2627 if (result._tag === "Success") continue;
2628 if (!isAtomCommandInterrupted(result)) {
2629 const error = squashAtomCommandFailure(result);
2630 toastManager.add(
2631 stackedThreadToast({
2632 type: "error",
2633 title: "Failed to regenerate thread titles",
2634 description: error instanceof Error ? error.message : "An error occurred.",
2635 }),
2636 );
2637 }
2638 return;
2639 }
2640 clearSelection();
2641 return;
2642 }
2643 if (clicked.value === "settle") {
2644 // Post-settle navigation must skip threads settling in this same
2645 // batch — they are all leaving the card block together. Rows that
2646 // are already explicitly settled are skipped: nothing to do on a
2647 // valid mixed selection. Pinned rows ARE included: the decider
2648 // clears the pin as part of settling, so they park like the rest.
2649 const coSettlingKeys = new Set(threadKeys);
2650 for (const threadKey of threadKeys) {
2651 const thread = threadByKeyRef.current.get(threadKey);
2652 if (!thread || thread.settledOverride === "settled") continue;
2653 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
2654 }
2655 clearSelection();
2656 return;
2657 }
2658 if (clicked.value === "mark-unread") {
2659 for (const threadKey of threadKeys) {
2660 const thread = threadByKeyRef.current.get(threadKey);
2661 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
2662 }
2663 clearSelection();
2664 return;
2665 }
2666 if (clicked.value !== "delete") return;
2667 if (confirmThreadDelete) {
2668 const confirmed = await settlePromise(() =>
2669 api.dialogs.confirm(
2670 [
2671 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
2672 "This permanently clears conversation history for these threads.",
2673 ].join("\n"),
2674 ),
2675 );
2676 if (confirmed._tag === "Failure" || !confirmed.value) return;
2677 }
2678 // Grown as deletions actually land, never seeded with the whole batch:
2679 // orphaned-worktree detection must only discount threads that are
2680 // really gone, or the first delete would treat still-alive batch mates
2681 // as deleted and remove a worktree they still point at.
2682 const deletedThreadKeys = new Set<string>();
2683 for (const threadKey of threadKeys) {
2684 const thread = threadByKeyRef.current.get(threadKey);
2685 if (!thread) continue;
2686 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
2687 deletedThreadKeys,
2688 });
2689 if (result._tag === "Failure") {
2690 if (!isAtomCommandInterrupted(result)) {
2691 const error = squashAtomCommandFailure(result);
2692 toastManager.add(
2693 stackedThreadToast({
2694 type: "error",
2695 title: "Failed to delete threads",
2696 description: error instanceof Error ? error.message : "An error occurred.",
2697 }),
2698 );
2699 }
2700 return;
2701 }
2702 deletedThreadKeys.add(threadKey);
2703 }
2704 removeFromSelection(threadKeys);
2705 },
2706 [
2707 attemptSettle,
2708 attemptSnooze,
2709 clearSelection,
2710 confirmThreadDelete,
2711 deleteThread,
2712 markThreadUnread,
2713 performSnooze,
2714 removeFromSelection,
2715 serverConfigs,
2716 attemptUnsnooze,
2717 updateThreadMetadata,
2718 timestampFormat,
2719 ],
2720 );
2721
2722 const handleThreadContextMenu = useCallback(
2723 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
2724 void (async () => {
2725 const api = readLocalApi();
2726 if (!api) return;
2727 const threadKey = scopedThreadKey(threadRef);
2728 const selectionState = useThreadSelectionStore.getState();
2729 if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) {
2730 await handleMultiSelectContextMenu(position);
2731 return;
2732 }
2733 const thread = threadByKeyRef.current.get(threadKey);
2734 if (!thread) return;
2735 const threadWorkspacePath =
2736 thread.worktreePath ??
2737 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
2738 null;
2739 // Un-settle works on every settled row: for explicit settles it
2740 // clears the override, for auto-settled rows it pins the thread
2741 // active until real activity clears the pin. Environments without
2742 // the settlement capability get no lifecycle items at all.
2743 const supportsSettlement =
2744 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
2745 true;
2746 const supportsSnooze =
2747 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2748 const supportsPinning =
2749 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true;
2750 const supportsTitleRegeneration =
2751 serverConfigs.get(thread.environmentId)?.environment.capabilities
2752 .threadTitleRegeneration === true;
2753 const isRegeneratingTitle = thread.titleRegeneration != null;
2754 const isSettled = settledThreadKeysRef.current.has(threadKey);
2755 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2756 const isPinned = thread.pinnedAt != null;
2757 // Presets resolve at menu-open time (same as the popover).
2758 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2759 const clicked = await settlePromise(() =>
2760 api.contextMenu.show(
2761 buildThreadActionMenuItems({
2762 branch: thread.branch ?? null,
2763 isPinned,
2764 isSettled,
2765 isSnoozed,
2766 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
2767 isRegeneratingTitle,
2768 supports: {
2769 settlement: supportsSettlement,
2770 snooze: supportsSnooze,
2771 pinning: supportsPinning,
2772 titleRegeneration: supportsTitleRegeneration,
2773 },
2774 snoozePresets,
2775 }),
2776 position,
2777 ),
2778 );
2779 if (clicked._tag === "Failure") return;
2780 if (clicked.value?.startsWith("snooze:")) {
2781 const preset = snoozePresets.find(
2782 (candidate) => `snooze:${candidate.id}` === clicked.value,
2783 );
2784 if (preset) attemptSnooze(threadRef, preset);
2785 return;
2786 }
2787 switch (clicked.value) {
2788 case "new-thread-on-branch": {
2789 // Explicit branch carry-over: reuse the thread's worktree when it
2790 // has one, otherwise its branch on the local checkout.
2791 const result = await settlePromise(() =>
2792 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
2793 branch: thread.branch,
2794 worktreePath: thread.worktreePath,
2795 envMode: thread.worktreePath ? "worktree" : "local",
2796 startFromOrigin: false,
2797 }),
2798 );
2799 if (result._tag === "Failure") {
2800 const error = squashAtomCommandFailure(result);
2801 toastManager.add(
2802 stackedThreadToast({
2803 type: "error",
2804 title: "Could not create thread",
2805 description: error instanceof Error ? error.message : "An error occurred.",
2806 }),
2807 );
2808 }
2809 return;
2810 }
2811 case "settle":
2812 attemptSettle(threadRef);
2813 return;
2814 case "unsettle":
2815 attemptUnsettle(threadRef);
2816 return;
2817 case "unsnooze":
2818 attemptUnsnooze(threadRef);
2819 return;
2820 case "pin":
2821 attemptPin(threadRef);
2822 return;
2823 case "unpin":
2824 attemptUnpin(threadRef);
2825 return;
2826 case "rename":
2827 startThreadRename(threadRef, thread.title);
2828 return;
2829 case "regenerate-title": {
2830 if (isRegeneratingTitle) return;
2831 const result = await updateThreadMetadata({
2832 environmentId: threadRef.environmentId,
2833 input: { threadId: threadRef.threadId, regenerateTitle: true },
2834 });
2835 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2836 const error = squashAtomCommandFailure(result);
2837 toastManager.add(
2838 stackedThreadToast({
2839 type: "error",
2840 title: "Failed to regenerate thread title",
2841 description: error instanceof Error ? error.message : "An error occurred.",
2842 }),
2843 );
2844 }
2845 return;
2846 }
2847 case "mark-unread":
2848 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2849 return;
2850 case "copy-path":
2851 if (!threadWorkspacePath) {
2852 toastManager.add(
2853 stackedThreadToast({
2854 type: "error",
2855 title: "Path unavailable",
2856 description: "This thread does not have a workspace path to copy.",
2857 }),
2858 );
2859 return;
2860 }
2861 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2862 return;
2863 case "copy-branch":
2864 if (thread.branch) {
2865 copyBranchToClipboard(thread.branch, { branch: thread.branch });
2866 }
2867 return;
2868 case "delete": {
2869 if (confirmThreadDelete) {
2870 const confirmed = await settlePromise(() =>
2871 api.dialogs.confirm(
2872 [
2873 `Delete thread "${thread.title}"?`,
2874 "This permanently clears conversation history for this thread.",
2875 ].join("\n"),
2876 ),
2877 );
2878 if (confirmed._tag === "Failure" || !confirmed.value) return;
2879 }
2880 const result = await deleteThread(threadRef);
2881 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2882 const error = squashAtomCommandFailure(result);
2883 toastManager.add(
2884 stackedThreadToast({
2885 type: "error",
2886 title: "Failed to delete thread",
2887 description: error instanceof Error ? error.message : "An error occurred.",
2888 }),
2889 );
2890 return;
2891 }
2892 return;
2893 }
2894 default:
2895 return;
2896 }
2897 })();
2898 },
2899 [
2900 attemptPin,
2901 attemptSettle,
2902 attemptSnooze,
2903 attemptUnpin,
2904 attemptUnsettle,
2905 attemptUnsnooze,
2906 confirmThreadDelete,
2907 copyBranchToClipboard,
2908 copyPathToClipboard,
2909 deleteThread,
2910 handleMultiSelectContextMenu,
2911 markThreadUnread,
2912 projectCwdByKey,
2913 serverConfigs,
2914 startThreadRename,
2915 updateThreadMetadata,
2916 timestampFormat,
2917 ],
2918 );
2919
2920 // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
2921 // v1 — the keybinding layer is shared, only the ordered list differs.
2922 const routeTerminalOpen = useTerminalUiStateStore((state) =>
2923 routeThreadRef
2924 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
2925 : false,
2926 );
2927 useEffect(() => {
2928 const onWindowKeyDown = (event: KeyboardEvent) => {
2929 if (event.defaultPrevented || event.repeat) return;
2930 const command = resolveShortcutCommand(event, keybindings, {
2931 platform: navigator.platform,
2932 context: {
2933 terminalFocus: isTerminalFocused(),
2934 terminalOpen: routeTerminalOpen,
2935 modelPickerOpen: isModelPickerOpen(),
2936 },
2937 });
2938 const navigateToThreadKey = (targetThreadKey: string | null) => {
2939 if (!targetThreadKey) return false;
2940 const targetThread = threadByKey.get(targetThreadKey);
2941 if (!targetThread) return false;
2942 event.preventDefault();
2943 event.stopPropagation();
2944 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
2945 return true;
2946 };
2947 const traversalDirection = threadTraversalDirectionFromCommand(command);
2948 if (traversalDirection !== null) {
2949 navigateToThreadKey(
2950 resolveAdjacentThreadId({
2951 threadIds: orderedThreadKeys,
2952 currentThreadId: routeThreadKey,
2953 direction: traversalDirection,
2954 }),
2955 );
2956 return;
2957 }
2958 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
2959 if (jumpIndex === null) return;
2960 navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null);
2961 };
2962 window.addEventListener("keydown", onWindowKeyDown);
2963 return () => window.removeEventListener("keydown", onWindowKeyDown);
2964 }, [
2965 keybindings,
2966 navigateToThread,
2967 orderedThreadKeys,
2968 routeTerminalOpen,
2969 routeThreadKey,
2970 threadByKey,
2971 ]);
2972
2973 // Same predicate as v1: hints show only while the held modifiers exactly
2974 // match a thread-jump binding. Adding Shift (screenshots) or Alt no
2975 // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4.
2976 const shortcutModifiers = useShortcutModifierState();
2977 const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
2978 shortcutModifiers,
2979 keybindings,
2980 { platform: navigator.platform },
2981 );
2982 useEffect(() => {
2983 setShowJumpHints(shouldShowJumpHintsNow);
2984 }, [shouldShowJumpHintsNow]);
2985
2986 const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
2987 if (!node) return;
2988 autoAnimate(node, { duration: 150, easing: "ease-out" });
2989 }, []);
2990
2991 // New thread defaults to the project you're in (active thread's project,
2992 // falling back to the top project) — same resolution the command palette
2993 // uses. The command palette already offers a "New thread in..." submenu
2994 // for multi-project setups.
2995 const handleNewThreadClick = useCallback(() => {
2996 // One project: nothing to pick, create immediately.
2997 if (projectGroups.length <= 1) {
2998 if (isMobile) setOpenMobile(false);
2999 void startNewThreadFromContext({
3000 activeDraftThread: newThreadContext.activeDraftThread,
3001 activeThread: newThreadContext.activeThread ?? undefined,
3002 defaultProjectRef: newThreadContext.defaultProjectRef,
3003 handleNewThread: newThreadContext.handleNewThread,
3004 });
3005 return;
3006 }
3007 if (isMobile) setOpenMobile(false);
3008 openCommandPalette({ open: "new-thread-in" });
3009 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
3010
3011 // The button mirrors chat.new: in multi-project setups both route through
3012 // the command palette's "New thread in..." picker, and in single-project
3013 // setups both create immediately. chat.newLocal always creates directly, so
3014 // it is only a correct label when chat.new is unbound.
3015 const newThreadShortcutLabel =
3016 shortcutLabelForCommand(keybindings, "chat.new") ??
3017 shortcutLabelForCommand(keybindings, "chat.newLocal");
3018 return (
3019 <>
3020 <SidebarChromeHeader isElectron={isElectron} />
3021 <SidebarContent
3022 className="gap-0"
3023 fixedHeader={
3024 // Lifted above the stage backdrop, whose fade bleeds below the
3025 // header and would otherwise paint across the search row's outline.
3026 <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]">
3027 <div className="flex items-center gap-1">
3028 <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
3029 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
3030 <Input
3031 ref={threadSearchInputRef}
3032 nativeInput
3033 unstyled
3034 type="search"
3035 value={threadSearchQuery}
3036 onChange={(event) => {
3037 setThreadSearchQuery(event.currentTarget.value);
3038 setActiveSearchResultIndex(0);
3039 }}
3040 onKeyDown={handleThreadSearchKeyDown}
3041 placeholder="Search"
3042 aria-label="Search threads"
3043 role="combobox"
3044 aria-autocomplete="list"
3045 aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
3046 aria-controls={
3047 isSearchingThreads && threadSearchResults.length > 0
3048 ? "sidebar-thread-search-results"
3049 : undefined
3050 }
3051 aria-activedescendant={
3052 isSearchingThreads && threadSearchResults[activeSearchResultIndex]
3053 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
3054 : undefined
3055 }
3056 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"
3057 />
3058 {isSearchingThreads ? (
3059 <Button
3060 type="button"
3061 size="icon-xs"
3062 variant="ghost"
3063 className="size-5 shrink-0 rounded-sm text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
3064 aria-label="Clear thread search"
3065 onClick={() => {
3066 clearThreadSearch();
3067 threadSearchInputRef.current?.focus();
3068 }}
3069 >
3070 <XIcon className="size-3" />
3071 </Button>
3072 ) : null}
3073 </div>
3074 <div className="shrink-0">
3075 <Tooltip>
3076 <TooltipTrigger
3077 render={
3078 <SidebarMenuButton
3079 size="icon"
3080 type="button"
3081 className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3082 onClick={handleNewThreadClick}
3083 disabled={projects.length === 0}
3084 aria-label="New thread"
3085 />
3086 }
3087 >
3088 <SquarePenIcon />
3089 <span
3090 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
3091 aria-hidden="true"
3092 />
3093 </TooltipTrigger>
3094 <TooltipPopup side="right">
3095 {newThreadShortcutLabel
3096 ? `New thread (${newThreadShortcutLabel})`
3097 : "New thread"}
3098 </TooltipPopup>
3099 </Tooltip>
3100 </div>
3101 </div>
3102 {projectGroups.length > 0 ? (
3103 <div className="flex items-center gap-1">
3104 <Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}>
3105 <MenuTrigger
3106 render={
3107 <SidebarMenuButton
3108 aria-label="Filter threads by project"
3109 className="min-w-0 flex-1 ps-[calc(var(--sidebar-row-content-inset)-1px)] focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3110 />
3111 }
3112 >
3113 {scopedProjectGroup ? (
3114 <ProjectFavicon
3115 environmentId={scopedProjectGroup.environmentId}
3116 cwd={scopedProjectGroup.workspaceRoot}
3117 className="size-4 shrink-0"
3118 />
3119 ) : (
3120 <FolderIcon className="size-4 shrink-0" />
3121 )}
3122 <span className="min-w-0 flex-1 truncate">
3123 {scopedProjectGroup?.displayName ?? "All projects"}
3124 </span>
3125 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
3126 </MenuTrigger>
3127 <MenuPopup align="start" className="w-(--anchor-width)">
3128 <MenuRadioGroup
3129 value={projectScopeKey ?? "all"}
3130 onValueChange={(value) =>
3131 setProjectScopeKey(value === "all" ? null : (value as string))
3132 }
3133 >
3134 <MenuRadioItem
3135 value="all"
3136 closeOnClick
3137 className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
3138 >
3139 <FolderIcon className="size-4 shrink-0" />
3140 <span className="min-w-0 truncate text-sm">All projects</span>
3141 </MenuRadioItem>
3142 {projectGroups.map((project) => {
3143 const scopeKey = project.projectKey;
3144 return (
3145 <MenuRadioItem
3146 key={scopeKey}
3147 value={scopeKey}
3148 closeOnClick
3149 className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
3150 >
3151 <ProjectFavicon
3152 environmentId={project.environmentId}
3153 cwd={project.workspaceRoot}
3154 className="size-4 shrink-0"
3155 />
3156 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
3157 <button
3158 type="button"
3159 aria-label={`Project actions for ${project.displayName}`}
3160 title={`Project actions for ${project.displayName}`}
3161 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
3162 onPointerDown={(event) => event.stopPropagation()}
3163 onClick={(event) => {
3164 void handleProjectActions(event, project);
3165 }}
3166 >
3167 <EllipsisIcon className="size-3.5" />
3168 </button>
3169 </MenuRadioItem>
3170 );
3171 })}
3172 </MenuRadioGroup>
3173 </MenuPopup>
3174 </Menu>
3175 <Tooltip>
3176 <TooltipTrigger
3177 render={
3178 <SidebarMenuButton
3179 size="icon"
3180 className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
3181 onClick={openAddProjectCommandPalette}
3182 type="button"
3183 aria-label="New project"
3184 />
3185 }
3186 >
3187 <FolderPlusIcon />
3188 <span
3189 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
3190 aria-hidden="true"
3191 />
3192 </TooltipTrigger>
3193 <TooltipPopup side="right">New project</TooltipPopup>
3194 </Tooltip>
3195 </div>
3196 ) : null}
3197 </SidebarGroup>
3198 }
3199 >
3200 <SidebarGroup className="ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0">
3201 {isSearchingThreads ? (
3202 threadSearchResults.length > 0 ? (
3203 <TooltipProvider
3204 key="sidebar-thread-search-tooltips-150"
3205 delay={150}
3206 closeDelay={0}
3207 timeout={400}
3208 >
3209 <ul
3210 id="sidebar-thread-search-results"
3211 role="listbox"
3212 aria-label="Thread search results"
3213 className="flex flex-col gap-px"
3214 >
3215 {threadSearchResults.map((thread, index) => {
3216 const threadKey = scopedThreadKey(
3217 scopeThreadRef(thread.environmentId, thread.id),
3218 );
3219 return (
3220 <SidebarV2SearchResultRow
3221 key={threadKey}
3222 thread={thread}
3223 projectCwd={
3224 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3225 }
3226 projectTitle={
3227 projectDisplayNameByKey.get(
3228 `${thread.environmentId}:${thread.projectId}`,
3229 ) ?? null
3230 }
3231 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3232 providerEntryByInstanceId={providerEntryByInstanceId}
3233 isHighlighted={activeSearchResultIndex === index}
3234 isRouteActive={routeThreadKey === threadKey}
3235 resultId={`sidebar-thread-search-result-${index}`}
3236 onHighlight={() => setActiveSearchResultIndex(index)}
3237 onSelect={() => selectThreadSearchResult(thread)}
3238 />
3239 );
3240 })}
3241 </ul>
3242 </TooltipProvider>
3243 ) : (
3244 <p
3245 role="status"
3246 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
3247 >
3248 No threads found
3249 </p>
3250 )
3251 ) : null}
3252 {!isSearchingThreads ? (
3253 <TooltipProvider
3254 key="sidebar-thread-tooltips-150"
3255 delay={150}
3256 closeDelay={0}
3257 timeout={400}
3258 >
3259 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
3260 {(() => {
3261 const renderThreadRow = (
3262 thread: EnvironmentThreadShell,
3263 section: "pinned" | "active" | "snoozed" | "settled",
3264 sortable?: SortablePinnedRowBag,
3265 ) => {
3266 const threadKey = scopedThreadKey(
3267 scopeThreadRef(thread.environmentId, thread.id),
3268 );
3269 // Settled and snoozed are the ONLY things that collapse a
3270 // row: every other thread is a full card. Density comes
3271 // from users (or the auto rules) actually parking work,
3272 // not from the sidebar second-guessing what still matters.
3273 const isCard = section === "active" || section === "pinned";
3274 const rowVariant = isCard ? "card" : "slim";
3275 return (
3276 <SidebarV2Row
3277 // Keyed per variant on purpose: when a thread settles,
3278 // the card fades out in place and the slim row fades
3279 // in at its settled position instead of one element
3280 // FLIP-sliding through every row in between (rows here
3281 // are translucent, so a crossing row reads as text
3282 // painted over text).
3283 key={`${threadKey}:${rowVariant}`}
3284 thread={thread}
3285 variant={rowVariant}
3286 // Snoozed rows wake; settled rows un-settle (explicit
3287 // settles clear the override, auto-settled rows get
3288 // pinned active); cards settle.
3289 variantAction={
3290 section === "snoozed"
3291 ? "unsnooze"
3292 : section === "settled"
3293 ? "unsettle"
3294 : "settle"
3295 }
3296 settlementSupported={
3297 serverConfigs.get(thread.environmentId)?.environment.capabilities
3298 .threadSettlement === true
3299 }
3300 snoozeSupported={
3301 serverConfigs.get(thread.environmentId)?.environment.capabilities
3302 .threadSnooze === true
3303 }
3304 pinningSupported={
3305 serverConfigs.get(thread.environmentId)?.environment.capabilities
3306 .threadPinning === true
3307 }
3308 isPinned={section === "pinned"}
3309 sortable={sortable}
3310 snoozeWakeLabelText={
3311 section === "snoozed" && thread.snoozedUntil != null
3312 ? snoozeWakeLabel(thread.snoozedUntil, {
3313 now: new Date().toISOString(),
3314 })
3315 : null
3316 }
3317 // All sections: a woken thread can classify straight
3318 // into the settled tail (PR merged while snoozed), and
3319 // the wake signal must survive the trip. Still-snoozed
3320 // rows resolve to null on their own.
3321 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
3322 isActive={routeThreadKey === threadKey}
3323 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
3324 currentEnvironmentId={primaryEnvironmentId}
3325 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3326 projectCwd={
3327 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
3328 }
3329 projectTitle={
3330 projectDisplayNameByKey.get(
3331 `${thread.environmentId}:${thread.projectId}`,
3332 ) ?? null
3333 }
3334 providerEntryByInstanceId={providerEntryByInstanceId}
3335 timestampFormat={timestampFormat}
3336 onThreadClick={handleThreadClick}
3337 onThreadActivate={navigateToThread}
3338 onStartRename={startThreadRename}
3339 onRenameTitleChange={setRenamingTitle}
3340 onCommitRename={commitThreadRename}
3341 onCancelRename={cancelThreadRename}
3342 isRenaming={renamingThreadKey === threadKey}
3343 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
3344 onContextMenu={handleThreadContextMenu}
3345 onSettle={attemptSettle}
3346 onUnsettle={attemptUnsettle}
3347 onSnooze={attemptSnooze}
3348 onUnsnooze={attemptUnsnooze}
3349 onUnpin={attemptUnpin}
3350 onAcknowledgeWoke={acknowledgeWoke}
3351 onChangeRequestState={handleChangeRequestState}
3352 />
3353 );
3354 };
3355 // Pinned block: full cards above the inbox, closed by a
3356 // thin divider (the pin glyphs carry the meaning, so no
3357 // header text). Vanishes entirely at count 0.
3358 // Rows render in the one shared pinned order; only
3359 // reorder-capable rows register as sortable (legacy-server
3360 // pins render in place as plain rows).
3361 const items: ReactNode[] = [
3362 <DndContext
3363 key="pinned-dnd"
3364 sensors={pinnedDndSensors}
3365 collisionDetection={closestCenter}
3366 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
3367 onDragEnd={handlePinnedDragEnd}
3368 >
3369 <SortableContext
3370 items={orderedPinnedThreads
3371 .map((thread) =>
3372 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3373 )
3374 .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
3375 strategy={verticalListSortingStrategy}
3376 >
3377 {orderedPinnedThreads.map((thread) => {
3378 const threadKey = scopedThreadKey(
3379 scopeThreadRef(thread.environmentId, thread.id),
3380 );
3381 if (!reorderablePinnedKeys.has(threadKey)) {
3382 return renderThreadRow(thread, "pinned");
3383 }
3384 return (
3385 <SortablePinnedThreadRow key={threadKey} id={threadKey}>
3386 {(bag) => renderThreadRow(thread, "pinned", bag)}
3387 </SortablePinnedThreadRow>
3388 );
3389 })}
3390 </SortableContext>
3391 </DndContext>,
3392 ];
3393 if (pinnedThreads.length > 0) {
3394 items.push(
3395 <li
3396 key="pinned-divider"
3397 aria-hidden
3398 data-testid="sidebar-v2-pinned-divider"
3399 className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
3400 />,
3401 );
3402 }
3403 for (const thread of activeThreads) {
3404 items.push(renderThreadRow(thread, "active"));
3405 }
3406 // Snoozed shelf: between the inbox and Settled — out of the
3407 // way, never gone. The header always renders while anything
3408 // is snoozed (the count is the whole footprint when
3409 // collapsed); rows only when expanded. Vanishes entirely at
3410 // count 0.
3411 if (snoozedThreads.length > 0) {
3412 items.push(
3413 <li
3414 key="snoozed-shelf-header"
3415 data-thread-selection-safe
3416 className="list-none"
3417 >
3418 <button
3419 type="button"
3420 onClick={toggleSnoozedShelf}
3421 aria-expanded={snoozedShelfExpanded}
3422 data-testid="sidebar-v2-snoozed-shelf-toggle"
3423 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
3424 >
3425 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
3426 {snoozedShelfExpanded
3427 ? "Snoozed"
3428 : `Snoozed (${snoozedThreads.length})`}
3429 </span>
3430 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
3431 <ChevronDownIcon
3432 aria-hidden
3433 className={cn(
3434 "size-3 text-blue-600 transition-transform dark:text-blue-400",
3435 snoozedShelfExpanded && "rotate-180",
3436 )}
3437 />
3438 </button>
3439 </li>,
3440 );
3441 for (const thread of visibleSnoozedThreads) {
3442 items.push(renderThreadRow(thread, "snoozed"));
3443 }
3444 }
3445 if (settledThreads.length > 0) {
3446 items.push(
3447 <li
3448 key="settled-shelf-header"
3449 data-thread-selection-safe
3450 className="list-none"
3451 >
3452 <button
3453 type="button"
3454 onClick={toggleSettledShelf}
3455 aria-expanded={settledShelfExpanded}
3456 data-testid="sidebar-v2-settled-shelf-toggle"
3457 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
3458 >
3459 <span className="text-xs font-medium text-muted-foreground/50">
3460 {settledShelfExpanded
3461 ? "Settled"
3462 : `Settled (${settledThreads.length})`}
3463 </span>
3464 <span className="h-px flex-1 bg-sidebar-border/60" />
3465 <ChevronDownIcon
3466 aria-hidden
3467 className={cn(
3468 "size-3 text-muted-foreground/50 transition-transform",
3469 settledShelfExpanded && "rotate-180",
3470 )}
3471 />
3472 </button>
3473 </li>,
3474 );
3475 }
3476 for (const thread of renderedSettledThreads) {
3477 items.push(renderThreadRow(thread, "settled"));
3478 }
3479 return items;
3480 })()}
3481 {settledShelfExpanded && hiddenSettledCount > 0 ? (
3482 <li className="list-none">
3483 <button
3484 type="button"
3485 onClick={showMoreSettled}
3486 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
3487 >
3488 <PlusIcon aria-hidden className="size-4 shrink-0" />
3489 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
3490 </button>
3491 </li>
3492 ) : null}
3493 </ul>
3494 </TooltipProvider>
3495 ) : null}
3496 {!isSearchingThreads &&
3497 pinnedThreads.length +
3498 activeThreads.length +
3499 snoozedThreads.length +
3500 settledThreads.length ===
3501 0 ? (
3502 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
3503 {projects.length === 0 ? (
3504 <>
3505 <span>No projects yet</span>
3506 <button
3507 type="button"
3508 onClick={openAddProjectCommandPalette}
3509 className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
3510 >
3511 <PlusIcon className="-mx-0.5 size-3" />
3512 Add project
3513 </button>
3514 </>
3515 ) : scopedProjectGroup ? (
3516 `No threads in ${scopedProjectGroup.displayName} yet`
3517 ) : (
3518 "No threads yet"
3519 )}
3520 </div>
3521 ) : null}
3522 </SidebarGroup>
3523 </SidebarContent>
3524 <Dialog
3525 open={projectActionsTarget !== null}
3526 onOpenChange={(open) => {
3527 if (!open) setProjectActionsTarget(null);
3528 }}
3529 >
3530 <DialogPopup className="max-w-xl">
3531 <DialogHeader className="gap-3 pb-1!">
3532 <DialogTitle className="text-balance">Project settings</DialogTitle>
3533 <DialogDescription className="sr-only">
3534 Manage project names, grouping rules, and environments.
3535 </DialogDescription>
3536 <div className="grid gap-1.5 text-base text-muted-foreground">
3537 {projectActionsTarget?.memberProjects.map((member) => (
3538 <div key={member.physicalProjectKey} className="flex min-w-0 items-center gap-3">
3539 <span className="flex min-w-0 items-center gap-1">
3540 <FolderIcon className="size-3.5 shrink-0 opacity-60" />
3541 <span className="min-w-0 truncate font-mono">{member.workspaceRoot}</span>
3542 <Button
3543 size="icon-xs"
3544 variant="ghost"
3545 className="size-4 shrink-0 rounded-sm"
3546 aria-label="Copy project path"
3547 title="Copy project path"
3548 onClick={() =>
3549 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
3550 }
3551 >
3552 <CopyIcon className="size-3.5" />
3553 </Button>
3554 </span>
3555 <span className="flex min-w-0 shrink-0 items-center gap-1">
3556 <ServerIcon className="size-3.5 shrink-0 opacity-60" />
3557 <span className="min-w-0 truncate">
3558 {member.environmentLabel ?? "Current environment"}
3559 </span>
3560 </span>
3561 </div>
3562 ))}
3563 </div>
3564 </DialogHeader>
3565 <DialogPanel className="p-0">
3566 <div className="divide-y divide-border/60">
3567 {projectActionsTarget?.memberProjects.map((member) => (
3568 <section
3569 key={member.physicalProjectKey}
3570 className="grid min-w-0 gap-5 px-6 pb-5 pt-2 sm:gap-4 sm:pb-4 sm:pt-2"
3571 >
3572 <div className="grid gap-4 sm:grid-cols-2 sm:gap-3">
3573 <label className="grid min-w-0 gap-1.5">
3574 <span className="font-medium text-foreground">Project name</span>
3575 <Input
3576 key={`${member.physicalProjectKey}:${member.title}`}
3577 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
3578 defaultValue={member.title}
3579 onBlur={(event) => {
3580 void renameProjectMember(member, event.currentTarget.value);
3581 }}
3582 onKeyDown={(event) => {
3583 if (event.key === "Enter") event.currentTarget.blur();
3584 }}
3585 />
3586 </label>
3587 <label className="grid min-w-0 gap-1.5">
3588 <span className="font-medium text-foreground">Grouping rule</span>
3589 <Select
3590 value={
3591 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3592 deriveProjectGroupingOverrideKey(member)
3593 ] ?? "inherit"
3594 }
3595 onValueChange={(value) => {
3596 if (
3597 value === "inherit" ||
3598 value === "repository" ||
3599 value === "repository_path" ||
3600 value === "separate"
3601 ) {
3602 updateProjectGroupingPreference(member, value);
3603 }
3604 }}
3605 >
3606 <SelectTrigger
3607 className="w-full sm:min-h-7.5"
3608 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
3609 >
3610 <SelectValue>
3611 {(() => {
3612 const selection =
3613 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3614 deriveProjectGroupingOverrideKey(member)
3615 ] ?? "inherit";
3616 return selection === "inherit"
3617 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
3618 : PROJECT_GROUPING_MODE_LABELS[selection];
3619 })()}
3620 </SelectValue>
3621 </SelectTrigger>
3622 <SelectPopup align="start" alignItemWithTrigger={false}>
3623 <SelectItem hideIndicator value="inherit">
3624 Use global default
3625 </SelectItem>
3626 <SelectItem hideIndicator value="repository">
3627 {PROJECT_GROUPING_MODE_LABELS.repository}
3628 </SelectItem>
3629 <SelectItem hideIndicator value="repository_path">
3630 {PROJECT_GROUPING_MODE_LABELS.repository_path}
3631 </SelectItem>
3632 <SelectItem hideIndicator value="separate">
3633 {PROJECT_GROUPING_MODE_LABELS.separate}
3634 </SelectItem>
3635 </SelectPopup>
3636 </Select>
3637 </label>
3638 </div>
3639 {projectActionsTarget.memberProjects.length > 1 ? (
3640 <div className="flex justify-end">
3641 <Button
3642 size="sm"
3643 variant="ghost"
3644 className="text-destructive-foreground hover:bg-destructive/8 hover:text-destructive-foreground"
3645 onClick={() => {
3646 const projectGroup = projectActionsTarget;
3647 setProjectActionsTarget(null);
3648 void handleRemoveProjectMembers(projectGroup, [member]);
3649 }}
3650 >
3651 <Trash2Icon />
3652 Remove project
3653 </Button>
3654 </div>
3655 ) : null}
3656 </section>
3657 ))}
3658 </div>
3659 {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? (
3660 <div className="flex flex-col gap-3 border-t border-border/60 bg-muted/32 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
3661 <div className="min-w-0">
3662 <p className="text-base font-medium text-foreground sm:text-sm">
3663 Remove this project everywhere
3664 </p>
3665 <p className="text-base text-pretty text-muted-foreground sm:text-sm">
3666 Deletes all grouped entries and their conversation history.
3667 </p>
3668 </div>
3669 <Button
3670 size="sm"
3671 variant="destructive-outline"
3672 className="shrink-0"
3673 onClick={() => {
3674 const projectGroup = projectActionsTarget;
3675 setProjectActionsTarget(null);
3676 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3677 }}
3678 >
3679 <Trash2Icon />
3680 Remove all entries
3681 </Button>
3682 </div>
3683 ) : null}
3684 </DialogPanel>
3685 <DialogFooter
3686 variant="bare"
3687 className={cn(
3688 projectActionsTarget?.memberProjects.length === 1 && "sm:justify-between",
3689 )}
3690 >
3691 {projectActionsTarget?.memberProjects.length === 1 ? (
3692 <Button
3693 variant="destructive-outline"
3694 onClick={() => {
3695 const projectGroup = projectActionsTarget;
3696 setProjectActionsTarget(null);
3697 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3698 }}
3699 >
3700 <Trash2Icon />
3701 Remove project
3702 </Button>
3703 ) : null}
3704 <Button onClick={() => setProjectActionsTarget(null)}>Close</Button>
3705 </DialogFooter>
3706 </DialogPopup>
3707 </Dialog>
3708 <SidebarChromeFooter />
3709 </>
3710 );
3711}
apps/web/src/components/settings/BetaSettingsPanel.tsx deleted-120
......@@ -1,120 +0,0 @@
1import { useEffect, useState } from "react";
2
3import {
4 useClientSettings,
5 useSidebarV2Enabled,
6 useUpdateClientSettings,
7} from "../../hooks/useSettings";
8import { Input } from "../ui/input";
9import { Switch } from "../ui/switch";
10import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
11import { searchableSetting } from "./settingsSearch";
12
13const AUTO_SETTLE_MIN_DAYS = 1;
14const AUTO_SETTLE_MAX_DAYS = 90;
15const AUTO_SETTLE_DEFAULT_DAYS = 3;
16
17function AutoSettleDaysInput({
18 value,
19 onCommit,
20}: {
21 value: number;
22 onCommit: (days: number) => void;
23}) {
24 // Local draft so the field can be emptied mid-edit; the setting only moves
25 // on valid input and snaps back to the persisted value on blur.
26 const [draft, setDraft] = useState(String(value));
27 useEffect(() => {
28 setDraft(String(value));
29 }, [value]);
30
31 return (
32 <Input
33 type="number"
34 min={AUTO_SETTLE_MIN_DAYS}
35 max={AUTO_SETTLE_MAX_DAYS}
36 className="w-full sm:w-24"
37 value={draft}
38 onChange={(event) => {
39 setDraft(event.target.value);
40 // Number(), not parseInt: "3.5" must be rejected (not truncated to a
41 // committed 3 while the field shows 3.5) — commit only when the
42 // persisted value matches the displayed one.
43 const parsed = Number(event.target.value);
44 if (
45 Number.isInteger(parsed) &&
46 parsed >= AUTO_SETTLE_MIN_DAYS &&
47 parsed <= AUTO_SETTLE_MAX_DAYS
48 ) {
49 onCommit(parsed);
50 }
51 }}
52 onBlur={() => setDraft(String(value))}
53 aria-label="Days of inactivity before auto-settle"
54 />
55 );
56}
57
58export function BetaSettingsPanel() {
59 const sidebarV2Enabled = useSidebarV2Enabled();
60 const sidebarAutoSettleAfterDays = useClientSettings(
61 (settings) => settings.sidebarAutoSettleAfterDays,
62 );
63 const updateSettings = useUpdateClientSettings();
64
65 return (
66 <SettingsPageContainer>
67 <SettingsSection title="Beta features">
68 <SettingsRow
69 {...searchableSetting("sidebar-v2")}
70 description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time."
71 control={
72 <Switch
73 checked={sidebarV2Enabled}
74 // Touching the switch pins the choice, so a nightly build that
75 // defaults v2 on does not flip it back after the user opts out.
76 onCheckedChange={(checked) =>
77 updateSettings({
78 sidebarV2Enabled: Boolean(checked),
79 sidebarV2ConfiguredByUser: true,
80 })
81 }
82 aria-label="Enable the sidebar v2 beta"
83 />
84 }
85 />
86 {sidebarV2Enabled ? (
87 <>
88 <SettingsRow
89 title={searchableSetting("auto-settle-inactive-threads").title}
90 description="Threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle."
91 control={
92 <Switch
93 checked={sidebarAutoSettleAfterDays !== null}
94 onCheckedChange={(checked) =>
95 updateSettings({
96 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
97 })
98 }
99 aria-label="Auto-settle inactive threads"
100 />
101 }
102 />
103 {sidebarAutoSettleAfterDays !== null ? (
104 <SettingsRow
105 title="Days of inactivity before auto-settle"
106 description="Any new activity un-settles a thread automatically."
107 control={
108 <AutoSettleDaysInput
109 value={sidebarAutoSettleAfterDays}
110 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
111 />
112 }
113 />
114 ) : null}
115 </>
116 ) : null}
117 </SettingsSection>
118 </SettingsPageContainer>
119 );
120}
apps/web/src/components/settings/SettingsPanels.tsx+107-1
......@@ -24,11 +24,13 @@ import {
2424 MAX_GLASS_OPACITY,
2525 MAX_INTERFACE_FONT_SIZE,
2626 MAX_PROMPT_FONT_SIZE,
27 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
2728 MAX_TERMINAL_FONT_SIZE,
2829 MIN_CODE_FONT_SIZE,
2930 MIN_GLASS_OPACITY,
3031 MIN_INTERFACE_FONT_SIZE,
3132 MIN_PROMPT_FONT_SIZE,
33 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
3234 MIN_TERMINAL_FONT_SIZE,
3335} from "@t3tools/contracts/settings";
3436import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
......@@ -463,6 +465,10 @@ export function useSettingsRestore(onRestored?: () => void) {
463465 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
464466 ? ["Project Grouping"]
465467 : []),
468 ...(settings.sidebarAutoSettleAfterDays !==
469 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
470 ? ["Auto-settle inactive threads"]
471 : []),
466472 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
467473 ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans
468474 ? ["Interface font"]
......@@ -525,6 +531,7 @@ export function useSettingsRestore(onRestored?: () => void) {
525531 settings.glassOpacity,
526532 settings.enableLegacyTokenStreaming,
527533 settings.enableProviderUpdateChecks,
534 settings.sidebarAutoSettleAfterDays,
528535 settings.sidebarProjectGroupingMode,
529536 settings.sidebarThreadPreviewCount,
530537 settings.timestampFormat,
......@@ -603,6 +610,7 @@ export function useSettingsRestore(onRestored?: () => void) {
603610 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
604611 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
605612 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
613 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
606614 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
607615 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
608616 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -1506,11 +1514,55 @@ function FontFamilySettingsRow({
15061514 );
15071515}
15081516
1509// Both legacy rows sit behind the fold, so a settings-search jump has to
1517const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
1518
1519function AutoSettleDaysInput({
1520 value,
1521 onCommit,
1522}: {
1523 value: number;
1524 onCommit: (days: number) => void;
1525}) {
1526 // Local draft so the field can be emptied mid-edit; the setting only moves
1527 // on valid input and snaps back to the persisted value on blur.
1528 const [draft, setDraft] = useState(String(value));
1529 useEffect(() => {
1530 setDraft(String(value));
1531 }, [value]);
1532
1533 return (
1534 <Input
1535 type="number"
1536 min={MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
1537 max={MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
1538 className="w-full sm:w-24"
1539 value={draft}
1540 onChange={(event) => {
1541 setDraft(event.target.value);
1542 // Number(), not parseInt: "3.5" must be rejected (not truncated to a
1543 // committed 3 while the field shows 3.5) — commit only when the
1544 // persisted value matches the displayed one.
1545 const parsed = Number(event.target.value);
1546 if (
1547 Number.isInteger(parsed) &&
1548 parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
1549 parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
1550 ) {
1551 onCommit(parsed);
1552 }
1553 }}
1554 onBlur={() => setDraft(String(value))}
1555 aria-label="Days of inactivity before auto-settle"
1556 />
1557 );
1558}
1559
1560// The legacy rows sit behind the fold, so a settings-search jump has to
15101561// expand the section before its target can mount and scroll.
15111562const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
15121563 "legacy-plan-mode",
15131564 "legacy-token-streaming",
1565 "legacy-sidebar",
15141566]);
15151567
15161568/**
......@@ -1589,6 +1641,19 @@ function LegacyFeaturesSection() {
15891641 />
15901642 }
15911643 />
1644 <SettingsRow
1645 {...searchableSetting("legacy-sidebar")}
1646 description="Brings back the original sidebar with per-project thread trees. The default sidebar shows one flat list: active work as rich cards, settled threads as compact rows."
1647 control={
1648 <Switch
1649 checked={settings.legacySidebarEnabled}
1650 onCheckedChange={(checked) =>
1651 updateSettings({ legacySidebarEnabled: Boolean(checked) })
1652 }
1653 aria-label="Sidebar (legacy)"
1654 />
1655 }
1656 />
15921657 </div>
15931658 </CollapsiblePanel>
15941659 </Collapsible>
......@@ -1688,6 +1753,47 @@ export function GeneralSettingsPanel() {
16881753 }
16891754 />
16901755
1756 <SettingsRow
1757 {...searchableSetting("auto-settle-inactive-threads")}
1758 description="Sidebar threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle."
1759 resetAction={
1760 settings.sidebarAutoSettleAfterDays !==
1761 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1762 <SettingResetButton
1763 label="auto-settle"
1764 onClick={() =>
1765 updateSettings({
1766 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1767 })
1768 }
1769 />
1770 ) : null
1771 }
1772 control={
1773 <Switch
1774 checked={settings.sidebarAutoSettleAfterDays !== null}
1775 onCheckedChange={(checked) =>
1776 updateSettings({
1777 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
1778 })
1779 }
1780 aria-label="Auto-settle inactive threads"
1781 />
1782 }
1783 />
1784 {settings.sidebarAutoSettleAfterDays !== null ? (
1785 <SettingsRow
1786 title="Days of inactivity before auto-settle"
1787 description="Any new activity un-settles a thread automatically."
1788 control={
1789 <AutoSettleDaysInput
1790 value={settings.sidebarAutoSettleAfterDays}
1791 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
1792 />
1793 }
1794 />
1795 ) : null}
1796
16911797 <SettingsRow
16921798 {...searchableSetting("time-format")}
16931799 description="System default follows your browser or OS clock preference."
apps/web/src/components/settings/SettingsSidebarNav.tsx-2
......@@ -11,7 +11,6 @@ import {
1111 ArchiveIcon,
1212 ArrowLeftIcon,
1313 BotIcon,
14 FlaskConicalIcon,
1514 GitBranchIcon,
1615 KeyboardIcon,
1716 Link2Icon,
......@@ -52,7 +51,6 @@ const SETTINGS_SECTION_ICONS: Readonly<
5251 "/settings/providers": BotIcon,
5352 "/settings/source-control": GitBranchIcon,
5453 "/settings/connections": Link2Icon,
55 "/settings/beta": FlaskConicalIcon,
5654 "/settings/archived": ArchiveIcon,
5755};
5856
apps/web/src/components/settings/settingsSearch.ts+10-13
......@@ -5,7 +5,6 @@ export type SettingsPath =
55 | "/settings/providers"
66 | "/settings/source-control"
77 | "/settings/connections"
8 | "/settings/beta"
98 | "/settings/archived";
109
1110export interface SettingsSearchItem {
......@@ -26,7 +25,6 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
2625 "/settings/providers": "Providers",
2726 "/settings/source-control": "Source Control",
2827 "/settings/connections": "Connections",
29 "/settings/beta": "Beta",
3028 "/settings/archived": "Archive",
3129};
3230
......@@ -100,6 +98,11 @@ export const SETTINGS_SEARCH_ITEMS = [
10098 title: "Project grouping",
10199 to: "/settings/general",
102100 },
101 {
102 id: "auto-settle-inactive-threads",
103 title: "Auto-settle inactive threads",
104 to: "/settings/general",
105 },
103106 {
104107 id: "time-format",
105108 title: "Time format",
......@@ -161,6 +164,11 @@ export const SETTINGS_SEARCH_ITEMS = [
161164 title: "Stream token by token (legacy)",
162165 to: "/settings/general",
163166 },
167 {
168 id: "legacy-sidebar",
169 title: "Sidebar (legacy)",
170 to: "/settings/general",
171 },
164172 {
165173 id: "keybindings",
166174 title: "Keybindings",
......@@ -181,17 +189,6 @@ export const SETTINGS_SEARCH_ITEMS = [
181189 title: "Remote environments",
182190 to: "/settings/connections",
183191 },
184 {
185 id: "sidebar-v2",
186 title: "Sidebar v2",
187 to: "/settings/beta",
188 },
189 {
190 id: "auto-settle-inactive-threads",
191 title: "Auto-settle inactive threads",
192 to: "/settings/beta",
193 targetId: "sidebar-v2",
194 },
195192 {
196193 id: "archive",
197194 title: "Archived threads",
apps/web/src/hooks/useSettings.ts+9-22
......@@ -25,8 +25,6 @@ import {
2525 type UnifiedSettings,
2626} from "@t3tools/contracts/settings";
2727import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import { APP_STAGE_LABEL } from "~/branding";
29import { resolveSidebarV2Enabled } from "~/branding.logic";
3028import { ensureLocalApi } from "~/localApi";
3129import {
3230 getThemeDefinition,
......@@ -266,29 +264,18 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod
266264}
267265
268266/**
269 * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user
270 * has made one, otherwise the default for this build stage (on for nightly and
271 * dev, off for production). Every consumer must read through this rather than
272 * `settings.sidebarV2Enabled`, which is only meaningful alongside
273 * `sidebarV2ConfiguredByUser`.
267 * Whether the legacy sidebar (Settings → General → Legacy features) replaces
268 * the default one.
274269 *
275 * Held at v1 until client settings hydrate. The pre-hydration snapshot is just
276 * the schema defaults, so resolving against it would mount one sidebar and then
277 * swap it out once persisted settings land — remounting the whole tree.
270 * Held at the default sidebar until client settings hydrate: the pre-hydration
271 * snapshot is just the schema defaults, so resolving against it could mount one
272 * sidebar and then swap it out once persisted settings land — remounting the
273 * whole tree for everyone instead of only for legacy opt-ins.
278274 */
279export function useSidebarV2Enabled(): boolean {
275export function useLegacySidebarEnabled(): boolean {
280276 const settingsHydrated = useClientSettingsHydrated();
281 const settings = useClientSettingsValue();
282 return useMemo(
283 () =>
284 resolveSidebarV2Enabled({
285 enabled: settings.sidebarV2Enabled,
286 configuredByUser: settings.sidebarV2ConfiguredByUser,
287 settingsHydrated,
288 stageLabel: APP_STAGE_LABEL,
289 }),
290 [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated],
291 );
277 const legacySidebarEnabled = useClientSettingsValue().legacySidebarEnabled;
278 return settingsHydrated && legacySidebarEnabled;
292279}
293280
294281/** Read current settings for one environment, merged with client-local preferences. */
apps/web/src/index.css+6-10
......@@ -995,12 +995,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
995995 }
996996}
997997
998/* Keep both navigation implementations on the same quiet zinc hierarchy:
999 zinc-50 navigation, zinc-25 hover, and white selected/raised surfaces.
1000 The version attribute remains useful for layout-specific styling without
1001 changing the color system when the beta is toggled. */
1002[data-sidebar-version="v1"],
1003[data-sidebar-version="v2"] {
998/* Keep both sidebar implementations (default and legacy) on the same quiet
999 zinc hierarchy: zinc-50 navigation, zinc-25 hover, and white selected/raised
1000 surfaces. */
1001[data-app-sidebar] {
10041002 --background: var(--color-zinc-25);
10051003 --foreground: var(--color-zinc-800);
10061004 --card: var(--color-white);
......@@ -1023,8 +1021,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
10231021 background-color: var(--sidebar);
10241022}
10251023
1026.dark [data-sidebar-version="v1"],
1027.dark [data-sidebar-version="v2"] {
1024.dark [data-app-sidebar] {
10281025 --background: #000;
10291026 --foreground: #f1f3f7;
10301027 --card: #000;
......@@ -1321,8 +1318,7 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="tr
13211318 color: var(--code-foreground);
13221319}
13231320
1324html[data-theme-id] [data-sidebar-version="v1"],
1325html[data-theme-id] [data-sidebar-version="v2"] {
1321html[data-theme-id] [data-app-sidebar] {
13261322 --background: var(--app-theme-canvas);
13271323 --foreground: var(--app-theme-text);
13281324 --card: var(--app-theme-surface);
apps/web/src/routeTree.gen.ts-21
......@@ -20,7 +20,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi
2020import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2121import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2222import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
23import { Route as SettingsBetaRouteImport } from './routes/settings.beta'
2423import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2524import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
2625import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
......@@ -81,11 +80,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
8180 path: '/connections',
8281 getParentRoute: () => SettingsRoute,
8382} as any)
84const SettingsBetaRoute = SettingsBetaRouteImport.update({
85 id: '/beta',
86 path: '/beta',
87 getParentRoute: () => SettingsRoute,
88} as any)
8983const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
9084 id: '/archived',
9185 path: '/archived',
......@@ -121,7 +115,6 @@ export interface FileRoutesByFullPath {
121115 '/connect/callback': typeof ConnectCallbackRoute
122116 '/settings/appearance': typeof SettingsAppearanceRoute
123117 '/settings/archived': typeof SettingsArchivedRoute
124 '/settings/beta': typeof SettingsBetaRoute
125118 '/settings/connections': typeof SettingsConnectionsRoute
126119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
127120 '/settings/general': typeof SettingsGeneralRoute
......@@ -138,7 +131,6 @@ export interface FileRoutesByTo {
138131 '/connect/callback': typeof ConnectCallbackRoute
139132 '/settings/appearance': typeof SettingsAppearanceRoute
140133 '/settings/archived': typeof SettingsArchivedRoute
141 '/settings/beta': typeof SettingsBetaRoute
142134 '/settings/connections': typeof SettingsConnectionsRoute
143135 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
144136 '/settings/general': typeof SettingsGeneralRoute
......@@ -158,7 +150,6 @@ export interface FileRoutesById {
158150 '/connect_/callback': typeof ConnectCallbackRoute
159151 '/settings/appearance': typeof SettingsAppearanceRoute
160152 '/settings/archived': typeof SettingsArchivedRoute
161 '/settings/beta': typeof SettingsBetaRoute
162153 '/settings/connections': typeof SettingsConnectionsRoute
163154 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
164155 '/settings/general': typeof SettingsGeneralRoute
......@@ -179,7 +170,6 @@ export interface FileRouteTypes {
179170 | '/connect/callback'
180171 | '/settings/appearance'
181172 | '/settings/archived'
182 | '/settings/beta'
183173 | '/settings/connections'
184174 | '/settings/diagnostics'
185175 | '/settings/general'
......@@ -196,7 +186,6 @@ export interface FileRouteTypes {
196186 | '/connect/callback'
197187 | '/settings/appearance'
198188 | '/settings/archived'
199 | '/settings/beta'
200189 | '/settings/connections'
201190 | '/settings/diagnostics'
202191 | '/settings/general'
......@@ -215,7 +204,6 @@ export interface FileRouteTypes {
215204 | '/connect_/callback'
216205 | '/settings/appearance'
217206 | '/settings/archived'
218 | '/settings/beta'
219207 | '/settings/connections'
220208 | '/settings/diagnostics'
221209 | '/settings/general'
......@@ -314,13 +302,6 @@ declare module '@tanstack/react-router' {
314302 preLoaderRoute: typeof SettingsConnectionsRouteImport
315303 parentRoute: typeof SettingsRoute
316304 }
317 '/settings/beta': {
318 id: '/settings/beta'
319 path: '/beta'
320 fullPath: '/settings/beta'
321 preLoaderRoute: typeof SettingsBetaRouteImport
322 parentRoute: typeof SettingsRoute
323 }
324305 '/settings/archived': {
325306 id: '/settings/archived'
326307 path: '/archived'
......@@ -376,7 +357,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
376357interface SettingsRouteChildren {
377358 SettingsAppearanceRoute: typeof SettingsAppearanceRoute
378359 SettingsArchivedRoute: typeof SettingsArchivedRoute
379 SettingsBetaRoute: typeof SettingsBetaRoute
380360 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
381361 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
382362 SettingsGeneralRoute: typeof SettingsGeneralRoute
......@@ -388,7 +368,6 @@ interface SettingsRouteChildren {
388368const SettingsRouteChildren: SettingsRouteChildren = {
389369 SettingsAppearanceRoute: SettingsAppearanceRoute,
390370 SettingsArchivedRoute: SettingsArchivedRoute,
391 SettingsBetaRoute: SettingsBetaRoute,
392371 SettingsConnectionsRoute: SettingsConnectionsRoute,
393372 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
394373 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.tsx+7-7
......@@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
33import { useEffect, useMemo } from "react";
44
55import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
6import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
77import { openCommandPalette } from "../commandPaletteBus";
88import { useProjects } from "../state/entities";
99import { usePrimaryEnvironmentId } from "../state/environments";
......@@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() {
2828 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
2929 useHandleNewThread();
3030 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
31 const sidebarV2Enabled = useSidebarV2Enabled();
31 const legacySidebarEnabled = useLegacySidebarEnabled();
3232 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3333 const projects = useProjects();
3434 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -92,10 +92,10 @@ function ChatRouteGlobalShortcuts() {
9292 if (command === "chat.new") {
9393 event.preventDefault();
9494 event.stopPropagation();
95 // Sidebar v2 routes creation through the command palette whenever
96 // there is a real choice to make; v1 (and single-project setups)
97 // keep the immediate contextual create.
98 if (sidebarV2Enabled && projectGroupCount > 1) {
95 // The default sidebar routes creation through the command palette
96 // whenever there is a real choice to make; the legacy sidebar (and
97 // single-project setups) keep the immediate contextual create.
98 if (!legacySidebarEnabled && projectGroupCount > 1) {
9999 openCommandPalette({ open: "new-thread-in" });
100100 return;
101101 }
......@@ -167,7 +167,7 @@ function ChatRouteGlobalShortcuts() {
167167 projectGroupCount,
168168 routeThreadRef,
169169 selectedThreadKeysSize,
170 sidebarV2Enabled,
170 legacySidebarEnabled,
171171 terminalOpen,
172172 ]);
173173
apps/web/src/routes/settings.beta.tsx deleted-11
......@@ -1,11 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel";
4
5function SettingsBetaRoute() {
6 return <BetaSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/beta")({
10 component: SettingsBetaRoute,
11});
packages/contracts/src/settings.test.ts+13-21
......@@ -67,36 +67,28 @@ describe("ClientSettings environment identification", () => {
6767 });
6868});
6969
70describe("ClientSettings sidebar v2", () => {
71 it("defaults the beta off with a three-day auto-settle threshold", () => {
70describe("ClientSettings sidebar", () => {
71 it("defaults to the current sidebar with a three-day auto-settle threshold", () => {
7272 const settings = decodeClientSettings({});
73 expect(settings.sidebarV2Enabled).toBe(false);
73 expect(settings.legacySidebarEnabled).toBe(false);
7474 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
7575 });
7676
77 it("treats settings written before the beta had a per-channel default as unconfigured", () => {
78 // The stored blob always carries `sidebarV2Enabled`, so only the companion
79 // flag can distinguish "user opted out" from "never touched it".
80 expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false);
81 expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false);
82 });
83
84 it("preserves an explicit beta choice", () => {
85 const settings = decodeClientSettings({
77 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
78 const decoded = decodeClientSettings({
8679 sidebarV2Enabled: false,
8780 sidebarV2ConfiguredByUser: true,
8881 });
89 expect(settings.sidebarV2Enabled).toBe(false);
90 expect(settings.sidebarV2ConfiguredByUser).toBe(true);
82 expect(decoded.legacySidebarEnabled).toBe(false);
83 expect(decoded).not.toHaveProperty("sidebarV2Enabled");
84 expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser");
9185 });
9286
93 it("carries an explicit beta opt-out through the patch the beta toggle writes", () => {
94 const patch = decodeClientSettingsPatch({
95 sidebarV2Enabled: false,
96 sidebarV2ConfiguredByUser: true,
97 });
98 expect(patch.sidebarV2Enabled).toBe(false);
99 expect(patch.sidebarV2ConfiguredByUser).toBe(true);
87 it("preserves an explicit legacy sidebar opt-in", () => {
88 expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true);
89 expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(
90 true,
91 );
10092 });
10193
10294 it("allows auto-settle by inactivity to be disabled", () => {
packages/contracts/src/settings.ts+6-9
......@@ -171,6 +171,11 @@ export const ClientSettingsSchema = Schema.Struct({
171171 // default UI; this beta flag restores it (plus the /plan and /default slash
172172 // commands) for users who still rely on the old workflow.
173173 planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
174 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
175 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the
176 // old keys, so everyone, including prior beta opt-outs, resets to the new
177 // default sidebar.
178 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
174179 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
175180 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
176181 ),
......@@ -190,13 +195,6 @@ export const ClientSettingsSchema = Schema.Struct({
190195 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
191196 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
192197 ),
193 sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
194 // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta.
195 // Client settings persist as a whole blob, so every user who has ever touched
196 // any setting already has `sidebarV2Enabled: false` stored — without this bit
197 // there is no way to tell that apart from "left alone", and a channel-derived
198 // default could never reach them. Mirrors `updateChannelConfiguredByUser`.
199 sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
200198 timestampFormat: TimestampFormat.pipe(
201199 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
202200 ),
......@@ -791,6 +789,7 @@ export const ClientSettingsPatch = Schema.Struct({
791789 ),
792790 ),
793791 planModeEnabled: Schema.optionalKey(Schema.Boolean),
792 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
794793 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
795794 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
796795 sidebarProjectGroupingOverrides: Schema.optionalKey(
......@@ -799,8 +798,6 @@ export const ClientSettingsPatch = Schema.Struct({
799798 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
800799 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
801800 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
802 sidebarV2Enabled: Schema.optionalKey(Schema.Boolean),
803 sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean),
804801 timestampFormat: Schema.optionalKey(TimestampFormat),
805802 wordWrap: Schema.optionalKey(Schema.Boolean),
806803});