diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 37683b4f26ca..62839e6183e7 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -79,6 +79,7 @@ describe("DesktopLifecycle", () => { handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, syncAppearance: Effect.void, navigateToThread: () => Effect.void, navigateToProject: () => Effect.void, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index d374b291af5b..43ba9042d885 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -98,6 +98,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + zoomMain: () => Effect.die("unexpected zoom"), syncAppearance: Effect.void, navigateToThread: () => Effect.void, navigateToProject: () => Effect.void, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c1213b5d8f7f..a5e8f3e94e2d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -54,8 +54,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarHideProviderIcons: false, - sidebarV2Enabled: false, - sidebarV2ConfiguredByUser: false, + legacySidebarEnabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 813281acf7bb..49238e098dab 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -83,6 +83,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + zoomMain: (direction) => + Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), syncAppearance: Effect.void, navigateToThread: () => Effect.void, navigateToProject: () => Effect.void, @@ -98,6 +100,30 @@ const makeElectronMenuLayer = ( showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); +const configureMenu = ( + selectedAction: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, +) => + Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { @@ -105,25 +131,7 @@ describe("DesktopApplicationMenu", () => { const applicationMenuTemplate = yield* Deferred.make(); - yield* Effect.gen(function* () { - const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - yield* menu.configure; - }).pipe( - Effect.provide( - DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), - Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), - Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(electronDialogLayer), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - ), - ), - ), - ), - ); + yield* configureMenu(selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); const fileMenu = template.find((item) => item.label === "File"); @@ -142,4 +150,38 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + // Zoom must route through DesktopWindow.zoomMain instead of the Electron + // zoom roles: the roles zoom whichever webContents has focus, which breaks + // app zoom while an embedded preview WebContentsView holds focus. + it.effect("routes View menu zoom to the main window instead of zoom roles", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + assert.isDefined(viewMenu); + if (!Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + + assert.isUndefined( + viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + ); + + const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + assert.isDefined(zoomIn); + assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + if (typeof zoomIn.click !== "function") { + throw new Error("Expected Zoom In menu item to have a click handler."); + } + + zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0a..66244534debf 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); +const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( + direction: DesktopWindow.MainWindowZoomDirection, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.zoomMain(direction); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { + runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + /* + Not the zoom roles: those act on the focused webContents, so with + an embedded preview WebContentsView focused they zoom the guest + page and the app UI appears stuck. These always zoom the main + window (see DesktopWindow.zoomMain). + */ + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, + { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: zoomClick("in"), + }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 36093491ca3c..fc9baf598c4c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -66,6 +66,8 @@ export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; +export type MainWindowZoomDirection = "in" | "out" | "reset"; + export class DesktopWindow extends Context.Service< DesktopWindow, { @@ -92,6 +94,12 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` + // menu roles act on whichever webContents has keyboard focus, so with an + // embedded preview WebContentsView (or DevTools) focused they zoom the + // guest page instead of the app UI. The menu routes here to always target + // the main window. + readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly syncAppearance: Effect.Effect; /** * Navigate the main window to the canonical thread route @@ -910,6 +918,18 @@ export const make = Effect.gen(function* () { send(); }), + zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { + yield* Effect.annotateCurrentSpan({ direction }); + const window = yield* focusedMainWindow; + if (Option.isNone(window) || window.value.isDestroyed()) { + return; + } + const webContents = window.value.webContents; + // Same step size as the Electron zoomIn/zoomOut menu roles. + webContents.setZoomLevel( + direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), + ); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cd34a76e1969..5b6bde3a5a30 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -58,6 +58,7 @@ import { THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, + resolveThreadListV2Enabled, } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { matchesEnvironmentFilter } from "./homeEnvironmentFilter"; @@ -232,10 +233,15 @@ export function HomeScreen(props: HomeScreenProps) { const preferencesResult = useAtomValue(mobilePreferencesAtom); // Grouping changes V2 ordering only; the cards, pin block, and shelves are // shared across project and recency modes. + // v2 is the default list since #5672; the legacy grouped list is the opt-in. const threadListV2Enabled = props.listMode === "threads" && - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + resolveThreadListV2Enabled({ + legacyPreference: AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.legacyThreadListEnabled + : undefined, + preferencesLoaded: AsyncResult.isSuccess(preferencesResult), + }); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 33da2aec153a..65d441bb8b3b 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -135,7 +135,7 @@ function LocalSettingsRouteScreen() { - + @@ -523,7 +523,7 @@ function ConfiguredSettingsRouteScreen() { - + @@ -542,26 +542,27 @@ function GeneralSettingsSection() { } /** - * Device-local beta toggles. Mobile has no client-settings sync, so this is - * the counterpart of web's Settings → Beta backed by mobile preferences. + * Device-local legacy toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → General → Legacy features backed by + * mobile preferences. */ -function BetaSettingsSection() { +function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); return ( - + savePreferences({ threadListV2Enabled: value })} + label="Legacy Thread List" + value={!threadListV2Enabled} + onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} /> - One flat thread list in creation order. Active work renders as cards; settled threads - collapse to compact rows. Switch back any time. + Brings back the original grouped thread list. The default list is flat, in creation order: + active work renders as cards; settled threads collapse to compact rows. ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 68568d121868..6ab1f8634003 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -106,6 +106,7 @@ import { THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, + resolveThreadListV2Enabled, } from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread @@ -292,10 +293,16 @@ function ThreadNavigationSidebarPane( ); // Grouping changes V2 ordering only; it must never swap in a different row // renderer or remove pinning / shelves. + // v2 is the default list since #5672; legacyThreadListEnabled is the opt-out. + // Must match HomeScreen, or this surface and Home disagree about the list. const threadListV2Enabled = options.listMode === "threads" && - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + resolveThreadListV2Enabled({ + legacyPreference: AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.legacyThreadListEnabled + : undefined, + preferencesLoaded: AsyncResult.isSuccess(preferencesResult), + }); const hideSettledOnRecent = AsyncResult.isSuccess(preferencesResult) ? resolveHideSettledOnRecent(preferencesResult.value) : true; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1000ff60fddb..82748d342732 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), + ).toBe(true); }); - it("honors an explicit device opt-out", () => { - expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); - expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); + it("honors an explicit legacy opt-in", () => { + expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( + false, + ); + expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( + true, + ); }); it("holds the default while preferences are still loading so the list does not remount", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2b981db0197a..30cd4ee6b61b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -108,23 +108,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** - * Thread List v2 is on by default on every app variant; the Settings → Beta - * toggle is an opt-out. Preferences persist as sparse patches, so `undefined` - * genuinely means "never chosen". + * The flat Thread List v2 is the default on every app variant; the Settings → + * Legacy toggle opts a device back into the grouped legacy list. Preferences + * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit opt-out lands anyway. + * is where every device without an explicit legacy opt-in lands anyway. */ export function resolveThreadListV2Enabled(input: { - readonly preference: boolean | undefined; + readonly legacyPreference: boolean | undefined; readonly preferencesLoaded: boolean; }): boolean { if (!input.preferencesLoaded) { return true; } - return input.preference ?? true; + return input.legacyPreference !== true; } export function resolveThreadListV2Status( diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index 266bda944ae8..2672942c2d36 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -5,15 +5,15 @@ import { mobilePreferencesAtom } from "../../state/preferences"; import { resolveThreadListV2Enabled } from "./threadListV2"; /** - * Resolved Thread List v2 state: the device-local preference if the user has - * set one, otherwise the default (on). Every consumer must read through this + * Resolved Thread List v2 state: on unless the device opted into the legacy + * grouped list (Settings → Legacy). Every consumer must read through this * rather than the raw preference, which is undefined until explicitly chosen. */ export function useThreadListV2Enabled(): boolean { const preferencesResult = useAtomValue(mobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); return resolveThreadListV2Enabled({ - preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, preferencesLoaded: loaded, }); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index bbe175109fd2..2b090f9ad555 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -27,12 +27,18 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; /** - * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no - * client-settings sync, so the flat v2 thread list is opted out of per - * device. Undefined means the user has never chosen, which resolves to on — - * see `resolveThreadListV2Enabled`. + * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has + * no client-settings sync, so the legacy grouped thread list is opted into + * per device. Deliberately a fresh key (was `threadListV2Enabled`, an + * opt-out): sanitizing drops the old key, so every device resets to the + * default flat list — see `resolveThreadListV2Enabled`. + */ + /** + * @deprecated Superseded by `legacyThreadListEnabled` when v2 became the + * default (#5672). Kept so older device preference payloads still decode. */ readonly threadListV2Enabled?: boolean; + readonly legacyThreadListEnabled?: boolean; /** * @deprecated Legacy toggle from Needs attention / Recent work UI (removed). * Kept only so older device preference payloads still decode. @@ -122,6 +128,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; threadListV2Enabled?: boolean; + legacyThreadListEnabled?: boolean; recentWorkEnabled?: boolean; selectedEnvironmentIds?: readonly string[]; hideSettledThreads?: boolean; @@ -166,8 +173,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.threadListV2Enabled === "boolean") { - preferences.threadListV2Enabled = parsed.threadListV2Enabled; + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } if (typeof parsed.recentWorkEnabled === "boolean") { preferences.recentWorkEnabled = parsed.recentWorkEnabled; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 36633590a48b..9078626ba2e4 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -994,6 +995,73 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]); + + const { manager, ghCalls } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/never-pushed"); + expect(status.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]); + // No `-u`, so the remote-tracking ref exists but branch..merge does + // not. Most terminal and agent pushes land this way, and they can still + // have a PR, so the skip must not trigger here. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 214, + title: "Pushed without upstream", + url: "https://github.com/pingdotgg/t3code/pull/214", + baseRefName: "main", + headRefName: "feature/pushed-no-upstream", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.number).toBe(214); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + + it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { + expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); + // The point of the backoff: by the third retry a failing branch must not be + // asking more often than a healthy one, which refreshes every 2 minutes. + expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); + }); + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 5c43b5cc3347..80e8c96ec71d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -125,11 +125,27 @@ const STATUS_RESULT_CACHE_TTL = Duration.seconds(5); */ const REMOTE_STATUS_RESULT_CACHE_TTL = Duration.seconds(35); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -/** PR lookup is the list-badge path; 1 min balances gh load vs badge freshness. */ -const PR_LOOKUP_CACHE_TTL = Duration.minutes(1); -const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; +/** + * How long a failed PR lookup is cached, given the number of consecutive + * failures for that branch. + * + * A hosting provider rejects a throttled request immediately, so caching every + * failure for a flat 20s made a rate-limited poller re-ask *faster* than a + * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 + * into sustained pressure. Backing off per branch keeps the retry rate below + * the healthy rate once a branch has failed more than a couple of times. + */ +export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); + return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); +} + /** Merged/closed last-known badges do not re-hit the hosting provider until invalidateStatus. */ export function isTerminalStatusPrState( state: "open" | "closed" | "merged" | null | undefined, @@ -922,6 +938,23 @@ export const make = Effect.gen(function* () { // back to a null upstreamRef. const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + // Consecutive failures per cache key, so a branch that keeps failing waits + // longer before the next attempt. Cleared as soon as a lookup succeeds. + const prLookupFailureStreakByKey = new Map(); + const nextPrLookupFailureTtl = (key: string) => { + if ( + !prLookupFailureStreakByKey.has(key) && + prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = prLookupFailureStreakByKey.keys().next().value; + if (oldestKey !== undefined) { + prLookupFailureStreakByKey.delete(oldestKey); + } + } + const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; + prLookupFailureStreakByKey.set(key, streak); + return prLookupFailureTtl(streak); + }; const prLookupCache = yield* Cache.makeWith( (key: string) => { const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); @@ -929,17 +962,26 @@ export const make = Effect.gen(function* () { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, }; - return resolveBranchHeadContext(cwd, details).pipe( - Effect.flatMap((headContext) => - findLatestPrForHeadContext(cwd, headContext).pipe( - Effect.map((latest) => ({ latest, headContext })), - ), - ), - ); + return Effect.gen(function* () { + const headContext = yield* resolveBranchHeadContext(cwd, details); + // Only skip when the branch is untracked as well: anything carrying an + // upstream keeps the old behaviour. + if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + return { latest: null, headContext }; + } + const latest = yield* findLatestPrForHeadContext(cwd, headContext); + return { latest, headContext }; + }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + timeToLive: (exit, key) => { + if (Exit.isSuccess(exit)) { + prLookupFailureStreakByKey.delete(key); + return PR_LOOKUP_CACHE_TTL; + } + return nextPrLookupFailureTtl(key); + }, }, ); // A transient lookup failure (rate limit, network blip) must not clear an @@ -1255,6 +1297,43 @@ export const make = Effect.gen(function* () { } satisfies BranchHeadContext; }); + /** + * Whether git has no record of this branch on any remote, so a change request + * cannot exist for it and asking the provider is a guaranteed-empty API call. + * + * `git push` writes the remote-tracking ref even without `-u` (how most + * terminal and agent pushes land), which makes this a safer "did it ever + * reach the host" test than looking for upstream config, and the glob spans + * every remote so a fork branch still counts. A repository that tracks no + * remotes at all cannot answer the question, because then every branch looks + * unpublished; it, and any failed probe, keeps the lookup. + */ + const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( + cwd: string, + headContext: Pick, + ) { + if (headContext.headBranch.length === 0) { + return false; + } + const matchesRef = (pattern: string) => + gitCore + .execute({ + operation: "GitManager.isUnpublishedBranch", + cwd, + args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], + timeoutMs: 5_000, + }) + .pipe(Effect.map((result) => result.stdout.trim().length > 0)); + + return yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), + Effect.orElseSucceed(() => false), + ); + }); + const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, headContext: Pick< diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts index 1eb33594ad9e..2f168c5ccf6c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.grokSegments.test.ts @@ -395,7 +395,7 @@ describe("ProviderRuntimeIngestion Grok multi-segment assistant bubbles", () => async function runGrokStatusSandwichTurn(options: { readonly streaming: boolean }) { const harness = await createHarness({ - serverSettings: { enableAssistantStreaming: options.streaming }, + serverSettings: { enableLegacyTokenStreaming: options.streaming }, }); const turnId = asTurnId("turn-grok-sandwich"); const sessionId = "019f8373-fa8d-7982-a0d7-caf8b866b523"; @@ -504,7 +504,7 @@ describe("ProviderRuntimeIngestion Grok multi-segment assistant bubbles", () => // Grok AcpSessionRuntime item ids already start with `assistant:…`. Ingestion // must not create a second message row from the completion fallback id path. const harness = await createHarness({ - serverSettings: { enableAssistantStreaming: true }, + serverSettings: { enableLegacyTokenStreaming: true }, }); const turnId = asTurnId("turn-id-prefix"); const sessionId = "sess-1"; @@ -582,7 +582,7 @@ describe("ProviderRuntimeIngestion Grok multi-segment assistant bubbles", () => it("drops a buffered assistant segment that repeats the previous status text", async () => { const harness = await createHarness({ - serverSettings: { enableAssistantStreaming: false }, + serverSettings: { enableLegacyTokenStreaming: false }, }); const turnId = asTurnId("turn-dup-status"); const sessionId = "sess-dup"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 833e28d4c070..229944e47b7a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2213,7 +2213,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new streaming assistant message segment after approval", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; const resumedAt = "2026-03-28T07:00:02.000Z"; @@ -2320,7 +2320,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index fd4bc48ab3a2..b0c554b4d625 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1780,7 +1780,7 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1816,7 +1816,7 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index ab04fc032219..da7dd1994399 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -69,6 +69,7 @@ function makeReadModel( readonly lastError: string | null; readonly updatedAt: string; } | null; + readonly backgroundLiveness?: "working" | "monitoring" | null; }>, ) { const now = "2026-01-01T00:00:00.000Z"; @@ -111,6 +112,7 @@ function makeReadModel( messages: [], queuedMessages: [], session: thread.session, + backgroundLiveness: thread.backgroundLiveness ?? null, activities: [], proposedPlans: [], checkpoints: [], @@ -137,6 +139,14 @@ describe("ProviderSessionReaper", () => { runtime = null; }); + // Shared start sequence so each test adds no manual Effect runners + // (no-manual-effect-runtime-in-tests tracks this file's legacy count). + async function startReaper() { + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + } + async function createHarness(input: { readonly readModel: ReturnType; readonly stopSessionImplementation?: (input: { @@ -281,9 +291,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 1); @@ -331,9 +339,55 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips stale sessions while background work is still live", async () => { + const threadId = ThreadId.make("thread-reaper-background-work"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + backgroundLiveness: "working", + }, + ]), + }); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-background-work", + }, + runtimePayload: null, + }), + ); + + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -437,9 +491,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -486,9 +538,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -572,9 +622,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await runtime!.runPromise(Scope.make("sequential")); - await runtime!.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); @@ -655,9 +703,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 414fc96c0dcb..ca0e57ae5b2c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -105,6 +105,19 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } + // The turn can settle while background work runs on (subagent + // fleets, workflow runs, Monitor watch loops). Those live inside the + // provider process, so stopping the session would kill them silently, + // and nothing bumps lastSeenAt between turns. + if (thread?.backgroundLiveness != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-background-work", { + threadId: binding.threadId, + backgroundLiveness: thread.backgroundLiveness, + idleDurationMs, + }); + continue; + } + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( Effect.tap(() => Effect.logInfo("provider.session.reaped", { diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 5c642e33f0f7..31a2f1d779c5 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -11,6 +11,7 @@ import { cssFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, + resolveTerminalFontSizePreference, } from "./appearanceFonts"; describe("areFontAdvancesMonospace", () => { @@ -97,6 +98,16 @@ describe("resolveTerminalFontPreference", () => { }); }); +describe("resolveTerminalFontSizePreference", () => { + it("inherits the code font size in simple mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: false, code: 15, terminal: 12 })).toBe(15); + }); + + it("keeps code and terminal font sizes independent in advanced mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: true, code: 15, terminal: 12 })).toBe(12); + }); +}); + describe("font size clamping", () => { it("keeps sizes inside the ranges the UI can absorb", () => { expect(clampInterfaceFontSize(16)).toBe(16); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 60801ef01182..6053e5fb0dd4 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -41,6 +41,15 @@ export function resolveTerminalFontPreference(input: { return input.code; } +export function resolveTerminalFontSizePreference(input: { + readonly advanced: boolean; + readonly code: number; + readonly terminal: number; +}): number { + if (input.advanced) return input.terminal; + return input.code; +} + function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 06d663ca0b4a..056fbb76e6ab 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,51 +11,6 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } -/** - * Whether the sidebar v2 beta is on by default for a build stage. - * - * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved - * from the client's own stage label rather than the connected server's version: - * v2 only exists in the client, so a stable client on a nightly server has - * nothing to turn on. - */ -export function resolveSidebarV2Default(stageLabel: string): boolean { - const stage = stageLabel.trim().toLowerCase(); - return stage === "nightly" || stage === "dev"; -} - -/** - * Resolved sidebar v2 state: an explicit choice if the user has made one, - * otherwise the default for this build stage. - * - * A stored `enabled: true` counts as an explicit choice even without the - * companion flag. `true` was never the schema default, so it can only have come - * from the Settings → Beta toggle — settings written before that flag existed - * would otherwise lose the opt-in and drop such users back to v1 on production. - * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored - * `updateChannel: "nightly"` as user-configured. - * - * `settingsHydrated` guards the startup window: client settings load - * asynchronously and the pre-hydration snapshot is just the schema defaults, so - * resolving against it would mount one sidebar and swap it out a tick later, - * remounting the tree. While hydrating, hold v1 — where both paths already - * start. - */ -export function resolveSidebarV2Enabled(input: { - readonly enabled: boolean; - readonly configuredByUser: boolean; - readonly settingsHydrated: boolean; - readonly stageLabel: string; -}): boolean { - if (!input.settingsHydrated) { - return false; - } - - return input.configuredByUser || input.enabled - ? input.enabled - : resolveSidebarV2Default(input.stageLabel); -} - export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e517d40b04f3..e1c87bcf0595 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, - resolveSidebarV2Default, - resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -116,74 +114,3 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); - -describe("resolveSidebarV2Default", () => { - it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(true); - }); - - it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(false); - }); -}); - -describe("resolveSidebarV2Enabled", () => { - const hydrated = { settingsHydrated: true } as const; - - it.each(["Alpha", "Latest"])( - "keeps a legacy opt-in on %s builds even without the companion flag", - (stageLabel) => { - // `true` was never the schema default, so it can only be an explicit - // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: true, - configuredByUser: false, - stageLabel, - }), - ).toBe(true); - }, - ); - - it("applies the stage default when the beta was never enabled or configured", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Nightly", - }), - ).toBe(true); - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Latest", - }), - ).toBe(false); - }); - - it("honors an explicit opt-out over the stage default", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: true, - stageLabel: "Nightly", - }), - ).toBe(false); - }); - - it("holds v1 until settings hydrate so the sidebar does not remount", () => { - expect( - resolveSidebarV2Enabled({ - enabled: true, - configuredByUser: true, - settingsHydrated: false, - stageLabel: "Nightly", - }), - ).toBe(false); - }); -}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 54d360cd91ea..afd88dbb732a 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -12,7 +12,8 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { previewEnvironment } from "~/state/preview"; import { readPreparedConnection } from "~/state/session"; -const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); +export const normalizeHostname = (host: string): string => + host.toLowerCase().replace(/^\[|\]$/g, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -22,7 +23,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; -const isLocalLoopbackHost = (host: string): boolean => { +export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts new file mode 100644 index 000000000000..29d27eb55353 --- /dev/null +++ b/apps/web/src/browserHistoryStore.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const { readPreparedConnection } = vi.hoisted(() => ({ + readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), +})); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + BROWSER_HISTORY_MAX_PROJECTS, + BROWSER_HISTORY_MAX_TITLE_LENGTH, + type BrowserHistoryEntry, + evictExcessProjects, + mergeBrowserHistoryState, + migratePersistedBrowserHistoryState, + normalizeHistoryUrl, + recordVisitForThread, + removeUrlForThread, + resetBrowserHistoryForTests, + setTitleForThreadUrl, + upsertHistoryEntry, + useBrowserHistoryStore, +} from "./browserHistoryStore"; + +function entry(overrides: Partial = {}): BrowserHistoryEntry { + return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; +} + +beforeEach(() => readPreparedConnection.mockReturnValue(null)); +afterEach(() => vi.restoreAllMocks()); + +function spyOnPersistWrites() { + const storage = useBrowserHistoryStore.persist.getOptions().storage; + if (!storage) throw new Error("Browser history persistence storage is unavailable."); + return vi.spyOn(storage, "setItem"); +} + +describe("normalizeHistoryUrl", () => { + it("normalizes bare loopback hosts to http and keeps path/query", () => { + expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( + "http://localhost:3000/admin?tab=1", + ); + }); + + it("normalizes bare public hosts to https", () => { + expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); + }); + + it("preserves hash routes and strips credentials", () => { + expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( + "http://localhost:3000/app#/route", + ); + expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/"); + }); + + it("rejects non-http(s), unparseable, and oversized urls", () => { + expect(normalizeHistoryUrl("ftp://example.com")).toBeNull(); + expect(normalizeHistoryUrl("")).toBeNull(); + expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull(); + }); +}); + +describe("upsertHistoryEntry", () => { + it("prepends new urls", () => { + const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); + expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); + expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); + }); + + it("moves revisits to front, updates the timestamp, and keeps the title", () => { + const existing = [ + entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), + entry({ url: "http://b.test/", lastVisitedAt: 400 }), + ]; + const next = upsertHistoryEntry(existing, "http://b.test/", 3000); + expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); + expect(next[0]?.lastVisitedAt).toBe(3000); + expect(next[1]?.title).toBe("A"); + }); + + it("caps the list at the per-project limit", () => { + const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => + entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), + ); + const next = upsertHistoryEntry(full, "http://new.test/", 9999); + expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + expect(next[0]?.url).toBe("http://new.test/"); + const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; + expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); + expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); + }); + + it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { + const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { + insertOrdered: true, + }); + expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + }); + + it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { + const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); + expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]); + }); +}); + +describe("evictExcessProjects", () => { + it("keeps the most recently visited projects when over the cap", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ + `project-${i}`, + [entry({ lastVisitedAt: i })], + ]), + ); + const next = evictExcessProjects(byProjectKey); + expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(next["project-0"]).toBeUndefined(); + expect(next["project-1"]).toBeUndefined(); + expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); + }); +}); + +describe("migratePersistedBrowserHistoryState", () => { + it("drops malformed state and invalid entries", () => { + expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); + expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} }); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + { url: "", lastVisitedAt: 100 }, + { url: "ftp://ghost.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: Number.NaN }, + "junk", + ], + bad: "junk", + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + ]); + expect(migrated.byProjectKey["bad"]).toBeUndefined(); + }); + + it("normalizes persisted urls with the same rules as live writes", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "https://a.test/path#section", lastVisitedAt: 100 }, + ]); + }); + + it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ + `project-${index}`, + [{ url: `http://project-${index}.test/`, lastVisitedAt: index }], + ]), + ); + byProjectKey["project-1"] = [ + { url: "a.test/", lastVisitedAt: 1 }, + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]; + + const migrated = migratePersistedBrowserHistoryState({ byProjectKey }); + + expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(migrated.byProjectKey["project-0"]).toBeUndefined(); + expect(migrated.byProjectKey["project-1"]).toEqual([ + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]); + }); + + it("rejects a lastVisitedAt outside Date's valid range", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: 1e20 }, + ], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); + }); + + it("truncates oversized persisted titles to the contract bound", () => { + const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], + }, + }); + expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( + BROWSER_HISTORY_MAX_TITLE_LENGTH, + ); + expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( + oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), + ); + }); +}); + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("useBrowserHistoryStore", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("records visits for registered threads under the project key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, + ]); + }); + + it("does not persist when a thread is already registered to the same project", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const persist = spyOnPersistWrites(); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + expect(persist).not.toHaveBeenCalled(); + }); + + it("ignores invalid urls whether queued pending or recorded post-registration", () => { + recordVisitForThread(threadRef, "ftp://a.test/", 1); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "ftp://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + }); + + it("sets titles update-only via the thread helper", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + recordVisitForThread(threadRef, "http://a.test/#/settings", 1); + setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); + }); + + it("does not persist when the title is already set", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + const persist = spyOnPersistWrites(); + const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; + + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + + expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); + expect(persist).not.toHaveBeenCalled(); + }); + + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community", + title: "Community", + }); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community/", + title: "Community", + }); + }); + + it("matches a requested localhost URL to the resolved environment host", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); + + it("deduplicates loopback aliases and the resolved environment host", () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 3 }, + ]); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4); + recordVisitForThread(threadRef, "http://localhost:5173/app", 5); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 5 }, + ]); + }); + + it("does not match a genuinely different path via the trailing-slash comparison", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); + }); + + it("updates only the most recent entry when several share a trailing-slash comparison key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + recordVisitForThread(threadRef, "http://a.test/community", 2); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); + expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); + expect(entries?.[1]?.title).toBeUndefined(); + }); + + it("truncates oversized titles to the contract bound", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); + setTitleForThreadUrl(threadRef, "http://a.test/", oversized); + const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; + expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); + expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); + }); + + it("removes entries", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + removeUrlForThread(threadRef, "http://a.test/"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + ]); + }); +}); + +describe("pendingVisitsByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("queues a visit recorded before registration and drains it in order on registration", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + "http://a.test/", + ]); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1); + expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); + }); + + it("caps the per-thread pending list at 10, dropping the oldest", () => { + for (let i = 0; i < 12; i++) { + recordVisitForThread(threadRef, `http://a.test/${i}`, i); + } + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); + expect(urls).toHaveLength(10); + expect(urls).not.toContain("http://a.test/0"); + expect(urls).not.toContain("http://a.test/1"); + expect(urls?.[0]).toBe("http://a.test/11"); + }); + + it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { + const otherThreadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-2"), + }; + useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(otherThreadRef, "http://newer.test/", 2000); + recordVisitForThread(threadRef, "http://older.test/", 1000); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on. + expect(entries?.[0]?.lastVisitedAt).toBe(2000); + }); +}); + +describe("pendingTitlesByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("buffers a title set before registration and applies it once the matching visit drains", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); + expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); + }); + + it("preserves environment host matching while a title is pending", () => { + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); +}); + +describe("mergeBrowserHistoryState", () => { + it("sanitizes same-version corrupt persisted data and preserves actions", () => { + // `migrate` only runs when versions differ; `merge` runs on every rehydrate. + const current = useBrowserHistoryStore.getState(); + const merged = mergeBrowserHistoryState( + { + byProjectKey: { + a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }], + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }, + projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 }, + }, + current, + ); + expect(merged.byProjectKey).toEqual({ + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }); + expect(typeof merged.recordVisit).toBe("function"); + expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); + expect(merged.pendingVisitsByThreadKey).toEqual({}); + expect(merged.pendingTitlesByThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts new file mode 100644 index 000000000000..4c0a560817bb --- /dev/null +++ b/apps/web/src/browserHistoryStore.ts @@ -0,0 +1,398 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; + +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { readPreparedConnection } from "~/state/session"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; +import { resolveStorage } from "./lib/storage"; + +export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; + +export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; +export const BROWSER_HISTORY_MAX_PROJECTS = 20; +export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; +const MAX_VALID_DATE_MS = 8_640_000_000_000_000; + +export function isValidHistoryTimestamp(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS + ); +} + +export function normalizeHistoryUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(normalizePreviewUrl(raw)); + } catch { + return null; + } + parsed.username = parsed.password = ""; + return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; +} + +export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(visitLookupKey(normalized, environmentHostname)); + if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) + parsed.pathname = parsed.pathname.slice(0, -1); + return parsed.href; +} + +function visitLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(normalized); + const host = normalizeHostname(parsed.hostname); + const environmentHost = environmentHostname && normalizeHostname(environmentHostname); + if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) + parsed.hostname = "local"; + return parsed.href; +} + +function isStableLocalUrl(normalized: string): boolean { + const host = normalizeHostname(new URL(normalized).hostname); + return isLocalLoopbackHost(host) || host === "0.0.0.0"; +} + +export function upsertHistoryEntry( + entries: ReadonlyArray, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, +): BrowserHistoryEntry[] { + const key = visitLookupKey(url, options?.environmentHostname); + const existing = entries.find( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, + ); + const rest = entries.filter( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, + ); + const visitedAt = + options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; + const storedUrl = + existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; + const entry: BrowserHistoryEntry = existing + ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } + : { url, lastVisitedAt: visitedAt }; + if (!options?.insertOrdered) + return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); + const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); + return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); +} + +export function evictExcessProjects( + byProjectKey: Record, +): Record { + const keys = Object.keys(byProjectKey); + if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; + const kept = keys + .toSorted( + (a, b) => + (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), + ) + .slice(0, BROWSER_HISTORY_MAX_PROJECTS); + return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); +} + +export function migratePersistedBrowserHistoryState(persistedState: unknown): { + byProjectKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; + const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; + const byProjectKey: Record = {}; + for (const [projectKey, value] of Object.entries(raw as Record)) { + if (!Array.isArray(value)) continue; + const seenUrls = new Set(); + const entries = value + .flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const { url, lastVisitedAt, title } = candidate as Record; + if (typeof url !== "string") return []; + const normalizedUrl = normalizeHistoryUrl(url); + if (!normalizedUrl) return []; + if (!isValidHistoryTimestamp(lastVisitedAt)) return []; + return [ + { + url: normalizedUrl, + lastVisitedAt, + ...(typeof title === "string" && title.length > 0 + ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } + : {}), + }, + ]; + }) + .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .filter((entry) => { + const key = visitLookupKey(entry.url); + if (seenUrls.has(key)) return false; + seenUrls.add(key); + return true; + }) + .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + if (entries.length > 0) byProjectKey[projectKey] = entries; + } + return { byProjectKey: evictExcessProjects(byProjectKey) }; +} + +const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; + +const PENDING_MAX_PER_THREAD = 10; +const PENDING_MAX_THREADS = 20; + +type PendingVisit = { url: string; at: number; environmentHostname: string | null }; +type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; + +interface BrowserHistoryStoreState { + byProjectKey: Record; + projectKeyByThreadKey: Record; + pendingVisitsByThreadKey: Record; + pendingTitlesByThreadKey: Record; + recordVisit: ( + projectKey: string, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, + ) => void; + setTitleForUrl: ( + projectKey: string, + url: string, + title: string, + environmentHostname?: string | null, + ) => void; + removeUrl: (projectKey: string, url: string) => void; + registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; +} + +function addPendingByThread( + pendingByThreadKey: Record, + threadKey: string, + item: T, +): Record { + const existing = pendingByThreadKey[threadKey] ?? []; + const next = { ...pendingByThreadKey }; + next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); + const keys = Object.keys(next); + if (keys.length > PENDING_MAX_THREADS) { + const oldestKey = keys[0]; + if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; + } + return next; +} + +export const useBrowserHistoryStore = create()( + persist( + (set, get) => ({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + recordVisit: (projectKey, url, at, options) => { + const normalized = normalizeHistoryUrl(url); + if (!normalized) return; + set((state) => { + return { + byProjectKey: evictExcessProjects({ + ...state.byProjectKey, + [projectKey]: upsertHistoryEntry( + state.byProjectKey[projectKey] ?? [], + normalized, + at, + options, + ), + }), + }; + }); + }, + setTitleForUrl: (projectKey, url, title, environmentHostname) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); + if (!normalized || !entries || trimmed.length === 0) return; + const key = titleLookupKey(normalized, environmentHostname); + const index = entries.findIndex( + (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, + ); + if (index === -1 || entries[index]?.title === trimmed) return; + set({ + byProjectKey: { + ...state.byProjectKey, + [projectKey]: entries.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, title: trimmed } : candidate, + ), + }, + }); + }, + removeUrl: (projectKey, url) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + if (!normalized || !entries) return; + const next = entries.filter((candidate) => candidate.url !== normalized); + if (next.length === entries.length) return; + if (next.length === 0) { + const { [projectKey]: _removed, ...rest } = state.byProjectKey; + set({ byProjectKey: rest }); + return; + } + set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); + }, + registerThreadProject: (ref, projectKey) => { + const threadKey = scopedThreadKey(ref); + const state = get(); + const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; + const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; + if ( + state.projectKeyByThreadKey[threadKey] === projectKey && + !pendingVisits && + !pendingTitles + ) { + return; + } + const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; + const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; + delete nextPendingVisits[threadKey]; + delete nextPendingTitles[threadKey]; + set({ + projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, + pendingVisitsByThreadKey: nextPendingVisits, + pendingTitlesByThreadKey: nextPendingTitles, + }); + for (const visit of pendingVisits ?? []) + get().recordVisit(projectKey, visit.url, visit.at, { + insertOrdered: true, + environmentHostname: visit.environmentHostname, + }); + for (const pendingTitle of pendingTitles ?? []) + get().setTitleForUrl( + projectKey, + pendingTitle.url, + pendingTitle.title, + pendingTitle.environmentHostname, + ); + }, + }), + { + name: BROWSER_HISTORY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byProjectKey: state.byProjectKey, + projectKeyByThreadKey: state.projectKeyByThreadKey, + }), + migrate: migratePersistedBrowserHistoryState, + merge: mergeBrowserHistoryState, + }, + ), +); + +export function mergeBrowserHistoryState( + persistedState: unknown, + currentState: BrowserHistoryStoreState, +): BrowserHistoryStoreState { + const migrated = migratePersistedBrowserHistoryState(persistedState); + return { + ...currentState, + ...migrated, + projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }; +} + +function migratePersistedThreadProjectKeys( + persistedState: unknown, + byProjectKey: Record, +): Record { + if (!persistedState || typeof persistedState !== "object") return {}; + const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record) + .filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1] in byProjectKey, + ) + .slice(-100), + ); +} + +export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + const visitAt = at ?? Date.now(); + const connection = readPreparedConnection(ref.environmentId); + const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { + url, + at: visitAt, + environmentHostname, + }), + }); + return; + } + state.recordVisit(projectKey, url, visitAt, { environmentHostname }); +} + +export function setTitleForThreadUrl( + ref: ScopedThreadRef, + url: string, + title: string, + environmentHostname?: string | null, +): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { + url, + title, + environmentHostname, + }), + }); + return; + } + state.setTitleForUrl(projectKey, url, title, environmentHostname); +} + +export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + if (!projectKey) return; + state.removeUrl(projectKey, url); +} + +const EMPTY_HISTORY: ReadonlyArray = []; + +export function useThreadRecentHistory( + ref: ScopedThreadRef, + limit: number, +): ReadonlyArray { + return useBrowserHistoryStore( + useShallow((state) => { + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + const entries = projectKey ? state.byProjectKey[projectKey] : undefined; + return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; + }), + ); +} + +export function resetBrowserHistoryForTests(): void { + useBrowserHistoryStore.setState({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }); + useBrowserHistoryStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5c6acd62aea8..4888ded7d0f4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,9 +14,11 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import ThreadSidebarV2 from "./SidebarV2"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, @@ -118,13 +120,11 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useSidebarV2Enabled(); - // Settings routes render the settings nav, which lives in the v1 component - // and is identical for both sidebars — so v1 stays mounted there. + const legacySidebarEnabled = useLegacySidebarEnabled(); + // Settings routes show the settings nav in place of whichever thread + // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); - const useSidebarV2 = sidebarV2Enabled && !isOnSettings; - const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // Subscribed rather than read once: the clamp must track live window size, @@ -188,7 +188,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"} className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" resizable={{ maxWidth: sidebarMaximumWidth, @@ -200,7 +199,16 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { onResize: setSidebarWidth, }} > - {useSidebarV2 ? : } + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} {children} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 4876df165045..abca16dfbc97 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -132,7 +132,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 3fd59e6085a4..889a07450d83 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -56,7 +56,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 2cf99547752a..56fb91fb4b82 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,9 +41,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); + // The static label carries the xs control's height (h-7 sm:h-6) as well as + // its padding: the composer context strip has no min-height of its own, and + // the glass seam joining it to the composer assumes a fixed strip height, so + // a shorter label would drag the seam out of line whenever this label is the + // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2f541f3fe2d5..bf9e0d13af92 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,6 +53,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -1429,7 +1430,10 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { + if (result._tag === "Success") recordVisitForThread(threadRef, url); + return result; + }); }, [openPreview, threadRef], ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 060d549c3cb3..3b77175c1a6f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { @@ -19,7 +19,9 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, pruneOptimisticQueuedMessageIds, @@ -31,6 +33,8 @@ import { shouldRenderServerThreadRoute, shouldTreatServerThreadAsActive, resolveServerThreadError, + scheduleEnvironmentReconnectWarning, + startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -40,6 +44,42 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("environment reconnect warning grace", () => { + afterEach(() => vi.useRealTimers()); + + it("shows a persistent reconnect after the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + scheduleEnvironmentReconnectWarning(showWarning); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); + expect(showWarning).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(showWarning).toHaveBeenCalledOnce(); + }); + + it("cancels the warning when the connection recovers during the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + const cancel = scheduleEnvironmentReconnectWarning(showWarning); + cancel(); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + + expect(showWarning).not.toHaveBeenCalled(); + }); + + it("does not reuse elapsed grace from another environment", () => { + const anotherEnvironmentId = EnvironmentId.make("environment-remote"); + + expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); + expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( + false, + ); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 420911af65e6..1b11364c419c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -26,9 +26,22 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { + const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + return () => globalThis.clearTimeout(timeoutId); +} + +export function hasEnvironmentReconnectWarningGraceElapsed( + activeEnvironmentId: EnvironmentId | null, + elapsedEnvironmentId: EnvironmentId | null, +): boolean { + return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0858822694c4..6cfe9732f5e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -186,18 +186,25 @@ import { listQueuedThreadTurns, removeQueuedThreadTurn, } from "../threadTurnOutbox"; +import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + useEnvironmentSettings, +} from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { + derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -293,6 +300,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -1583,6 +1592,7 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1618,8 +1628,11 @@ function ChatViewContent(props: ChatViewProps) { return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ @@ -1741,6 +1754,8 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const clientSettingsHydrated = useClientSettingsHydrated(); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -1779,11 +1794,54 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; + // Reuse the sidebar's grouping so history follows the project rows the user + // sees. Deriving the key from the active project alone would miss the + // identity a duplicate row borrows from its siblings. + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + useBrowserHistoryStore + .getState() + .registerThreadProject( + activeThreadRef, + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), + ); + }, [ + activeProject, + activeThreadRef, + allProjects, + clientSettingsHydrated, + primaryEnvironmentId, + projectGroupingSettings, + ]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" + ? (activeEnvironment?.environmentId ?? null) + : null; + const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = + useState(null); + const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + useEffect(() => { + setReconnectWarningGraceElapsedEnvironmentId(null); + if (activeReconnectingEnvironmentId === null) return; + return scheduleEnvironmentReconnectWarning(() => + setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), + ); + }, [activeReconnectingEnvironmentId]); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1876,7 +1934,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2081,7 +2138,9 @@ function ChatViewContent(props: ChatViewProps) { // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; + const suppressUnavailableBanner = + environmentReconnecting && + (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ @@ -2211,6 +2270,7 @@ function ChatViewContent(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx new file mode 100644 index 000000000000..a7d58f326708 --- /dev/null +++ b/apps/web/src/components/LegacySidebar.tsx @@ -0,0 +1,6029 @@ +import { + ArchiveIcon, + ArrowUpDownIcon, + BotIcon, + ChevronDownIcon, + ChevronRightIcon, + CloudIcon, + ContainerIcon, + EllipsisVerticalIcon, + FolderIcon, + FolderPlusIcon, + Globe2Icon, + LayersIcon, + ListFilterIcon, + LoaderIcon, + MessageSquareIcon, + PinIcon, + PlusIcon, + SearchIcon, + ServerIcon, + SettingsIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, + Undo2Icon, +} from "lucide-react"; +import { getDriverOption } from "./settings/providerDriverMeta"; +import { AiUsageStats } from "./chat/AiUsageStats"; + +import { + ComposerDraftDot, + prStatusIndicator, + PrStatusTooltipContent, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, + ThreadWorktreeIndicator, +} from "./ThreadStatusIndicators"; +import { ThreadIdentityMark } from "./identity/ParticipantStack"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; +import { hasComposerDraftMessage, useComposerDraftStore } from "../composerDraftStore"; +import { ProjectFavicon, ProjectFaviconFallback } from "./ProjectFavicon"; +import { useAtomValue } from "@effect/atom-react"; +import { autoAnimate } from "@formkit/auto-animate"; +import React, { useCallback, useContext, useEffect, memo, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; +import { + type ContextMenuItem, + type EnvironmentId, + ProjectId, + type ScopedThreadRef, + type ResolvedKeybindingsConfig, + type SidebarProjectGroupingMode, + ThreadId, +} from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + type SidebarProjectSortOrder, + type SidebarThreadPreviewCount, + type SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; +import { isElectron } from "../env"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { cn, isMacPlatform } from "../lib/utils"; +import { + readEnvironmentSupportsSettlement, + readThreadShell, + useProject, + useProjects, + useServerConfigs, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; +import { previewEnvironment } from "../state/preview"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { readLocalApi } from "../localApi"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; +import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot"; +import { resolveThreadModelPresentation } from "../threadModelPresentation"; +import { + hasUsageMarker, + resolveDriverUsage, + usageDotFillClass, + usageDotRingColor, +} from "../aiUsageState"; + +import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { Kbd } from "./ui/kbd"; +import { + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { + Menu, + MenuCheckboxItem, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "./ui/number-field"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "./ui/sidebar"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; +import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, + getSidebarThreadIdsToPrewarm, + resolveAdjacentThreadId, + isContextMenuPointerDown, + isTrailingDoubleClick, + resolveProjectStatusIndicator, + resolveSidebarProjectBadgeColorIndex, + resolveSidebarProjectBadgeLabel, + resolveThreadRowClassName, + resolveThreadStatusPill, + isThreadSettledForDisplay, + orderItemsByPreferredIds, + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, + groupSettledThreadsByRecencyForSidebarV2, + resolveSettledTimestamp, + shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + sortSettledThreadsForSidebar, + useThreadJumpHintVisibility, + ThreadStatusPill, +} from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { CommandDialogTrigger } from "./ui/command"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { + DEFAULT_HIDE_SETTLED_PROJECTS, + DEFAULT_HIDE_SETTLED_RECENT, + DEFAULT_SIDEBAR_OWNERSHIP_FILTER, + DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, + DEFAULT_WEB_LIST_MODE, + DEFAULT_WEB_THREAD_GROUPING, + EMPTY_LIST_ENVIRONMENT_FILTER, + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, + LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, + LIST_MODE_STORAGE_KEY, + LIST_PROJECT_FILTER_ALL, + LIST_PROJECT_FILTER_STORAGE_KEY, + LIST_THREAD_GROUPING_STORAGE_KEY, + ListEnvironmentFilterSchema, + ListHideSettledSchema, + ListProjectFilterSchema, + SIDEBAR_OWNERSHIP_FILTER_LABELS, + SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, + SIDEBAR_OWNERSHIP_FILTERS, + SIDEBAR_OWNERSHIP_RELATION_LABELS, + SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, + SIDEBAR_OWNERSHIP_RELATIONS, + SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, + WEB_LIST_MODE_LABELS, + WEB_LIST_MODES, + WEB_THREAD_GROUPING_LABELS, + WEB_THREAD_GROUPINGS, + WebListModeSchema, + WebThreadGroupingSchema, + defaultThreadGroupingFromLegacyModeStorage, + isAllEnvironmentsSelected, + isEnvironmentSelected, + isWebListMode, + isWebThreadGrouping, + matchesEnvironmentFilter, + parseSidebarOwnershipFilter, + resolveSelectedEnvironmentIds, + toggleEnvironmentId, + usesFlatThreadGrouping, + usesProjectThreadGrouping, + type SidebarOwnershipFilter, + type WebListMode, + type WebThreadGrouping, +} from "./listEnvironmentFilter"; +import { + claimPersonIdForEnvironment, + DEFAULT_OWNERSHIP_RELATION, + isOwnershipRelation, + threadMatchesMine, + type OwnershipRelation, +} from "@t3tools/client-runtime/state/identity"; +import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; +import { + groupSortedThreadsByRecency, + shouldShowRecencySectionHeaders, +} from "@t3tools/client-runtime/state/thread-recency-groups"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; + +/** + * Active sidebar rows report resolved PR state upward so hide-settled / + * settled-shelf classification can auto-settle merged/closed PRs the same way + * Sidebar V2 and the board do. Settled history rows skip reporting. + */ +type SidebarChangeRequestStateReporter = ( + threadKey: string, + state: "open" | "closed" | "merged" | null, +) => void; +const noopSidebarChangeRequestStateReporter: SidebarChangeRequestStateReporter = () => {}; +const SidebarChangeRequestStateContext = React.createContext( + noopSidebarChangeRequestStateReporter, +); + +/** + * Reveal provider details while Command/Control is held when the compact + * sidebar setting normally hides them. + */ +function useModifierRevealHeld(enabled: boolean): boolean { + const [held, setHeld] = useState(false); + + useEffect(() => { + if (!enabled) { + setHeld(false); + return; + } + + const onKey = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey); + const onBlur = () => setHeld(false); + + window.addEventListener("keydown", onKey, true); + window.addEventListener("keyup", onKey, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("keyup", onKey, true); + window.removeEventListener("blur", onBlur); + }; + }, [enabled]); + + return enabled && held; +} + +const SIDEBAR_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", +}; +const SIDEBAR_THREAD_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", +}; +const SIDEBAR_LIST_ANIMATION_OPTIONS = { + duration: 180, + easing: "ease-out", +} as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; +const SIDEBAR_ICON_ACTION_BUTTON_CLASS = + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + +function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { + return Math.min( + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), + ) as SidebarThreadPreviewCount; +} + +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.title; + } + + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; +} + +function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { + return [ + project.projectKey, + ...project.memberProjects.map((member) => member.physicalProjectKey), + ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + +function buildThreadJumpLabelMap(input: { + keybindings: ResolvedKeybindingsConfig; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +interface SidebarThreadRowProps { + thread: SidebarThreadSummary; + projectCwd: string | null; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; + jumpLabel: string | null; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; +} + +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasDraft = useComposerDraftStore((state) => + hasComposerDraftMessage(state.draftsByThreadKey[threadKey]), + ); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const isMobile = useIsMobile(); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const environment = useEnvironment(thread.environmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = environment?.label ?? null; + // A desktop-local secondary backend (e.g. the WSL backend) shows up as a + // bearer environment whose connection id is prefixed "local:". It runs on the + // user's own machine, so the cloud icon is misleading — label it "Local" and + // suppress the cloud icon (the project header already shows a container icon + // for desktop-local projects, see sidebarProjectGrouping). + const isDesktopLocalThread = + environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProject = useProject( + useMemo( + () => scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.listStatus({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data ?? null, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Lift PR state so parent hide-settled / shelf classification can auto-settle + // merged/closed PRs (matches Sidebar V2 row reporting). + const onChangeRequestState = useContext(SidebarChangeRequestStateContext); + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) — only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + const hasSelection = useThreadSelectionStore.getState().hasSelection(); + if (hasSelection && isSelected) { + void (async () => { + const result = await settlePromise(() => + handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + return; + } + + if (hasSelection) { + clearSelection(); + } + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
, []); + + return ( + + +
+ {threadStatus && } + {renamingThreadKey === threadKey ? ( + + ) : ( + <> + + + {thread.title} + + } + /> + + {thread.title} + + + + + )} + {hasDraft ? : null} + {prStatus && pr ? ( + + + } + > + #{pr.number} + + + + + + ) : null} +
+
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} + + {terminalStatus && ( + + + } + > + + + {terminalStatus.label} + + )} +
+ {isConfirmingArchive ? ( + + ) : !isThreadRunning ? ( + appSettingsConfirmThreadArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + ) + ) : null} + + + {isRemoteThread && !isDesktopLocalThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} + + +
+
+ + + ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; +} + +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + + + + { + if (!open) { + closeProjectRenameDialog(); + } + }} + > + + + Rename project + + {projectRenameTarget + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` + : "Update the project title."} + + + +
+ Project title + setProjectRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitProjectRename(); + } + }} + /> +
+ {projectRenameTarget?.environmentLabel ? ( +

+ Environment: {projectRenameTarget.environmentLabel} +

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

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

+
+ + + + +
+
+ + ); +}); + +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); + +function LocalSecondaryStatus() { + const { environments } = useEnvironments(); + // The desktop reports which local secondary backends (e.g. the WSL backend) + // exist; the hook polls because the bridge has no change event. A backend that + // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we + // surface "Connecting" straight from the bootstrap list and clear it once the + // matching environment reports a connected phase. + const secondaries = useDesktopLocalBootstraps(); + + // Connected desktop-local environments keyed by their backend URL so we can + // match a bootstrap (which only knows the URL) to its connection phase. + const localEnvByUrl = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + if ( + isDesktopLocalConnectionTarget(environment.entry.target) && + environment.displayUrl !== null + ) { + map.set(environment.displayUrl, { + phase: environment.connection.phase, + error: environment.connection.error, + }); + } + } + return map; + }, [environments]); + + const connecting: string[] = []; + const failed: Array<{ label: string; error: string | null }> = []; + for (const bootstrap of secondaries) { + const env = + bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; + if (env?.phase === "connected") { + continue; + } + if (env?.phase === "error") { + failed.push({ label: bootstrap.label, error: env.error }); + continue; + } + connecting.push(bootstrap.label); + } + + if (connecting.length === 0 && failed.length === 0) { + return null; + } + + return ( + + {connecting.length > 0 ? ( + + + + Connecting {connecting.join(", ")} + + + ) : null} + {failed.length > 0 ? ( + + + Couldn't connect {failed.map((entry) => entry.label).join(", ")} + + {failed + .map((entry) => entry.error) + .filter(Boolean) + .join("; ") || "The backend didn't respond."} + + + ) : null} + + ); +} + +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + threadPreviewCount, + onProjectSortOrderChange, + onThreadSortOrderChange, + onThreadPreviewCountChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; +}) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + + return ( + + + + } + > + + + Sidebar options + + + +
+ Sort projects +
+ { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
+ +
+ Sort threads +
+ { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
+ +
+ Visible threads +
+
+ + + + { + event.stopPropagation(); + }} + /> + + + +
+
+
+
+ ); +} + +function SortableProjectItem({ + projectId, + disabled = false, + children, +}: { + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); +} + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + updateSettings: ReturnType; + openAddProject: () => void; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + settleThread: ReturnType["settleThread"]; + unsettleThread: ReturnType["unsettleThread"]; + sortedProjects: readonly SidebarProjectSnapshot[]; + recentThreads: readonly SidebarRecentThread[]; + threadByKey: ReadonlyMap; + navigateToThread: (threadRef: ScopedThreadRef) => void; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; + listMode: WebListMode; + onListModeChange: (mode: WebListMode) => void; + threadGrouping: WebThreadGrouping; + onThreadGroupingChange: (grouping: WebThreadGrouping) => void; + environmentFilterOptions: readonly { environmentId: EnvironmentId; label: string }[]; + selectedEnvironmentIds: readonly EnvironmentId[]; + onSelectedEnvironmentIdsChange: (next: readonly EnvironmentId[]) => void; + projectFilterOptions: readonly { + projectKey: string; + displayName: string; + environmentId: EnvironmentId; + workspaceRoot: string; + }[]; + selectedProjectFilterKey: string | null; + onSelectedProjectFilterKeyChange: (key: string | null) => void; + ownershipFilter: SidebarOwnershipFilter; + onOwnershipFilterChange: (filter: SidebarOwnershipFilter) => void; + ownershipRelation: OwnershipRelation; + onOwnershipRelationChange: (relation: OwnershipRelation) => void; + claimPersonIdByEnvironment: ReadonlyMap; + hideSettledThreads: boolean; + onHideSettledThreadsChange: (hide: boolean) => void; + settledThreadKeys: ReadonlySet; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +interface SidebarRecentThread { + thread: SidebarThreadSummary; + project: SidebarProjectSnapshot; +} + +const RECENT_PROJECT_BADGE_CLASSES = [ + "bg-blue-500/12 text-blue-700 dark:text-blue-300", + "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", + "bg-violet-500/12 text-violet-700 dark:text-violet-300", + "bg-amber-500/14 text-amber-700 dark:text-amber-300", + "bg-rose-500/12 text-rose-700 dark:text-rose-300", + "bg-cyan-500/12 text-cyan-700 dark:text-cyan-300", +] as const; + +const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { + entry: SidebarRecentThread; + isActive: boolean; + jumpLabel: string | null; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + settleThread: ReturnType["settleThread"]; + unsettleThread: ReturnType["unsettleThread"]; + isSettled: boolean; + orderedRecentThreadKeys: readonly string[]; + threadByKey: ReadonlyMap; +}) { + const { project, thread } = props.entry; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasDraft = useComposerDraftStore((state) => + hasComposerDraftMessage(state.draftsByThreadKey[threadKey]), + ); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const serverConfigs = useServerConfigs(); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const environment = useEnvironment(thread.environmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const openPrLink = useOpenPrLink(); + const confirmThreadArchive = useClientSettings( + (settings) => settings.confirmThreadArchive, + ); + const hideProviderIcons = useClientSettings( + (settings) => settings.sidebarHideProviderIcons ?? false, + ); + const revealHeld = useModifierRevealHeld(hideProviderIcons); + const [confirmingArchive, setConfirmingArchive] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(thread.title); + const renameInputRef = useRef(null); + const renameCommitStartedRef = useRef(false); + const confirmThreadDelete = useClientSettings( + (settings) => settings.confirmThreadDelete, + ); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const isPinned = useUiStateStore((state) => state.pinnedThreadKeys.includes(threadKey)); + const toggleThreadPinned = useUiStateStore((state) => state.toggleThreadPinned); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => + toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }), + }); + const { copyToClipboard: copyPath } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => + toastManager.add({ type: "success", title: "Path copied", description: path }), + }); + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt } }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const isDesktopLocalThread = + environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); + const gitCwd = thread.worktreePath ?? project.workspaceRoot; + // Settled shelf rows match Sidebar V2 history: no list VCS subscription + // (PR auto-settle already applied or isn't needed; badges aren't live on history). + const gitStatus = useEnvironmentQuery( + !props.isSettled && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.listStatus({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data ?? null, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report PR state for partition; settled history keeps last reported value. + const onChangeRequestState = useContext(SidebarChangeRequestStateContext); + const prState = pr?.state ?? null; + useEffect(() => { + if (props.isSettled) return; + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, props.isSettled, threadKey]); + const threadModelPresentation = useMemo( + () => + resolveThreadModelPresentation( + thread.modelSelection, + serverConfigs.get(thread.environmentId), + ), + [serverConfigs, thread.environmentId, thread.modelSelection], + ); + const ProviderIcon = + getDriverOption(threadModelPresentation.driverKind ?? undefined)?.icon ?? BotIcon; + const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId); + const threadUsage = useMemo( + () => + resolveDriverUsage( + aiUsageSnapshot, + threadModelPresentation.driverKind, + thread.modelSelection.model, + ), + [aiUsageSnapshot, thread.modelSelection.model, threadModelPresentation.driverKind], + ); + const usageDotClass = threadUsage ? usageDotFillClass(threadUsage.marker) : undefined; + const usageRingColor = threadUsage ? usageDotRingColor(threadUsage.marker) : undefined; + const showProviderIcon = !hideProviderIcons || revealHeld; + const showProviderMarker = + showProviderIcon || (threadUsage && hasUsageMarker(threadUsage.marker)); + const badgeColorClass = + RECENT_PROJECT_BADGE_CLASSES[ + resolveSidebarProjectBadgeColorIndex(project.projectKey, RECENT_PROJECT_BADGE_CLASSES.length) + ]; + const settledTimestamp = resolveSettledTimestamp(thread); + const settledTimeLabel = + settledTimestamp === null + ? "" + : (() => { + const label = formatRelativeTimeLabel(settledTimestamp); + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; + })(); + + const attemptArchive = useCallback(() => { + setConfirmingArchive(false); + void props.archiveThread(threadRef).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + }, [props, threadRef]); + + const createThreadFromRecent = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const worktreePath = thread.worktreePath?.trim(); + void props.handleNewThread( + scopeProjectRef(thread.environmentId, thread.projectId), + worktreePath + ? { + ...(thread.branch !== null ? { branch: thread.branch } : {}), + worktreePath, + envMode: "local", + } + : { + ...(thread.branch !== null ? { branch: thread.branch } : {}), + envMode: "worktree", + }, + ); + }, + [props, thread], + ); + + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + props.navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port, openPreview }); + }, + [discoveredPorts, openPreview, props.navigateToThread, threadRef], + ); + + const commitRename = useCallback(async () => { + if (renameCommitStartedRef.current) return; + renameCommitStartedRef.current = true; + const trimmed = renamingTitle.trim(); + setIsRenaming(false); + if (!trimmed) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === thread.title) return; + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [renamingTitle, thread.environmentId, thread.id, thread.title, updateThreadMetadata]); + + const handleContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + void (async () => { + const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (selectedThreadKeys.length > 0 && isSelected) { + const count = selectedThreadKeys.length; + const selectedAction = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + { x: event.clientX, y: event.clientY }, + ); + if (selectedAction === "mark-unread") { + for (const selectedThreadKey of selectedThreadKeys) { + const selectedThread = props.threadByKey.get(selectedThreadKey); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + } else if (selectedAction === "delete") { + if ( + confirmThreadDelete && + !(await api.dialogs.confirm( + `Delete ${count} thread${count === 1 ? "" : "s"}?\nThis permanently clears conversation history for these threads.`, + )) + ) { + return; + } + const deletedThreadKeys = new Set(selectedThreadKeys); + for (const selectedThreadKey of selectedThreadKeys) { + const selectedThread = props.threadByKey.get(selectedThreadKey); + if (!selectedThread) continue; + const result = await props.deleteThread( + scopeThreadRef(selectedThread.environmentId, selectedThread.id), + { deletedThreadKeys }, + ); + if (result._tag === "Failure") return; + } + removeFromSelection(selectedThreadKeys); + } + return; + } + if (selectedThreadKeys.length > 0) clearSelection(); + const supportsSettlement = readEnvironmentSupportsSettlement(thread.environmentId); + const clicked = await api.contextMenu.show( + [ + ...(supportsSettlement + ? [ + props.isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + ] + : []), + { id: "pin", label: isPinned ? "Unpin thread" : "Pin thread" }, + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "settle" || clicked === "unsettle") { + const result = + clicked === "settle" + ? await props.settleThread(threadRef) + : await props.unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: + clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", + description: message, + }), + ); + } + } else if (clicked === "pin") { + toggleThreadPinned(threadKey); + } else if (clicked === "rename") { + renameCommitStartedRef.current = false; + setRenamingTitle(thread.title); + setIsRenaming(true); + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + } else if (clicked === "mark-unread") { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + } else if (clicked === "copy-path") { + copyPath(gitCwd, { path: gitCwd }); + } else if (clicked === "copy-thread-id") { + copyThreadId(thread.id, { threadId: thread.id }); + } else if (clicked === "delete") { + if ( + confirmThreadDelete && + !(await api.dialogs.confirm( + `Delete thread "${thread.title}"?\nThis permanently clears conversation history for this thread.`, + )) + ) { + return; + } + const result = await props.deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } + })(); + }, + [ + confirmThreadDelete, + clearSelection, + copyPath, + copyThreadId, + gitCwd, + isPinned, + isSelected, + markThreadUnread, + props, + removeFromSelection, + thread, + threadKey, + threadRef, + toggleThreadPinned, + ], + ); + + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isModClick = isMacPlatform(navigator.platform) ? event.metaKey : event.ctrlKey; + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, props.orderedRecentThreadKeys); + return; + } + if (isTrailingDoubleClick(event.detail)) return; + props.navigateToThread(threadRef); + }, + [ + props.navigateToThread, + props.orderedRecentThreadKeys, + rangeSelectTo, + threadKey, + threadRef, + toggleThreadSelection, + ], + ); + + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + setRenamingTitle(thread.title); + setIsRenaming(true); + renameCommitStartedRef.current = false; + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + }, + [thread.title], + ); + + const handleUnsettleClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void props.unsettleThread(threadRef).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + }, + [props, threadRef], + ); + + // Settled shelf: Sidebar V2 slim history chrome (dimmed favicon, muted + // title, settle-time label, un-settle on hover) — not the dense inbox row. + if (props.isSettled) { + const supportsSettlement = readEnvironmentSupportsSettlement(thread.environmentId); + return ( + + } + size="sm" + isActive={props.isActive} + data-testid={`recent-thread-${thread.id}`} + className={cn( + resolveThreadRowClassName({ + isActive: props.isActive, + isSelected, + }), + "relative isolate min-h-9 items-center gap-2.5 py-1.5", + )} + onClick={handleRowClick} + onDoubleClick={handleRowDoubleClick} + onContextMenu={handleContextMenu} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.navigateToThread(threadRef); + }} + > + + + +
    +
    + {isRenaming ? ( + setRenamingTitle(event.target.value)} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } else if (event.key === "Escape") { + setIsRenaming(false); + } + }} + onBlur={() => void commitRename()} + /> + ) : ( + <> + + {thread.title} + + + + )} + {hasDraft ? : null} +
    + + {project.displayName} + {environment?.label ? ( + <> + + · + + + {isRemoteThread ? ( + + ) : null} + {environment.label} + + + ) : null} + +
    + + + {settledTimeLabel} + + {supportsSettlement ? ( + + ) : null} + + {props.jumpLabel ? ( + + {props.jumpLabel} + + ) : null} +
    +
    + ); + } + + return ( + setConfirmingArchive(false)} + > + } + size="sm" + isActive={props.isActive} + data-testid={`recent-thread-${thread.id}`} + className={`${resolveThreadRowClassName({ + isActive: props.isActive, + isSelected, + })} relative isolate`} + onClick={handleRowClick} + onDoubleClick={handleRowDoubleClick} + onContextMenu={handleContextMenu} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.navigateToThread(threadRef); + }} + > +
    + + + } + > + {resolveSidebarProjectBadgeLabel(project.displayName)} + + {project.displayName} + +
    +
    + {threadStatus ? : null} + {isRenaming ? ( + setRenamingTitle(event.target.value)} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } else if (event.key === "Escape") { + setIsRenaming(false); + } + }} + onBlur={() => void commitRename()} + /> + ) : ( + <> + {thread.title} + + + )} + {hasDraft ? : null} + {prStatus && pr ? ( + + openPrLink(event, prStatus.url)} + /> + } + > + #{pr.number} + + + + + + ) : null} +
    + {/* Cross-project recency rows: project · server, matching mobile + + Sidebar V2's environment context (icon when remote). */} + + {project.displayName} + {environment?.label ? ( + <> + + · + + + {isRemoteThread ? ( + + ) : null} + {environment.label} + + + ) : null} + +
    +
    +
    + + handleContextMenu(event)} + /> + } + > + + + Thread actions + + {isPinned ? ( + + { + event.preventDefault(); + event.stopPropagation(); + toggleThreadPinned(threadKey); + }} + /> + } + > + + + Unpin thread + + ) : null} + {discoveredPorts.length > 0 ? ( + + + } + > + + + Open localhost:{discoveredPorts[0]?.port} + + ) : null} + {props.jumpLabel ? ( + + + } + > + {props.jumpLabel} + + {props.jumpLabel} + + ) : null} + + {terminalStatus ? ( + + + } + > + + + {terminalStatus.label} + + ) : null} + {showProviderMarker ? ( + + + } + > + {showProviderIcon ? : null} + {usageDotClass ? ( + + ) : null} + + + {threadUsage ? ( +
    + {threadModelPresentation.tooltip} + +
    + ) : ( + threadModelPresentation.tooltip + )} +
    +
    + ) : null} +
    + {/* Trailing remote cue kept for parity with project-thread rows; + subtitle already names the server when the label is available. */} + {isRemoteThread && !isDesktopLocalThread && !environment?.label ? ( + + + } + > + + + Remote + + ) : null} + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + {!isThreadRunning ? ( + confirmingArchive ? ( + + ) : ( + + { + event.preventDefault(); + event.stopPropagation(); + if (confirmThreadArchive) setConfirmingArchive(true); + else attemptArchive(); + }} + /> + } + > + + + Archive thread + + ) + ) : null} +
    +
    +
    +
    + ); +}); + +const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { + recentThreads: readonly SidebarRecentThread[]; + /** + * When true, partition by recency. Section headers render only when more + * than one non-empty bucket is present (Last Hour / Earlier Today / …). + */ + groupByRecency: boolean; + /** + * When true, settled threads leave the main list and sit in a collapsible + * shelf at the bottom (same idea as Sidebar V2 — out of the way, never gone). + */ + hideSettledThreads: boolean; + routeThreadKey: string | null; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + settleThread: ReturnType["settleThread"]; + unsettleThread: ReturnType["unsettleThread"]; + settledThreadKeys: ReadonlySet; + threadJumpLabelByKey: ReadonlyMap; + threadByKey: ReadonlyMap; +}) { + const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, + ListHideSettledSchema, + ); + const [settledRecencyHeadersEnabled] = useLocalStorage( + SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, + DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, + ListHideSettledSchema, + ); + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const nowMinute = useNowMinute(); + + const { activeEntries, settledEntries } = useMemo(() => { + if (!props.hideSettledThreads) { + return { + activeEntries: props.recentThreads, + settledEntries: [] as SidebarRecentThread[], + }; + } + const active: SidebarRecentThread[] = []; + const settled: SidebarRecentThread[] = []; + for (const entry of props.recentThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(entry.thread.environmentId, entry.thread.id), + ); + if (props.settledThreadKeys.has(threadKey)) { + settled.push(entry); + } else { + active.push(entry); + } + } + // Settled is history: order by when work ended, matching V2 shelf sort. + if (settled.length <= 1) { + return { activeEntries: active, settledEntries: settled }; + } + const sortedThreads = sortSettledThreadsForSidebar(settled.map((entry) => entry.thread)); + const entryByKey = new Map( + settled.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), + ); + return { + activeEntries: active, + settledEntries: sortedThreads.flatMap((thread) => { + const entry = entryByKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [entry] : []; + }), + }; + }, [props.hideSettledThreads, props.recentThreads, props.settledThreadKeys]); + + // When hide-settled turns off or the settled tail empties, drop a deep page + // so the next shelf open starts from the initial window again. + const settledPagingActive = props.hideSettledThreads && settledEntries.length > 0; + const lastSettledPagingActiveRef = useRef(settledPagingActive); + if (lastSettledPagingActiveRef.current !== settledPagingActive) { + lastSettledPagingActiveRef.current = settledPagingActive; + if (!settledPagingActive && settledVisibleCount !== SETTLED_TAIL_INITIAL_COUNT) { + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + } + + const pagedSettledEntries = useMemo(() => { + if (settledEntries.length <= settledVisibleCount) return settledEntries; + const visible = settledEntries.slice(0, settledVisibleCount); + // Open thread must stay reachable under "Show more". + if (props.routeThreadKey !== null) { + const routeEntry = settledEntries + .slice(settledVisibleCount) + .find( + (entry) => + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === + props.routeThreadKey, + ); + if (routeEntry !== undefined) visible.push(routeEntry); + } + return visible; + }, [props.routeThreadKey, settledEntries, settledVisibleCount]); + + const renderedSettledEntries = useMemo(() => { + if (!props.hideSettledThreads || settledEntries.length === 0) return []; + if (settledShelfExpanded) return pagedSettledEntries; + if (props.routeThreadKey === null) return []; + const routeEntry = pagedSettledEntries.find( + (entry) => + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === + props.routeThreadKey, + ); + return routeEntry === undefined ? [] : [routeEntry]; + }, [ + pagedSettledEntries, + props.hideSettledThreads, + props.routeThreadKey, + settledEntries.length, + settledShelfExpanded, + ]); + + const hiddenSettledCount = settledEntries.length - pagedSettledEntries.length; + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + const toggleSettledShelf = useCallback( + () => setSettledShelfExpanded((value) => !value), + [setSettledShelfExpanded], + ); + + // Date headers under Settled (Last Hour / Earlier Today / …) — same helper + // and rules as Sidebar V2. Single-bucket pages omit headers; View menu can + // disable headers without changing settle-time sort order. + const settledRecencyLayout = useMemo(() => { + void nowMinute; + const layout = groupSettledThreadsByRecencyForSidebarV2( + renderedSettledEntries.map((entry) => entry.thread), + new Date(), + ); + if (!settledRecencyHeadersEnabled) { + return { groups: layout.groups, showHeaders: false }; + } + return layout; + }, [nowMinute, renderedSettledEntries, settledRecencyHeadersEnabled]); + + const settledEntryByThreadKey = useMemo( + () => + new Map( + renderedSettledEntries.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), + ), + [renderedSettledEntries], + ); + + // Multi-select range walks rendered rows only (collapsed shelf is out). + const orderedRecentThreadKeys = useMemo( + () => + [...activeEntries, ...renderedSettledEntries].map(({ thread }) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeEntries, renderedSettledEntries], + ); + + if (props.recentThreads.length === 0) { + return ( + +
    No threads
    +
    + ); + } + + const renderThreadRow = (entry: SidebarRecentThread) => { + const threadKey = scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)); + return ( + + ); + }; + + const renderSettledRows = () => { + if (renderedSettledEntries.length === 0) { + return null; + } + if (settledRecencyLayout.showHeaders) { + return ( + <> + {settledRecencyLayout.groups.map((group) => ( +
    +
    + {group.label} +
    + + {group.threads.flatMap((thread) => { + const entry = settledEntryByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [renderThreadRow(entry)] : []; + })} + +
    + ))} + + ); + } + return ( + + {renderedSettledEntries.map(renderThreadRow)} + + ); + }; + + const renderActiveList = () => { + if (activeEntries.length === 0) { + return null; + } + + if (!props.groupByRecency) { + return ( + + + {activeEntries.map(renderThreadRow)} + + + ); + } + + const recencyGroups = groupSortedThreadsByRecency(activeEntries.map((entry) => entry.thread)); + const showSectionHeaders = shouldShowRecencySectionHeaders(recencyGroups); + const entryByThreadKey = new Map( + activeEntries.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), + ); + + // Single non-empty bucket: skip headers (e.g. everything is "Last Hour"). + if (!showSectionHeaders) { + return ( + + + {activeEntries.map(renderThreadRow)} + + + ); + } + + return ( + <> + {recencyGroups.map((group) => ( + +
    + {group.label} +
    + + {group.threads.flatMap((thread) => { + const entry = entryByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [renderThreadRow(entry)] : []; + })} + +
    + ))} + + ); + }; + + const renderSettledShelf = () => { + if (!props.hideSettledThreads || settledEntries.length === 0) { + return null; + } + return ( + + + {renderSettledRows()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( + + ) : null} + + ); + }; + + return ( + <> + {renderActiveList()} + {renderSettledShelf()} + + ); +}); + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + threadPreviewCount, + updateSettings, + openAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + settleThread, + unsettleThread, + sortedProjects, + recentThreads, + threadByKey, + navigateToThread, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + newThreadShortcutLabel, + commandPaletteShortcutLabel, + listMode, + onListModeChange, + threadGrouping, + onThreadGroupingChange, + environmentFilterOptions, + selectedEnvironmentIds, + onSelectedEnvironmentIdsChange, + projectFilterOptions, + selectedProjectFilterKey, + onSelectedProjectFilterKeyChange, + ownershipFilter, + onOwnershipFilterChange, + ownershipRelation, + onOwnershipRelationChange, + claimPersonIdByEnvironment, + hideSettledThreads, + onHideSettledThreadsChange, + settledThreadKeys, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + const showThreadListChrome = listMode === "threads"; + const showProjectGroups = showThreadListChrome && usesProjectThreadGrouping(threadGrouping); + const showFlatOrRecencyList = showThreadListChrome && usesFlatThreadGrouping(threadGrouping); + + const selectedProjectFilterValue = + selectedProjectFilterKey !== null && + projectFilterOptions.some((project) => project.projectKey === selectedProjectFilterKey) + ? selectedProjectFilterKey + : LIST_PROJECT_FILTER_ALL; + + // Dot on the filter button when anything is non-default (active filters / + // non-default grouping or hide-settled). Matches Sidebar V2 “scoped” cues. + const defaultHideSettled = usesProjectThreadGrouping(threadGrouping) + ? DEFAULT_HIDE_SETTLED_PROJECTS + : DEFAULT_HIDE_SETTLED_RECENT; + const [settledRecencyHeadersEnabled, setSettledRecencyHeadersEnabled] = useLocalStorage( + SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, + DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, + ListHideSettledSchema, + ); + const listOptionsActive = + !isAllEnvironmentsSelected(selectedEnvironmentIds) || + selectedProjectFilterKey !== null || + threadGrouping !== DEFAULT_WEB_THREAD_GROUPING || + hideSettledThreads !== defaultHideSettled || + ownershipFilter !== DEFAULT_SIDEBAR_OWNERSHIP_FILTER || + ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || + (showFlatOrRecencyList && + settledRecencyHeadersEnabled !== DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS); + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); + + const { isMobile, setOpenMobile } = useSidebar(); + const canCreateThread = sortedProjects.length > 0; + const scopedNewThreadProject = + selectedProjectFilterKey === null + ? null + : (sortedProjects.find((project) => project.projectKey === selectedProjectFilterKey) ?? null); + // Multi-project: show a project picker menu (especially useful when + // grouping by project). Single project or a project filter: create immediately. + const needsNewThreadProjectMenu = scopedNewThreadProject === null && sortedProjects.length > 1; + + const createThreadInProject = useCallback( + (project: SidebarProjectSnapshot) => { + const member = project.memberProjects[0]; + if (!member) return; + if (isMobile) { + setOpenMobile(false); + } + void settlePromise(() => + handleNewThread(scopeProjectRef(member.environmentId, member.id)), + ).then((result) => { + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + }, + [handleNewThread, isMobile, setOpenMobile], + ); + + const handleHeaderNewThreadClick = useCallback(() => { + if (!canCreateThread) return; + if (scopedNewThreadProject) { + createThreadInProject(scopedNewThreadProject); + return; + } + if (sortedProjects.length === 1) { + createThreadInProject(sortedProjects[0]!); + return; + } + // Multi-project without a scope: prefer the command palette "New thread + // in…" flow (same as Sidebar V2) when not in project grouping; when + // grouping by project we still offer an inline menu below. + if (!usesProjectThreadGrouping(threadGrouping)) { + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); + } + }, [ + canCreateThread, + createThreadInProject, + isMobile, + scopedNewThreadProject, + setOpenMobile, + sortedProjects, + threadGrouping, + ]); + + const newThreadButtonClassName = + "relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-sidebar-muted-foreground outline-none transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"; + + return ( + + + {/* Search + New thread on one row (Sidebar V2 layout). */} +
    +
    + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + +
    + {needsNewThreadProjectMenu ? ( + + + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + + +
    + New thread in +
    + {sortedProjects.map((project) => ( + createThreadInProject(project)} + > + + + {project.displayName} + + + ))} +
    + + { + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); + }} + > + Browse all… + +
    +
    + ) : ( + + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + )} +
    + {/* Compact chrome: Threads|Board + view/filter menu. */} +
    + { + const next = value[0]; + if (isWebListMode(next)) { + onListModeChange(next); + } + }} + data-testid="sidebar-list-mode-switcher" + > + {WEB_LIST_MODES.map((mode) => ( + + {WEB_LIST_MODE_LABELS[mode]} + + ))} + + {showThreadListChrome ? ( + <> + + + + } + > + + {listOptionsActive ? ( + + ) : null} + + View & filters + + + +
    + Group threads +
    + { + if (isWebThreadGrouping(value)) { + onThreadGroupingChange(value); + } + }} + > + {WEB_THREAD_GROUPINGS.map((grouping) => ( + + + {grouping === "recency" ? ( + + ) : grouping === "project" ? ( + + ) : ( + + )} + {WEB_THREAD_GROUPING_LABELS[grouping]} + + + ))} + +
    + + {projectFilterOptions.length > 0 ? ( + <> + + +
    + Project +
    + { + onSelectedProjectFilterKeyChange( + value === LIST_PROJECT_FILTER_ALL ? null : (value as string), + ); + }} + > + + + + All projects + + + {projectFilterOptions.map((project) => ( + + + + {project.displayName} + + + ))} + +
    + + ) : null} + + {environmentFilterOptions.length > 1 ? ( + <> + + +
    + Environment +
    + onSelectedEnvironmentIdsChange([])} + > + All environments + + {environmentFilterOptions.map((environment) => ( + { + onSelectedEnvironmentIdsChange( + toggleEnvironmentId( + selectedEnvironmentIds, + environment.environmentId, + ), + ); + }} + > + {environment.label} + + ))} +
    + + ) : null} + + + +
    + Ownership +
    + { + if (value !== "any" && value !== "mine" && value !== "theirs") return; + onOwnershipFilterChange(value); + }} + > + {SIDEBAR_OWNERSHIP_FILTERS.map((value) => ( + + {SIDEBAR_OWNERSHIP_FILTER_LABELS[value]} + + ))} + +
    + {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( + <> + + +
    + {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} +
    + { + if (!isOwnershipRelation(value)) return; + onOwnershipRelationChange(value); + }} + > + {SIDEBAR_OWNERSHIP_RELATIONS.map((value) => ( + + {SIDEBAR_OWNERSHIP_RELATION_LABELS[value]} + + ))} + +
    + + ) : null} + + onHideSettledThreadsChange(checked === true)} + > + Hide settled + + {showFlatOrRecencyList && hideSettledThreads ? ( + + setSettledRecencyHeadersEnabled(checked === true) + } + > + Date headers on settled + + ) : null} +
    +
    + {showFlatOrRecencyList ? ( + + + } + > + + + Add project + + ) : null} + + ) : null} +
    +
    + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + + {showFlatOrRecencyList ? ( + + ) : null} + {showProjectGroups ? ( + +
    + Projects +
    + + + + } + > + + + Add project + +
    +
    + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 ? ( +
    + No projects yet +
    + ) : sortedProjects.length === 0 ? ( +
    + No projects in selected environments +
    + ) : null} +
    + ) : null} + {listMode === "board" ? ( + +
    + Board view is open in the main panel +
    +
    + ) : null} +
    + ); +}); + +export default function LegacySidebar() { + const pathname = useLocation({ select: (loc) => loc.pathname }); + const projects = useProjects(); + const sidebarThreads = useThreadShells(); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const updateSettings = useUpdateClientSettings(); + const handleNewThread = useNewThreadHandler(); + const { archiveThread, deleteThread, settleThread, unsettleThread } = useThreadActions(); + const serverConfigs = useServerConfigs(); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const nowMinute = useNowMinute(); + const { isMobile, setOpenMobile } = useSidebar(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const desktopUpdateState = useDesktopUpdateState(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [storedListMode, setStoredListMode] = useLocalStorage( + LIST_MODE_STORAGE_KEY, + DEFAULT_WEB_LIST_MODE, + WebListModeSchema, + ); + const defaultThreadGrouping = useMemo(() => { + if (typeof window === "undefined") return DEFAULT_WEB_THREAD_GROUPING; + try { + return defaultThreadGroupingFromLegacyModeStorage( + window.localStorage.getItem(LIST_MODE_STORAGE_KEY), + ); + } catch { + return DEFAULT_WEB_THREAD_GROUPING; + } + }, []); + const [storedThreadGrouping, setStoredThreadGrouping] = useLocalStorage( + LIST_THREAD_GROUPING_STORAGE_KEY, + defaultThreadGrouping, + WebThreadGroupingSchema, + ); + const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + EMPTY_LIST_ENVIRONMENT_FILTER, + ListEnvironmentFilterSchema, + ); + const [storedProjectFilter, setStoredProjectFilter] = useLocalStorage( + LIST_PROJECT_FILTER_STORAGE_KEY, + null as string | null, + ListProjectFilterSchema, + ); + const [hideSettledRecent, setHideSettledRecent] = useLocalStorage( + LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, + DEFAULT_HIDE_SETTLED_RECENT, + ListHideSettledSchema, + ); + const [hideSettledProjects, setHideSettledProjects] = useLocalStorage( + LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, + DEFAULT_HIDE_SETTLED_PROJECTS, + ListHideSettledSchema, + ); + const [ownershipFilter, setOwnershipFilter] = useState(() => { + try { + return parseSidebarOwnershipFilter( + window.localStorage.getItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY), + ); + } catch { + return DEFAULT_SIDEBAR_OWNERSHIP_FILTER; + } + }); + const handleOwnershipFilterChange = useCallback((filter: SidebarOwnershipFilter) => { + setOwnershipFilter(filter); + try { + window.localStorage.setItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, filter); + } catch { + // ignore + } + }, []); + const [ownershipRelation, setOwnershipRelation] = useState(() => { + try { + const raw = window.localStorage.getItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY); + if (isOwnershipRelation(raw)) return raw; + } catch { + // ignore + } + return DEFAULT_OWNERSHIP_RELATION; + }); + const handleOwnershipRelationChange = useCallback((relation: OwnershipRelation) => { + setOwnershipRelation(relation); + try { + window.localStorage.setItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, relation); + } catch { + // ignore + } + }, []); + const hideSettledThreads = usesProjectThreadGrouping(storedThreadGrouping) + ? hideSettledProjects + : hideSettledRecent; + const handleHideSettledThreadsChange = useCallback( + (hide: boolean) => { + if (usesProjectThreadGrouping(storedThreadGrouping)) { + setHideSettledProjects(hide); + return; + } + setHideSettledRecent(hide); + }, + [setHideSettledProjects, setHideSettledRecent, storedThreadGrouping], + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const selectedEnvironmentIds = useMemo( + () => + resolveSelectedEnvironmentIds( + storedEnvironmentFilter as readonly EnvironmentId[], + availableEnvironmentIds, + ), + [availableEnvironmentIds, storedEnvironmentFilter], + ); + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const environmentFilterOptions = useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })), + [environments], + ); + const handleListModeChange = useCallback( + (mode: WebListMode) => { + setStoredListMode(mode); + if (mode === "board") { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/board" }); + return; + } + if (pathname === "/board") { + void navigate({ to: "/" }); + } + }, + [isMobile, navigate, pathname, setOpenMobile, setStoredListMode], + ); + const handleSelectedEnvironmentIdsChange = useCallback( + (next: readonly EnvironmentId[]) => { + setStoredEnvironmentFilter([...next]); + }, + [setStoredEnvironmentFilter], + ); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + }, [projectOrder, projects]); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); + + const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }); + }, [ + environmentLabelById, + desktopLocalEnvironmentIds, + orderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }), + [routeTerminalOpen], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); + reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); + }, + [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const visibleThreads = useMemo( + () => + sidebarThreads.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: ownershipFilter, + relation: ownershipRelation, + }), + ), + [ + claimPersonIdByEnvironment, + ownershipFilter, + ownershipRelation, + selectedEnvironmentIds, + sidebarThreads, + ], + ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + if (!resolvedProject) { + return []; + } + if ( + !resolvedProject.memberProjects.some((member) => + matchesEnvironmentFilter(member.environmentId, selectedEnvironmentIds), + ) + ) { + return []; + } + return [resolvedProject]; + }); + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + selectedEnvironmentIds, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next classification pass — same + // path Sidebar V2 and the board use so hide-settled matches across surfaces. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const settledThreadKeys = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const keys = new Set(); + for (const thread of visibleThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if ( + isThreadSettledForDisplay(thread, { + serverConfigs, + now, + autoSettleAfterDays, + changeRequestState: changeRequestStateByKey.get(threadKey) ?? null, + }) + ) { + keys.add(threadKey); + } + } + return keys; + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs, visibleThreads]); + const selectedProjectFilterKey = + storedProjectFilter !== null && + sortedProjects.some((project) => project.projectKey === storedProjectFilter) + ? storedProjectFilter + : null; + const projectFilteredProjects = useMemo( + () => + selectedProjectFilterKey === null + ? sortedProjects + : sortedProjects.filter((project) => project.projectKey === selectedProjectFilterKey), + [selectedProjectFilterKey, sortedProjects], + ); + const projectFilterOptions = useMemo( + () => + sortedProjects.map((project) => ({ + projectKey: project.projectKey, + displayName: project.displayName, + environmentId: project.environmentId, + workspaceRoot: project.workspaceRoot, + })), + [sortedProjects], + ); + /** + * Flat/recency groupings: unarchived threads sorted by latest activity. + * Settled rows stay in this list; when hide-settled is on, the recent list + * shelves them at the bottom instead of omitting them. + */ + const recentThreads = useMemo(() => { + const memberKeysForSelectedProject = + selectedProjectFilterKey === null + ? null + : new Set( + ( + sortedProjects.find((project) => project.projectKey === selectedProjectFilterKey) + ?.memberProjects ?? [] + ).map((member) => scopedProjectKey(scopeProjectRef(member.environmentId, member.id))), + ); + return sortThreads(visibleThreads, "updated_at").flatMap((thread) => { + const memberKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = projectPhysicalKeyByScopedRef.get(memberKey) ?? memberKey; + const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + if ( + memberKeysForSelectedProject !== null && + !memberKeysForSelectedProject.has(memberKey) && + projectKey !== selectedProjectFilterKey + ) { + return []; + } + const project = sidebarProjectByKey.get(projectKey); + return project ? [{ thread, project }] : []; + }); + }, [ + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + selectedProjectFilterKey, + sidebarProjectByKey, + sortedProjects, + visibleThreads, + ]); + // Jump shortcuts target the main inbox only when settled are shelved — + // collapsed history shouldn't consume 1–9 slots. + const recentThreadKeys = useMemo( + () => + recentThreads.flatMap(({ thread }) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if ( + hideSettledRecent && + usesFlatThreadGrouping(storedThreadGrouping) && + settledThreadKeys.has(threadKey) + ) { + return []; + } + return [threadKey]; + }), + [hideSettledRecent, recentThreads, settledThreadKeys, storedThreadGrouping], + ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: ownershipFilter, + relation: ownershipRelation, + }), + ), + sidebarThreadSortOrder, + ); + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, sidebarThreadPreviewCount); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + claimPersonIdByEnvironment, + ownershipFilter, + ownershipRelation, + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + selectedEnvironmentIds, + sortedProjects, + threadsByProjectKey, + ], + ); + const jumpCandidateThreadKeys = useMemo( + () => + storedListMode === "threads" && usesFlatThreadGrouping(storedThreadGrouping) + ? recentThreadKeys + : visibleSidebarThreadKeys, + [recentThreadKeys, storedListMode, storedThreadGrouping, visibleSidebarThreadKeys], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of jumpCandidateThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [jumpCandidateThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const sidebarShortcutContext = { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }; + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + // Browser clients can sit behind constrained remote links. Prewarming every + // visible thread hydrates several full detail windows before the user opens + // any of them, so keep the eager cache warm-up desktop-only. The active + // route still subscribes to its selected thread normally in either mode. + () => (isElectron ? getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys) : []), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + const shortcutContext = getCurrentSidebarShortcutContext(); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + if (command === "board.open") { + event.preventDefault(); + event.stopPropagation(); + setStoredListMode("board"); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/board" }); + return; + } + + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + window.addEventListener("keydown", onWindowKeyDown); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + getCurrentSidebarShortcutContext, + isMobile, + keybindings, + navigate, + navigateToThread, + orderedSidebarThreadKeys, + platform, + routeThreadKey, + setStoredListMode, + sidebarThreadByKey, + setOpenMobile, + threadJumpThreadKeys, + ]); + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (!useThreadSelectionStore.getState().hasSelection()) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, [clearSelection]); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); + const handleDesktopUpdateButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + return; + } + + if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + } + }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); + + const expandThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (current.has(projectKey)) return current; + const next = new Set(current); + next.add(projectKey); + return next; + }); + }, []); + + const collapseThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (!current.has(projectKey)) return current; + const next = new Set(current); + next.delete(projectKey); + return next; + }); + }, []); + + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const physicalProjectKey = `${environmentId}:${projectId}`; + const projectKey = physicalToLogicalKey.get(physicalProjectKey) ?? physicalProjectKey; + if (!sidebarProjectByKey.has(projectKey)) return; + expandThreadListForProject(projectKey); + requestAnimationFrame(() => { + const rows = document.querySelectorAll("[data-project-key]"); + for (const row of rows) { + if (row.dataset.projectKey !== projectKey) continue; + row.scrollIntoView({ behavior: "smooth", block: "nearest" }); + break; + } + }); + }), + [expandThreadListForProject, physicalToLogicalKey, sidebarProjectByKey], + ); + + return ( + + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} + + + + + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 7750413e73d9..7a57c2a104c9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -28,7 +28,7 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, - resolveSidebarV2Status, + resolveSidebarThreadStatus, resolveSidebarV2TopStatus, resolveThreadStatusPill, resolveWorkingStartedAt, @@ -40,11 +40,11 @@ import { groupSettledThreadsByRecencyForSidebarV2, isThreadSettledForDisplay, resolveSettledTimestamp, - sortSettledThreadsForSidebarV2, + sortSettledThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, - sortPinnedThreadsForSidebarV2, - sortThreadsForSidebarV2, + sortPinnedThreadsForSidebar, + sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -1048,7 +1048,7 @@ describe("isContextMenuPointerDown", () => { }); }); -describe("resolveSidebarV2Status", () => { +describe("resolveSidebarThreadStatus", () => { const session = { threadId: ThreadId.make("thread-1"), status: "running" as const, @@ -1063,15 +1063,17 @@ describe("resolveSidebarV2Status", () => { const idle = { hasPendingApprovals: false, hasPendingUserInput: false }; it("prioritizes approval over a running session", () => { - expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe( + expect(resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, session })).toBe( "approval", ); }); it("prioritizes awaiting input over a running session, below approval", () => { - expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input"); + expect(resolveSidebarThreadStatus({ ...idle, hasPendingUserInput: true, session })).toBe( + "input", + ); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, hasPendingUserInput: true, @@ -1081,9 +1083,9 @@ describe("resolveSidebarV2Status", () => { }); it("reports working for running and starting sessions", () => { - expect(resolveSidebarV2Status({ ...idle, session })).toBe("working"); + expect(resolveSidebarThreadStatus({ ...idle, session })).toBe("working"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "starting" as const }, }), @@ -1092,19 +1094,19 @@ describe("resolveSidebarV2Status", () => { it("reports failed only while the session status is error", () => { expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "error" as const, lastError: "boom" }, }), ).toBe("failed"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "stopped" as const, lastError: "persisted" }, }), ).toBe("ready"); expect( - resolveSidebarV2Status({ + resolveSidebarThreadStatus({ ...idle, session: { ...session, status: "ready" as const, lastError: "persisted" }, }), @@ -1112,18 +1114,38 @@ describe("resolveSidebarV2Status", () => { }); it("defaults to ready with no session", () => { - expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready"); + expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready"); + }); +}); + +describe("searchSidebarThreadsByTitle", () => { + const threads = [ + { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, + { id: "thread-2", title: "Review providers", project: "Workspace" }, + { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" }, + ]; + + it("matches thread titles case-insensitively and preserves their order", () => { + expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + }); + + it("does not match project metadata", () => { + expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + }); + + it("returns no results for an empty query", () => { + expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); }); }); -describe("sortThreadsForSidebarV2", () => { +describe("sortThreadsForSidebar", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, createdAt: input.createdAt, }); it("orders by creation time, newest first, ignoring activity", () => { - const sorted = sortThreadsForSidebarV2([ + const sorted = sortThreadsForSidebar([ sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }), sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), @@ -1133,7 +1155,7 @@ describe("sortThreadsForSidebarV2", () => { }); it("breaks creation-time ties by id so the order is stable", () => { - const sorted = sortThreadsForSidebarV2([ + const sorted = sortThreadsForSidebar([ sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), ]); @@ -1239,7 +1261,7 @@ describe("planPinnedReorder", () => { }); }); -describe("sortPinnedThreadsForSidebarV2", () => { +describe("sortPinnedThreadsForSidebar", () => { const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ id: input.id, createdAt: input.createdAt, @@ -1247,7 +1269,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); it("sorts keyed threads by key ahead of keyless threads in creation order", () => { - const sorted = sortPinnedThreadsForSidebarV2([ + const sorted = sortPinnedThreadsForSidebar([ pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), @@ -1263,7 +1285,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); it("breaks equal keys by id so raced writes render identically everywhere", () => { - const sorted = sortPinnedThreadsForSidebarV2([ + const sorted = sortPinnedThreadsForSidebar([ pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), ]); @@ -1272,7 +1294,7 @@ describe("sortPinnedThreadsForSidebarV2", () => { }); }); -describe("sortSettledThreadsForSidebarV2", () => { +describe("sortSettledThreadsForSidebar", () => { const settled = (input: { id: string; settledAt?: string | null; @@ -1288,7 +1310,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("orders by settle time, most recently settled first", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "settled-first", settledAt: "2026-03-09T10:00:00.000Z", @@ -1306,7 +1328,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), @@ -1318,7 +1340,7 @@ describe("sortSettledThreadsForSidebarV2", () => { it("counts a turn completion as activity for auto-settled threads", () => { // The message came in before the other thread's, but its turn finished // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), settled({ id: "completed-later", @@ -1331,7 +1353,7 @@ describe("sortSettledThreadsForSidebarV2", () => { }); it("breaks timestamp ties by id so the order is stable", () => { - const sorted = sortSettledThreadsForSidebarV2([ + const sorted = sortSettledThreadsForSidebar([ settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), ]); @@ -1499,7 +1521,7 @@ describe("groupSettledThreadsByRecencyForSidebarV2", () => { const olderIso = new Date( new Date(2026, 2, 15).getTime() - 40 * 24 * 60 * 60 * 1000, ).toISOString(); - const ordered = sortSettledThreadsForSidebarV2([ + const ordered = sortSettledThreadsForSidebar([ settled({ id: "old", settledAt: olderIso }), settled({ id: "fresh", settledAt: lastHourIso }), ]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7bf898e57d5b..291e8b4fbd86 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -842,21 +842,27 @@ export function resolveThreadRowClassName(input: { ); } -// ── Sidebar v2 status model ───────────────────────────────────────── +// ── Sidebar thread status model ───────────────────────────────────── // Five visual states, three colors: color is reserved for "act now" // (approval), "in motion" (working), and "broken" (failed). Ready is the // unlabeled resting state — the agent stopped and is waiting on the user, // whether it finished, asked a question, or proposed a plan. // Unread completion is tracked separately: it describes whether a ready // thread needs attention, not what the thread is currently doing. -export type SidebarV2Status = "approval" | "input" | "working" | "monitoring" | "failed" | "ready"; - -type SidebarV2StatusInput = Pick< +export type SidebarThreadStatus = + | "approval" + | "input" + | "working" + | "monitoring" + | "failed" + | "ready"; + +type SidebarThreadStatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" >; -export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { +export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals) { return "approval"; } @@ -889,7 +895,7 @@ export interface SidebarV2TopStatus { } export function resolveSidebarV2TopStatus(input: { - status: SidebarV2Status; + status: SidebarThreadStatus; isUnread: boolean; }): SidebarV2TopStatus | null { switch (input.status) { @@ -971,11 +977,11 @@ export function firstValidTimestamp( return null; } -// v2 sort: static creation order, newest thread on top. Activity NEVER +// Sidebar sort: static creation order, newest thread on top. Activity NEVER // reorders the list — a row holds its position from open until settled, so // the screen only moves at lifecycle transitions. Status (including pending // approval) is carried by each card's edge strip, not by position. -export function sortThreadsForSidebarV2< +export function sortThreadsForSidebar< T extends { readonly id: string; readonly createdAt: string }, >(threads: readonly T[]): T[] { return [...threads].toSorted( @@ -992,7 +998,7 @@ export { pinOrderKeyBetween, planPinnedReorder, } from "@t3tools/client-runtime/state/thread-sort"; -export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** * Search the already-ordered sidebar thread collection by title only. @@ -1041,7 +1047,7 @@ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | // Settled rows are history, so they order by when the work ENDED, not when // the thread was created or last touched. -export function sortSettledThreadsForSidebarV2< +export function sortSettledThreadsForSidebar< T extends SettledTimestampInput & { readonly id: string }, >(threads: readonly T[]): T[] { const timestampMs = (thread: T) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89cd7ee60aca..88e45a324eb0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,120 +1,90 @@ -import { - ArchiveIcon, - ArrowUpDownIcon, - BotIcon, - ChevronDownIcon, - ChevronRightIcon, - CloudIcon, - ContainerIcon, - EllipsisVerticalIcon, - FolderIcon, - FolderPlusIcon, - Globe2Icon, - LayersIcon, - ListFilterIcon, - LoaderIcon, - MessageSquareIcon, - PinIcon, - PlusIcon, - SearchIcon, - ServerIcon, - SettingsIcon, - SquarePenIcon, - TerminalIcon, - TriangleAlertIcon, - Undo2Icon, -} from "lucide-react"; -import { - ComposerDraftDot, - prStatusIndicator, - PrStatusTooltipContent, - resolveThreadPr, - terminalStatusFromRunningIds, - ThreadStatusLabel, - ThreadWorktreeIndicator, -} from "./ThreadStatusIndicators"; -import { ThreadIdentityMark } from "./identity/ParticipantStack"; -import { - isIdentityClaimRequiredMessage, - requestIdentityClaimGate, -} from "./identity/IdentityClaimGate"; -import { hasComposerDraftMessage, useComposerDraftStore } from "../composerDraftStore"; -import { ProjectFavicon, ProjectFaviconFallback } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useContext, useEffect, memo, useMemo, useRef, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; +import { useAtomValue } from "@effect/atom-react"; import { DndContext, - type DragCancelEvent, - type CollisionDetection, PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, + closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - type ContextMenuItem, - type EnvironmentId, - ProjectId, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, - type SidebarProjectGroupingMode, - ThreadId, -} from "@t3tools/contracts"; + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import { + groupSortedThreadsByRecency, + shouldShowRecencySectionHeaders, +} from "@t3tools/client-runtime/state/thread-recency-groups"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, scopeProjectRef, scopeThreadRef, + scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import type { + EnvironmentId, + ScopedThreadRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { + AlarmClockIcon, + AlarmClockOffIcon, + CheckIcon, + ChevronDownIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleDashedIcon, + ClockIcon, + CopyIcon, + FolderIcon, + FolderPlusIcon, + GitBranchIcon, + EllipsisIcon, + ListIcon, + MessageSquareIcon, + ListFilterIcon, + PinIcon, + PlusIcon, + SearchIcon, + ServerIcon, + SquareKanbanIcon, + SquarePenIcon, + TerminalIcon, + Trash2Icon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { useLocation, useParams, useRouter } from "@tanstack/react-router"; + import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; -import { - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - type SidebarProjectSortOrder, - type SidebarThreadPreviewCount, - type SidebarThreadSortOrder, -} from "@t3tools/contracts/settings"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { cn, isMacPlatform } from "../lib/utils"; -import { - readEnvironmentSupportsSettlement, - readThreadShell, - useProject, - useProjects, - useServerConfigs, - useThreadShells, - useThreadShellsForProjectRefs, -} from "../state/entities"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; -import { useAtomCommand } from "../state/use-atom-command"; -import { previewEnvironment } from "../state/preview"; -import { - legacyProjectCwdPreferenceKey, - resolveProjectExpanded, - useUiStateStore, -} from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -123,47 +93,108 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useDesktopUpdateState } from "../state/desktopUpdate"; -import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot"; -import { resolveThreadModelPresentation } from "../threadModelPresentation"; import { - hasUsageMarker, - resolveDriverUsage, - usageDotFillClass, - usageDotRingColor, -} from "../aiUsageState"; - + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { useNowMinute } from "../hooks/useNowMinute"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; -import { threadEnvironment, useEnvironmentThread } from "../state/threads"; -import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { formatRelativeTimeLabel } from "../timestampFormat"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { getDriverOption } from "./settings/providerDriverMeta"; -import { AiUsageStats } from "./chat/AiUsageStats"; -import { Kbd } from "./ui/kbd"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { ThreadIdentityMark } from "./identity/ParticipantStack"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; +import { + claimPersonIdForEnvironment, + DEFAULT_OWNERSHIP_RELATION, + isOwnershipRelation, + threadMatchesMine, + type OwnershipRelation, +} from "@t3tools/client-runtime/state/identity"; +import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; +import { + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, + buildBulkTitleRegenerationContextMenuItem, + formatWorkingDurationLabel, + firstValidTimestampMs, + hasUnseenCompletion, + isTrailingDoubleClick, + orderItemsByPreferredIds, + planPinnedReorder, + resolveAdjacentThreadId, + resolveSettledTimestamp, + resolveSidebarThreadStatus, + searchSidebarThreadsByTitle, + resolveWorkingStartedAt, + shouldNavigateAfterProjectRemoval, + sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebar, + sortSettledThreadsForSidebar, + sortThreadsForSidebar, +} from "./Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + prStatusIndicator, + resolveThreadPr, + settledPrHoverColorClass, + terminalStatusFromRunningIds, + type TerminalStatusIndicator, +} from "./ThreadStatusIndicators"; import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, -} from "./desktopUpdate.logic"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; + resolveSnoozePresets, + snoozeWakeDescription, + snoozeWakeLabel, + type SnoozePreset, +} from "./Sidebar.snooze"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { AiUsageStats } from "./chat/AiUsageStats"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; +import { resolveDriverUsage, usageDotFillClass, usageDotRingColor } from "../aiUsageState"; +import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Dialog, @@ -175,1792 +206,2069 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; +import { Kbd } from "./ui/kbd"; import { Menu, MenuCheckboxItem, MenuGroup, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "./ui/menu"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "./ui/number-field"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { - SidebarContent, - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - useSidebar, -} from "./ui/sidebar"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; -import { subscribeToProjectReveal } from "../projectJump"; -import { - archiveSelectedThreadEntries, - buildMultiSelectThreadContextMenuItems, - getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, - isContextMenuPointerDown, - isTrailingDoubleClick, - resolveProjectStatusIndicator, - resolveSidebarProjectBadgeColorIndex, - resolveSidebarProjectBadgeLabel, - resolveThreadRowClassName, - resolveThreadStatusPill, - isThreadSettledForDisplay, - orderItemsByPreferredIds, - SETTLED_TAIL_INITIAL_COUNT, - SETTLED_TAIL_PAGE_COUNT, - groupSettledThreadsByRecencyForSidebarV2, - resolveSettledTimestamp, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - sortSettledThreadsForSidebarV2, - useThreadJumpHintVisibility, - ThreadStatusPill, -} from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; -import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useIsMobile } from "~/hooks/useMediaQuery"; import { useLocalStorage } from "~/hooks/useLocalStorage"; -import { useNowMinute } from "~/hooks/useNowMinute"; -import { CommandDialogTrigger } from "./ui/command"; -import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { - DEFAULT_HIDE_SETTLED_PROJECTS, - DEFAULT_HIDE_SETTLED_RECENT, DEFAULT_SIDEBAR_OWNERSHIP_FILTER, - DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, - DEFAULT_WEB_LIST_MODE, DEFAULT_WEB_THREAD_GROUPING, EMPTY_LIST_ENVIRONMENT_FILTER, LIST_ENVIRONMENT_FILTER_STORAGE_KEY, - LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, - LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, LIST_MODE_STORAGE_KEY, - LIST_PROJECT_FILTER_ALL, - LIST_PROJECT_FILTER_STORAGE_KEY, LIST_THREAD_GROUPING_STORAGE_KEY, ListEnvironmentFilterSchema, ListHideSettledSchema, - ListProjectFilterSchema, + parseSidebarOwnershipFilter, SIDEBAR_OWNERSHIP_FILTER_LABELS, SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, SIDEBAR_OWNERSHIP_FILTERS, SIDEBAR_OWNERSHIP_RELATION_LABELS, SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, SIDEBAR_OWNERSHIP_RELATIONS, - SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, - WEB_LIST_MODE_LABELS, - WEB_LIST_MODES, WEB_THREAD_GROUPING_LABELS, WEB_THREAD_GROUPINGS, - WebListModeSchema, WebThreadGroupingSchema, defaultThreadGroupingFromLegacyModeStorage, isAllEnvironmentsSelected, isEnvironmentSelected, - isWebListMode, - isWebThreadGrouping, matchesEnvironmentFilter, - parseSidebarOwnershipFilter, resolveSelectedEnvironmentIds, toggleEnvironmentId, usesFlatThreadGrouping, - usesProjectThreadGrouping, type SidebarOwnershipFilter, - type WebListMode, type WebThreadGrouping, } from "./listEnvironmentFilter"; -import { - claimPersonIdForEnvironment, - DEFAULT_OWNERSHIP_RELATION, - isOwnershipRelation, - threadMatchesMine, - type OwnershipRelation, -} from "@t3tools/client-runtime/state/identity"; -import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; -import { - groupSortedThreadsByRecency, - shouldShowRecencySectionHeaders, -} from "@t3tools/client-runtime/state/thread-recency-groups"; -import { Toggle, ToggleGroup } from "./ui/toggle-group"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; -import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; - -/** - * Active sidebar rows report resolved PR state upward so hide-settled / - * settled-shelf classification can auto-settle merged/closed PRs the same way - * Sidebar V2 and the board do. Settled history rows skip reporting. - */ -type SidebarChangeRequestStateReporter = ( - threadKey: string, - state: "open" | "closed" | "merged" | null, -) => void; -const noopSidebarChangeRequestStateReporter: SidebarChangeRequestStateReporter = () => {}; -const SidebarChangeRequestStateContext = React.createContext( - noopSidebarChangeRequestStateReporter, -); - -/** - * Reveal provider details while Command/Control is held when the compact - * sidebar setting normally hides them. - */ -function useModifierRevealHeld(enabled: boolean): boolean { - const [held, setHeld] = useState(false); - - useEffect(() => { - if (!enabled) { - setHeld(false); - return; - } - - const onKey = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey); - const onBlur = () => setHeld(false); - - window.addEventListener("keydown", onKey, true); - window.addEventListener("keyup", onKey, true); - window.addEventListener("blur", onBlur); - return () => { - window.removeEventListener("keydown", onKey, true); - window.removeEventListener("keyup", onKey, true); - window.removeEventListener("blur", onBlur); - }; - }, [enabled]); - - return enabled && held; -} +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { useComposerDraftStore } from "../composerDraftStore"; -const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", -}; -const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", -}; -const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", -} as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; -const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; -function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { - useEnvironmentThread(threadRef.environmentId, threadRef.threadId); - return null; +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; } -function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { - return Math.min( - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), - ) as SidebarThreadPreviewCount; +function threadTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } -function formatProjectMemberActionLabel( - member: SidebarProjectGroupMember, - groupedProjectCount: number, -): string { - if (groupedProjectCount <= 1) { - return member.title; - } +// Settled rows read "how long ago did this wrap up", matching their sort +// key: both go through resolveSettledTimestamp so label and order can't +// disagree. +function settledTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} - return member.environmentLabel - ? `${member.environmentLabel} — ${member.workspaceRoot}` - : member.workspaceRoot; +// Floats at the row's right edge, vertically centered, while the jump +// modifier is held. An overlay pill instead of an inline slot: the hint +// must neither displace the status/time label (holding ⌘ used to blank +// out "Working") nor shift any layout when it appears. pointer-events-none +// so it never swallows clicks meant for the settle/un-settle buttons it +// can overlap. +function JumpHintBadge(props: { label: string }) { + return ( + + {props.label} + + ); } -function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { - return [ - project.projectKey, - ...project.memberProjects.map((member) => member.physicalProjectKey), - ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), - ]; +// Self-ticking so only this span re-renders each second, not the whole row. +function WorkingDuration(props: { startedAt: string | null }) { + const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; + const [, setTick] = useState(0); + useEffect(() => { + if (Number.isNaN(startedMs)) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); + return () => window.clearInterval(id); + }, [startedMs]); + if (Number.isNaN(startedMs)) return null; + return ( + + {formatWorkingDurationLabel(Date.now() - startedMs)} + + ); } -function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { - switch (mode) { - case "repository": - return "Projects from the same repository share one sidebar row."; - case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; - case "separate": - return "Every project path gets its own sidebar row."; - } +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -function buildThreadJumpLabelMap(input: { - keybindings: ResolvedKeybindingsConfig; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } +function SidebarThreadTooltip({ + thread, + projectTitle, + projectCwd, + environmentLabel, + driverKind, + modelInstanceId, + modelLabel, + branchMismatch, + usageDotClass, + usageRingColor, + threadUsage, + terminalStatus, + terminalProcessCount, +}: { + thread: SidebarThreadSummary; + projectTitle: string | null; + projectCwd: string | null; + environmentLabel: string | null; + driverKind: ProviderInstanceEntry["driverKind"] | null; + modelInstanceId: string; + modelLabel: string; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; + usageDotClass?: string | undefined; + usageRingColor?: string | undefined; + threadUsage?: ReturnType | undefined; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; +}) { + return ( + +
    +
    + {thread.title} +
    +
    + {projectTitle ? ( +
    + +
    {projectTitle}
    +
    + ) : null} + {environmentLabel ? ( +
    + +
    {environmentLabel}
    +
    + ) : null} + {thread.branch ? ( +
    + +
    {thread.branch}
    +
    + ) : null} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} + {driverKind ? ( +
    + +
    {modelLabel}
    +
    + ) : null} + {threadUsage ? ( +
    + +
    + ) : null} + {terminalStatus ? ( +
    + +
    + {terminalProcessLabel(terminalProcessCount)} +
    +
    + ) : null} + {thread.session?.lastError ? ( +
    + +
    Error occurred
    +
    + ) : null} +
    +
    +
    + ); +} - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +/** + * Hover entry point for snooze: a clock button opening the preset menu. + * Controlled by the row (which also uses the open state to pin its hover + * actions while the menu is up). + */ +function SnoozePopoverButton(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSnooze: (preset: SnoozePreset) => void; + timestampFormat: TimestampFormat; +}) { + const { open, onOpenChange, onSnooze, timestampFormat } = props; + // Presets resolve at open time so "In 1 hour" is relative to the click, + // not to when the row mounted. + const presets = useMemo( + () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), + [open, timestampFormat], + ); + return ( + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + > + + + + {presets.map((preset) => ( + + ))} + + + ); +} + +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } -interface SidebarThreadRowProps { +const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; + variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle" | "unsnooze"; + // False on environments whose server predates thread.settle/unsettle: + // the lifecycle affordances hide entirely rather than fail on click. + settlementSupported: boolean; + // Same contract for thread.snooze/unsnooze. + snoozeSupported: boolean; + // Renders the pin glyph. Pinned cards keep the full settle/snooze quick + // actions: settling clears the pin server-side, and snoozing hides the + // card until wake with the pin intact underneath. The glyph is also the + // in-row pin state cue (the pinned block has no header), so it always + // shows while pinned; it only becomes a clickable unpin quick-action once + // the pinning capability is confirmed, and stays a passive marker while + // the descriptor is not loaded. Pinning itself lives in the context menu. + pinningSupported: boolean; + isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; + // Compact wake countdown ("2h") for rows in the snoozed shelf. + snoozeWakeLabelText: string | null; + // When a snooze ended (timer or early wake); drives the Woke pill until + // the user visits the thread. + wokeAt: string | null; isActive: boolean; jumpLabel: string | null; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + timestampFormat: TimestampFormat; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} - -export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onUnsnooze: (threadRef: ScopedThreadRef) => void; + onUnpin: (threadRef: ScopedThreadRef) => void; + onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { const { - orderedProjectThreadKeys, - isActive, - jumpLabel, - appSettingsConfirmThreadArchive, - renamingThreadKey, + isRenaming, + onChangeRequestState, + onCancelRename, + onCommitRename, + onContextMenu, + onAcknowledgeWoke, + onRenameTitleChange, + onSettle, + onSnooze, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + onUnsnooze, + onUnpin, renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, thread, + variant, + variantAction, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const hasDraft = useComposerDraftStore((state) => - hasComposerDraftMessage(state.draftsByThreadKey[threadKey]), - ); + const openPrLink = useOpenPrLink(); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); - const isMobile = useIsMobile(); - const discoveredPorts = useThreadDiscoveredPorts({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = environment?.label ?? null; - // A desktop-local secondary backend (e.g. the WSL backend) shows up as a - // bearer environment whose connection id is prefixed "local:". It runs on the - // user's own machine, so the cloud icon is misleading — label it "Local" and - // suppress the cloud icon (the project header already shows a container icon - // for desktop-local projects, see sidebarProjectGrouping). - const isDesktopLocalThread = - environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) - : null; - // For grouped projects, the thread may belong to a different environment - // than the representative project. Look up the thread's own project cwd - // so git status (and thus PR detection) queries the correct path. - const threadProject = useProject( - useMemo( - () => scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const terminalProcessCount = runningTerminalIds.length; + + // Same semantics as v1 (never-visited counts as read): flipping the beta + // flag must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarThreadStatus(thread); + // Screen-reader status for an in-flight title regeneration: the v2 rows are + // a fork rewrite of upstream's row, so this never came across with the rest + // of that surface even though the projection field did. + const isRegeneratingTitle = thread.titleRegeneration != null; + // A woken thread reappears at its original position (the sort is + // deliberately static), so the pill has to carry the weight. Snoozing is + // an explicit act, so the pill clears only when the user re-engages: + // reading a completion-triggered wake, clicking the pill, sending a + // message, settling, archiving — or finishing the work outright (merged + // or closed PR). Timer wakes survive a mere visit. An unparseable visit + // timestamp counts as never-visited — corrupt local data must not eat + // the wake signal. + const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.listStatus({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); - const isHighlighted = isActive || isSelected; - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open preview", - description: - error instanceof Error ? error.message : "The preview could not be opened.", - }), - ); - })(); - }, - [discoveredPorts, navigateToThread, openPreview, threadRef], - ); - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); const pr = resolveThreadPr({ threadBranch: thread.branch, - gitStatus: gitStatus.data ?? null, + gitStatus: gitStatus.data, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - // Lift PR state so parent hide-settled / shelf classification can auto-settle - // merged/closed PRs (matches Sidebar V2 row reporting). - const onChangeRequestState = useContext(SidebarChangeRequestStateContext); const prState = pr?.state ?? null; + + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = + wokeAtDate !== null && + (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && + prState !== "merged" && + prState !== "closed"; + // In-flight rows (working, or waiting on approval/input) fade as a whole: + // there is nothing for the user to do yet, so prominence is reserved for + // rows that need a human — done (unread), read-but-unsettled, failed, and + // freshly woken. The status label keeps its hue, so waiting rows stay + // findable. In-flight rows recede the same as read-ready ones (inbox-zero: + // working threads aren't your problem yet) — only the colored status label + // stands out. + const isInFlight = + status === "working" || status === "monitoring" || status === "approval" || status === "input"; + const shouldRecede = + (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; + // Status hues follow the system-wide convention set by sidebar v1 and the + // mobile Live Activity/widgets (amber approval, indigo input, sky working) + // so a thread reads the same color everywhere it surfaces. + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + // No shimmer: a label that animates forever is noise in a sidebar + // full of them (and repaints every vsync on high-refresh displays). + // Working is a background state, so it rests at the dim end of what + // the old pulse cycled through; only the thread you have open gets + // the label at full strength. + className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), + } + : status === "monitoring" + ? { + // Monitoring is calm background presence, not active progress + // (monitoring-pill D6), so it keeps the label at full strength. + label: "Monitoring", + icon: null, + className: "text-sky-600 dark:text-sky-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "text-indigo-600 dark:text-indigo-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "text-red-700 dark:text-red-300", + } + : isWoke + ? { + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; + const isWokeStatus = topStatus?.icon === "woke"; + + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - const clearConfirmingArchive = useCallback(() => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }, [setConfirmingArchiveThreadKey, threadKey]); - const handleMouseLeave = useCallback(() => { - clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }, - [clearConfirmingArchive], + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId); + const threadUsage = useMemo( + () => resolveDriverUsage(aiUsageSnapshot, driverKind, thread.modelSelection.model), + [aiUsageSnapshot, driverKind, thread.modelSelection.model], ); - const handleRowDoubleClick = useCallback( - (event: React.MouseEvent) => { - // Already renaming this row: a double-click on the row chrome (outside the - // input) must not restart and discard the in-progress edit. - if (renamingThreadKey === threadKey) return; - // On mobile the first tap navigates and closes the sidebar sheet, so the - // inline rename can't be shown. Renaming there stays on the context menu. - if (isMobile) return; - // cmd/ctrl/shift double-clicks are multi-select intent, not rename. - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - // Ignore double-clicks bubbling from nested controls (PR status, port, - // archive buttons) — only the row body should enter inline rename. - if ((event.target as HTMLElement).closest("button, a")) return; - event.preventDefault(); - startThreadRename(threadKey, thread.title); + const usageDotClass = threadUsage ? usageDotFillClass(threadUsage.marker) : undefined; + const usageRingColor = threadUsage ? usageDotRingColor(threadUsage.marker) : undefined; + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const detailsTooltip = ( + + ); + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); }, - [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + [onThreadClick, threadRef], ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; + const handleAcknowledgeWokeClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); - navigateToThread(threadRef); + event.stopPropagation(); + if (props.wokeAt === null) return; + onAcknowledgeWoke(threadRef, props.wokeAt); }, - [navigateToThread, threadRef], + [onAcknowledgeWoke, props.wokeAt, threadRef], ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void (async () => { - const result = await settlePromise(() => - handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - return; - } - - if (hasSelection) { - clearSelection(); - } - void (async () => { - const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); }, - [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], + [onContextMenu, threadRef], ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - if (!prStatus) return; - openPrLink(event, prStatus.url); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); }, - [openPrLink, prStatus], + [onThreadActivate, threadRef], ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], + [isRenaming, onStartRename, thread.title, threadRef], ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); + renameCommittedRef.current = true; + onCancelRename(); } }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], ); - const handleRenameInputBlur = useCallback(() => { - if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); - // Keep clicks/double-clicks inside the rename input from bubbling to the row. - // Without stopping `dblclick`, double-clicking to select a word would re-fire - // the row's rename handler and reset the in-progress edit back to the title. - const handleRenameInputClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); - const stopPropagationOnPointerDown = useCallback( - (event: React.PointerEvent) => { + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); event.stopPropagation(); + onSettle(threadRef); }, - [], + [onSettle, threadRef], ); - const handleConfirmArchiveClick = useCallback( - (event: React.MouseEvent) => { + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(threadRef); + onUnsettle(threadRef); }, - [attemptArchiveThread, clearConfirmingArchive, threadRef], + [onUnsettle, threadRef], ); - const handleStartArchiveConfirmation = useCallback( - (event: React.MouseEvent) => { + const handleUnsnoozeClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - setConfirmingArchiveThreadKey(threadKey); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); - }); + onUnsnooze(threadRef); }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + [onUnsnooze, threadRef], ); - const handleArchiveImmediateClick = useCallback( - (event: React.MouseEvent) => { + const handleUnpinClick = useCallback( + (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - void attemptArchiveThread(threadRef); + onUnpin(threadRef); }, - [attemptArchiveThread, threadRef], + [onUnpin, threadRef], + ); + const handleSnoozePreset = useCallback( + (preset: SnoozePreset) => { + onSnooze(threadRef, preset); + }, + [onSnooze, threadRef], + ); + // While the snooze popover is open the pointer leaves the row, which + // would fade the hover actions out from under the open menu; pin them. + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); + // Snooze is offered only where it can succeed: capability-gated and never + // on blocked-on-you work or queued turns (the server rejects both). + const showSnoozeButton = + props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); + // If the thread becomes blocked while the popover is open, the button + // unmounts without firing onOpenChange(false). Deriving the flag keeps a + // stale true from permanently hiding the status label / pinning the + // hover actions, and the effect clears the raw state so the popover + // doesn't resurrect if the button later remounts. + const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; + useEffect(() => { + if (!showSnoozeButton) setSnoozeMenuOpen(false); + }, [showSnoozeButton]); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], ); - const rowButtonRender = useMemo(() =>
    , []); - return ( - + {isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ) : ( + + {thread.title} + + )} + {!isRenaming ? ( + + ) : null} +
    + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + const terminalStatusIcon = terminalStatus ? ( + - + + ) : null; + + if (variant === "slim") { + return ( +
  • -
    - {threadStatus && } - {renamingThreadKey === threadKey ? ( - - ) : ( - <> - - - {thread.title} - - } - /> - - {thread.title} - - - + - - )} - {hasDraft ? : null} - {prStatus && pr ? ( - - - } - > - #{pr.number} - - - - - - ) : null} -
    -
    - {discoveredPorts.length > 0 && ( - - - } - > - - - - Open localhost:{discoveredPorts[0]?.port} - {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} - - - )} - - {terminalStatus && ( - - - } - > - - - {terminalStatus.label} - - )} -
    - {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
    + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. -
    - ) : ( - - - -
    - } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && !isDesktopLocalThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - - } - > - {jumpLabel} - - {jumpLabel} - ) : ( - - {formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - )} + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} )} + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : ( + + )} -
    - - - - ); -}); - -interface SidebarProjectThreadListProps { - projectKey: string; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; -} + {props.jumpLabel ? : null} + + {detailsTooltip} + +
  • + ); + } -const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, -) { - const { - projectKey, - projectExpanded, - hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - projectCwd, - activeRouteThreadKey, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, - } = props; - const showMoreButtonRender = useMemo(() => + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, + actions on hover/keyboard focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} + + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + + ) : null} + + +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( + {thread.branch} + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    + + {props.jumpLabel ? : null} + + {detailsTooltip} + + ); }); -interface SidebarProjectItemProps { - project: SidebarProjectSnapshot; - selectedEnvironmentIds: readonly EnvironmentId[]; - isThreadListExpanded: boolean; - activeRouteThreadKey: string | null; - newThreadShortcutLabel: string | null; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - settleThread: ReturnType["settleThread"]; - unsettleThread: ReturnType["unsettleThread"]; - hideSettledThreads: boolean; - settledThreadKeys: ReadonlySet; - ownershipFilter: SidebarOwnershipFilter; - ownershipRelation: OwnershipRelation; - claimPersonIdByEnvironment: ReadonlyMap; - threadJumpLabelByKey: ReadonlyMap; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - isManualProjectSorting: boolean; - dragHandleProps: SortableProjectHandleProps | null; +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; } -const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { - const { - project, - selectedEnvironmentIds, - isThreadListExpanded, - activeRouteThreadKey, - newThreadShortcutLabel, - handleNewThread, - archiveThread, - deleteThread, - settleThread, - unsettleThread, - hideSettledThreads, - settledThreadKeys, - ownershipFilter, - ownershipRelation, - claimPersonIdByEnvironment, - threadJumpLabelByKey, - attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - isManualProjectSorting, - dragHandleProps, - } = props; - const threadSortOrder = useClientSettings( - (settings) => settings.sidebarThreadSortOrder, +const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + thread: SidebarThreadSummary; + projectCwd: string | null; + projectTitle: string | null; + environmentLabel: string | null; + providerEntryByInstanceId: ReadonlyMap; + isHighlighted: boolean; + isRouteActive: boolean; + resultId: string; + onHighlight: () => void; + onSelect: () => void; +}) { + const { thread } = props; + // Same details tooltip as the regular rows: a search hit is still a thread, + // and the hover card is how you disambiguate identically-titled results. + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.listStatus({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, ); - const appSettingsConfirmThreadDelete = useClientSettings( - (settings) => settings.confirmThreadDelete, + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, ); - const appSettingsConfirmThreadArchive = useClientSettings( - (settings) => settings.confirmThreadArchive, + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + return ( +
  • + + + } + > + + {thread.title} + + {threadTimeLabel(thread)} + + + + +
  • ); +}); + +export default function Sidebar() { + const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const deleteProject = useAtomCommand(projectEnvironment.delete, { + const [threadGrouping, setThreadGrouping] = useLocalStorage( + LIST_THREAD_GROUPING_STORAGE_KEY, + DEFAULT_WEB_THREAD_GROUPING, + WebThreadGroupingSchema, + ); + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + unpinThread, + reorderPinnedThread, + deleteThread, + } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); - const updateProject = useAtomCommand(projectEnvironment.update, { + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); const updateSettings = useUpdateClientSettings(); - const sidebarThreadPreviewCount = useClientSettings( - (settings) => settings.sidebarThreadPreviewCount, - ); - const router = useRouter(); - const { isMobile, setOpenMobile } = useSidebar(); - const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); - const toggleThreadPinned = useUiStateStore((state) => state.toggleThreadPinned); - const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); - const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); - const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); - const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); - const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ - threadId: ThreadId; - }>({ - onCopy: (ctx) => { + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => { toastManager.add({ type: "success", - title: "Thread ID copied", - description: ctx.threadId, + title: "Path copied", + description: path, }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to copy thread ID", + title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ - path: string; - }>({ - onCopy: (ctx) => { + const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ + target: "branch name", + onCopy: ({ branch }) => { toastManager.add({ type: "success", - title: "Path copied", - description: ctx.path, + title: "Branch copied", + description: branch, }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to copy path", + title: "Failed to copy branch", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); - const openPrLink = useOpenPrLink(); - const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); - const ownershipMatchedThreads = useMemo( - () => - sidebarThreads.filter((thread) => - threadMatchesMine({ - claimPersonId: claimPersonIdForEnvironment( - claimPersonIdByEnvironment, - thread.environmentId, - ), - originPersonId: thread.originSource?.personId ?? null, - participantPersonIds: (thread.participantSummaries ?? []).map( - (participant) => participant.personId, - ), - mode: ownershipFilter, - relation: ownershipRelation, + const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: string }>({ + onCopy: ({ threadId }) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: threadId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", }), - ), - [claimPersonIdByEnvironment, ownershipFilter, ownershipRelation, sidebarThreads], - ); - const sidebarThreadByKey = useMemo( - () => - new Map( - ownershipMatchedThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [ownershipMatchedThreads], + ); + }, + }); + const [projectActionsTarget, setProjectActionsTarget] = useState( + null, ); - // Keep a ref so callbacks can read the latest map without appearing in - // dependency arrays (avoids invalidating every thread-row memo on each - // thread-list change). - const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); - sidebarThreadByKeyRef.current = sidebarThreadByKey; - const projectThreads = useMemo( - () => - hideSettledThreads - ? ownershipMatchedThreads.filter( - (thread) => - !settledThreadKeys.has( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ) - : ownershipMatchedThreads, - [hideSettledThreads, ownershipMatchedThreads, settledThreadKeys], + const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], ); - const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); - const projectExpanded = useUiStateStore((state) => - resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); + const acknowledgeWoke = useCallback( + (threadRef: ScopedThreadRef, visitedAt: string) => { + markThreadVisited(scopedThreadKey(threadRef), visitedAt); + }, + [markThreadVisited], ); - const threadLastVisitedAts = useUiStateStore( - useShallow((state) => - projectThreads.map( - (thread) => - state.threadLastVisitedAtById[ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) - ] ?? null, - ), - ), + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); - const [renamingThreadKey, setRenamingThreadKey] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); - const [projectRenameTarget, setProjectRenameTarget] = useState( - null, + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], ); - const [projectRenameTitle, setProjectRenameTitle] = useState(""); - const [projectGroupingTarget, setProjectGroupingTarget] = - useState(null); - const [projectGroupingSelection, setProjectGroupingSelection] = useState< - SidebarProjectGroupingMode | "inherit" - >("inherit"); - const renamingCommittedRef = useRef(false); - const renamingInputRef = useRef(null); - const confirmArchiveButtonRefs = useRef(new Map()); - const memberProjectByScopedKey = useMemo( + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTargetRef = useRef(routeTarget); + routeTargetRef.current = routeTarget; + // Post-settle navigation validates against the CURRENT route, not the one + // captured when the settle started: if the user navigated elsewhere while + // the command was in flight, completing it must not yank them away. + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + + const environmentLabelById = useMemo( () => new Map( - project.memberProjects.map((member) => [ - scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), - member, - ]), + environments.map((environment) => [environment.environmentId, environment.label] as const), ), - [project.memberProjects], + [environments], ); - const memberThreadCountByPhysicalKey = useMemo(() => { - const counts = new Map( - project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), - ); - for (const thread of projectThreads) { - const member = memberProjectByScopedKey.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + const [ownershipFilter, setOwnershipFilter] = useState(() => { + try { + return parseSidebarOwnershipFilter( + window.localStorage.getItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY), ); - if (!member) { - continue; - } - counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); + } catch { + return DEFAULT_SIDEBAR_OWNERSHIP_FILTER; } - return counts; - }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - - const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { - const lastVisitedAtByThreadKey = new Map( - projectThreads.map((thread, index) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - threadLastVisitedAts[index] ?? null, - ]), - ); - const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return resolveThreadStatusPill({ - thread: { - ...thread, - ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), - }, - }); - }; - const visibleProjectThreads = sortThreads( - projectThreads.filter( - (thread) => - thread.archivedAt === null && - matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds), - ), - threadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - return { - orderedProjectThreadKeys: visibleProjectThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - projectStatus, - visibleProjectThreads, - }; - }, [projectThreads, selectedEnvironmentIds, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { - const activeThreadKey = activeRouteThreadKey ?? undefined; - if (!activeThreadKey || projectExpanded) { - return null; + }); + const [ownershipRelation, setOwnershipRelation] = useState(() => { + try { + const raw = window.localStorage.getItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY); + if (isOwnershipRelation(raw)) return raw; + } catch { + return DEFAULT_OWNERSHIP_RELATION; } - return ( - visibleProjectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null - ); - }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); + return DEFAULT_OWNERSHIP_RELATION; + }); + // Per-environment claims (not primary-only): smart has no map while t3vm does. + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); - const { - hasOverflowingThreads, - hiddenThreadStatus, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - } = useMemo(() => { - const lastVisitedAtByThreadKey = new Map( - projectThreads.map((thread, index) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - threadLastVisitedAts[index] ?? null, - ]), - ); - const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return resolveThreadStatusPill({ - thread: { - ...thread, - ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), - }, - }); - }; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? visibleProjectThreads - : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); - const visibleThreadKeys = new Set( - [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads.filter((thread) => - visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const hiddenThreads = visibleProjectThreads.filter( - (thread) => - !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - return { - hasOverflowingThreads, - hiddenThreadStatus: resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + // Shared with classic list / Board so multi-env filters (e.g. hide t3vm) stick + // when switching sidebars. + const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( + LIST_ENVIRONMENT_FILTER_STORAGE_KEY, + EMPTY_LIST_ENVIRONMENT_FILTER, + ListEnvironmentFilterSchema, + ); + const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, + ListHideSettledSchema, + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const selectedEnvironmentIds = useMemo( + () => + resolveSelectedEnvironmentIds( + storedEnvironmentFilter as readonly EnvironmentId[], + availableEnvironmentIds, ), - renderedThreads, - showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, - shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, - }; - }, [ - isThreadListExpanded, - pinnedCollapsedThread, - projectExpanded, - projectThreads, - sidebarThreadPreviewCount, - threadLastVisitedAts, - visibleProjectThreads, - ]); + [availableEnvironmentIds, storedEnvironmentFilter], + ); - const handleProjectButtonClick = useCallback( - (event: React.MouseEvent) => { - if (suppressProjectClickForContextMenuRef.current) { - suppressProjectClickForContextMenuRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (dragInProgressRef.current) { - event.preventDefault(); - event.stopPropagation(); - return; - } - if (suppressProjectClickAfterDragRef.current) { - suppressProjectClickAfterDragRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (useThreadSelectionStore.getState().hasSelection()) { - clearSelection(); - } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); - }, + const listOptionsActive = + ownershipFilter !== DEFAULT_SIDEBAR_OWNERSHIP_FILTER || + ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || + !isAllEnvironmentsSelected(selectedEnvironmentIds) || + settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED; + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), [ - clearSelection, - dragInProgressRef, - projectExpanded, - projectPreferenceKeys, - setProjectExpanded, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + sidebarProjectSortOrder, ], ); - - const handleProjectButtonKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { - return; - } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); - }, - [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], + const projectGroups = useMemo( + () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), + [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); - - const handleProjectButtonPointerDownCapture = useCallback( - (event: React.PointerEvent) => { - suppressProjectClickForContextMenuRef.current = false; - if ( - isContextMenuPointerDown({ - button: event.button, - ctrlKey: event.ctrlKey, - isMac: isMacPlatform(navigator.platform), - }) - ) { - event.stopPropagation(); - } - - suppressProjectClickAfterDragRef.current = false; - }, - [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], ); - - const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { - setProjectRenameTarget(member); - setProjectRenameTitle(member.title); - }, []); - - const openProjectGroupingDialog = useCallback( - (member: SidebarProjectGroupMember) => { - const overrideKey = deriveProjectGroupingOverrideKey(member); - setProjectGroupingTarget(member); - setProjectGroupingSelection( - projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectDisplayNameByKey = useMemo( + () => + new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, + ), + ), + ), + [projectGroups], + ); + const orderForThreadGrouping = useCallback( + (ordered: EnvironmentThreadShell[]) => { + if (threadGrouping !== "recency") return ordered; + return ordered.toSorted( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt, right.createdAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt, left.createdAt) || + left.id.localeCompare(right.id), ); }, - [projectGroupingSettings.sidebarProjectGroupingOverrides], + [threadGrouping], ); - const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { - const memberProjectRef = scopeProjectRef(member.environmentId, member.id); - const result = await deleteProject({ - environmentId: member.environmentId, - input: { - projectId: member.id, - ...(options.force === true ? { force: true } : {}), - }, + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const nowMinute = useNowMinute(); + // Snooze wake times are second-precise, so classifying with the quantized + // minute would hold a woken thread on the shelf for up to a minute. The + // tick is a plain counter bumped exactly at the next wake boundary (armed + // below, after the partition knows the boundary); the partition reads a + // fresh clock whenever it recomputes. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; }); - if (result._tag === "Failure") { - return result; - } - const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); - } - draftStore.clearProjectDraftThreadId(memberProjectRef); - return result; }, - [deleteProject], + [], ); - const handleRemoveProject = useCallback( - async (member: SidebarProjectGroupMember) => { - const api = readLocalApi(); - if (!api) { - return; - } + // Project scope: one menu above the list. Scoping filters the list without + // making the header width depend on the number or length of project names. + const [projectScopeKey, setProjectScopeKey] = useState(null); + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const projectGroup = projectGroups.find((project) => + project.memberProjectRefs.some( + (ref) => ref.environmentId === environmentId && ref.projectId === projectId, + ), + ); + if (projectGroup !== undefined) { + setProjectScopeKey(projectGroup.projectKey); + } + }), + [projectGroups], + ); + const scopedProjectGroup = useMemo( + () => + projectScopeKey === null + ? null + : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), + [projectGroups, projectScopeKey], + ); + const scopedProjectKeys = useMemo( + () => + scopedProjectGroup === null + ? null + : new Set( + scopedProjectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ), + [scopedProjectGroup], + ); + useEffect(() => { + if (projectScopeKey !== null && scopedProjectGroup === null) { + setProjectScopeKey(null); + } + }, [projectScopeKey, scopedProjectGroup]); + // Scope flips drop the selection: rows selected under the old scope may be + // hidden now, and bulk actions must never count or touch invisible rows. + useEffect(() => { + clearSelection(); + }, [clearSelection, projectScopeKey]); - const memberProjectRef = scopeProjectRef(member.environmentId, member.id); - const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; - if (memberThreadCount > 0) { - const warningToastId = toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - actionVariant: "destructive", - actionProps: { - children: "Delete anyway", - onClick: () => { - void (async () => { - toastManager.close(warningToastId); - await new Promise((resolve) => { - window.setTimeout(resolve, 180); - }); + const handleRemoveProjectMembers = useCallback( + async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { + const api = readLocalApi(); + if (!api) return; - const latestProjectThreads = Array.from( - sidebarThreadByKeyRef.current.values(), - ).filter( - (thread) => - thread.environmentId === memberProjectRef.environmentId && - thread.projectId === memberProjectRef.projectId, - ); - const confirmed = await api.dialogs.confirm( - latestProjectThreads.length > 0 - ? [ - `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" - }?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This permanently clears conversation history for those threads.", - "This removes only this project entry.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This removes only this project entry.", - ].join("\n"), - ); - if (!confirmed) { - return; - } + const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); + const projectThreads = threads.filter((thread) => + memberKeys.has(`${thread.environmentId}:${thread.projectId}`), + ); + const isWholeGroup = members.length === projectGroup.memberProjects.length; + const singleMember = members.length === 1 ? members[0]! : null; + const targetLabel = singleMember?.title ?? projectGroup.displayName; + const confirmed = await settlePromise(() => + api.dialogs.confirm( + projectThreads.length > 0 + ? [ + `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), + "This permanently clears conversation history for those threads.", + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", + "This action cannot be undone.", + ].join("\n") + : [ + `Remove project "${targetLabel}"?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; - const result = await removeProject(member, { force: true }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${member.title}"`, - description: - error instanceof Error - ? error.message - : "Unknown error removing project.", - }), - ); - } - })().catch((error) => { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { - projectId: member.id, - environmentId: member.environmentId, - ...safeErrorLogAttributes(error), - }); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${member.title}"`, - description: message, - }), - ); - }); - }, - }, - }), + const draftStore = useComposerDraftStore.getState(); + let shouldNavigate = false; + for (const project of members) { + const memberThreads = projectThreads.filter( + (thread) => + thread.environmentId === project.environmentId && thread.projectId === project.id, ); - return; + const projectRef = scopeProjectRef(project.environmentId, project.id); + const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); + const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ + routeTarget: routeTargetRef.current, + projectThreads: memberThreads, + projectDraftId: projectDraftThread?.draftId ?? null, + }); + + const result = await deleteProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...(memberThreads.length > 0 ? { force: true } : {}), + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${project.title}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (shouldNavigate) { + void router.navigate({ to: "/" }); + } + return; + } + + shouldNavigate ||= memberRemovalNeedsNavigation; + if (projectDraftThread) { + draftStore.clearDraftThread(projectDraftThread.draftId); + } + draftStore.clearProjectDraftThreadId(projectRef); } - const message = [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This removes only this project entry.", - ].join("\n"); - const confirmed = await api.dialogs.confirm(message); - if (!confirmed) { - return; + if (shouldNavigate) { + void router.navigate({ to: "/" }); } + }, + [deleteProject, router, threads], + ); - const result = await removeProject(member); + const renameProjectMember = useCallback( + async (member: SidebarProjectGroupMember, nextTitle: string) => { + const title = nextTitle.trim(); + if (!title) { + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + return; + } + if (title === member.title) return; + const result = await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, title }, + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { - projectId: member.id, - environmentId: member.environmentId, - ...safeErrorLogAttributes(error), - }); toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.title}"`, - description: message, + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", }), ); } }, - [memberThreadCountByPhysicalKey, removeProject], + [updateProject], ); - const handleProjectButtonContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - suppressProjectClickForContextMenuRef.current = true; - void (async () => { - const api = readLocalApi(); - if (!api) return; - - const actionHandlers = new Map Promise | void>(); - const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", - member: SidebarProjectGroupMember, - options?: { - destructive?: boolean; - disabled?: boolean; - }, - ): ContextMenuItem => { - const id = `${action}:${member.physicalProjectKey}`; - actionHandlers.set(id, () => { - switch (action) { - case "rename": - openProjectRenameDialog(member); - return; - case "grouping": - openProjectGroupingDialog(member); - return; - case "copy-path": - copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }); - return; - case "delete": - return handleRemoveProject(member); - } - }); - - return { - id, - label: formatProjectMemberActionLabel(member, project.groupedProjectCount), - ...(options?.destructive ? { destructive: true } : {}), - ...(options?.disabled ? { disabled: true } : {}), - }; - }; - - const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", - label: string, - options?: { - destructive?: boolean; - isDisabled?: (member: SidebarProjectGroupMember) => boolean; - }, - ): ContextMenuItem => { - if (project.memberProjects.length === 1) { - const singleMember = project.memberProjects[0]!; - return { - ...makeLeaf(action, singleMember, { - ...(options?.destructive ? { destructive: true } : {}), - ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), - }), - label, - ...(action === "delete" ? { icon: "trash" } : {}), - }; - } - - return { - id: `${action}:submenu`, - label, - ...(action === "delete" ? { icon: "trash" } : {}), - children: project.memberProjects.map((member) => - makeLeaf(action, member, { - ...(options?.destructive ? { destructive: true } : {}), - ...(options?.isDisabled?.(member) ? { disabled: true } : {}), - }), - ), - }; - }; - - const clicked = await api.contextMenu.show( - [ - buildTargetedItem("rename", "Rename"), - buildTargetedItem("grouping", "Group into..."), - buildTargetedItem("copy-path", "Copy Path"), - buildTargetedItem("delete", "Remove", { - destructive: true, - }), - ], - { - x: event.clientX, - y: event.clientY, - }, - ); - - if (!clicked) { - return; - } - - await actionHandlers.get(clicked)?.(); - })(); + const updateProjectGroupingPreference = useCallback( + (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; + if (selection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = selection; + } + updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); }, - [ - copyPathToClipboard, - handleRemoveProject, - openProjectGroupingDialog, - openProjectRenameDialog, - project.groupedProjectCount, - project.memberProjects, - suppressProjectClickForContextMenuRef, - ], + [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], ); - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); + const handleProjectActions = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + setProjectScopeMenuOpen(false); + window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); }, - [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + [], ); - const handleThreadClick = useCallback( - ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const isShiftClick = event.shiftKey; - const threadKey = scopedThreadKey(threadRef); - const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; - - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - - if (isShiftClick) { - event.preventDefault(); - rangeSelectTo(threadKey, orderedProjectThreadKeys); - return; - } - - // Ignore the trailing click of a plain double-click so it doesn't navigate - // while a double-click is starting an inline rename. Placed after the - // modifier branches so cmd/shift selection still processes every click. - if (isTrailingDoubleClick(event.detail)) { - return; - } - - if (currentSelectionCount > 0) { - clearSelection(); - } - setSelectionAnchor(threadKey); - if (isMobile) { + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells: no archived-snapshot + // merging, no optimistic holds. Archived threads remain hidden here — + // archive keeps its original "remove from sidebar" meaning. + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const { + pinnedThreads, + reorderablePinnedKeys, + activeThreads, + snoozedThreads, + settledThreads, + snoozeNow, + } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: ownershipFilter, + relation: ownershipRelation, + }), + ); + const pinned: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze temporarily suspends a pin; the pin survives and resumes on wake. + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + // One shared rule on every platform (see sortPinnedThreadsByOrderKey): + // user-arranged keys first, keyless threads in creation order below. + // Server capability only gates DRAGGING — it must not influence the + // sort, or mixed-version fleets would render different pinned orders + // on web and mobile from the same data. The fork's grouping + // preference therefore applies to the active rows, not to pins. + pinnedThreads: sortPinnedThreadsForSidebar(pinned), + reorderablePinnedKeys: new Set( + pinned + .filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === + true, + ) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + activeThreads: orderForThreadGrouping(sortThreadsForSidebar(active)), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebar(settled), + snoozeNow: preciseNow, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + claimPersonIdByEnvironment, + nowMinute, + orderForThreadGrouping, + ownershipFilter, + ownershipRelation, + scopedProjectKeys, + selectedEnvironmentIds, + serverConfigs, + snoozeWakeTick, + threads, + ]); + + const threadSearchInputRef = useRef(null); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); + const isSearchingThreads = threadSearchQuery.trim().length > 0; + const searchableThreads = useMemo( + () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads], + ); + const threadSearchResults = useMemo( + () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + [searchableThreads, threadSearchQuery], + ); + const threadSearchResultOrderKey = threadSearchResults + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) + .join("\0"); + + useEffect(() => { + setActiveSearchResultIndex(0); + }, [threadSearchResultOrderKey]); + + useEffect(() => { + if (!isSearchingThreads) return; + document + .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) + ?.scrollIntoView({ block: "nearest" }); + }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); + + // Arm a timeout for the earliest upcoming wake so the shelf empties the + // moment a snooze expires instead of on the next minute tick. Sorted + // soonest-first, so entry 0 is the boundary. + useEffect(() => { + const nextWakeAtMs = + snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null + ? Date.parse(snoozedThreads[0].snoozedUntil) + : Number.NaN; + if (Number.isNaN(nextWakeAtMs)) return; + // setTimeout delays are signed 32-bit: anything larger overflows and + // fires immediately, turning a far-future wake (event-condition snoozes + // synced from elsewhere) into a tight re-arm loop. Clamped, the timer + // just re-arms every ~24.8 days until the wake is in range. + const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => window.clearTimeout(id); + }, [snoozedThreads]); + + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}:${selectedEnvironmentIds.join(",")}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const visibleSettledThreads = useMemo(() => { + if (settledThreads.length <= settledVisibleCount) return settledThreads; + const visible = settledThreads.slice(0, settledVisibleCount); + // The open thread must never hide under "Show more": navigating into a + // deep settled thread (search, deep link) pulls its row into the visible + // tail so the highlight and the un-settle affordance stay reachable. + if (routeThreadKey !== null) { + const routeThread = settledThreads + .slice(settledVisibleCount) + .find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + if (routeThread !== undefined) visible.push(routeThread); + } + return visible; + }, [routeThreadKey, settledThreads, settledVisibleCount]); + const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + const toggleSettledShelf = useCallback( + () => setSettledShelfExpanded((value) => !value), + [setSettledShelfExpanded], + ); + const renderedSettledThreads = useMemo(() => { + if (settledShelfExpanded) return visibleSettledThreads; + if (routeThreadKey === null) return []; + const routeThread = visibleSettledThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + + // The snoozed shelf is collapsed by default: out of the way, never gone. + // Collapsed threads don't render (and so don't participate in jump + // shortcuts or multi-select), matching the settled tail's paging model. + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const visibleSnoozedThreads = useMemo(() => { + if (snoozedShelfExpanded) return snoozedThreads; + // The open thread must never vanish behind the collapsed shelf: a + // snoozed thread reached by route (deep link, open before snoozing + // elsewhere) keeps its row — with highlight and wake affordance — same + // exception the settled tail's "Show more" makes. + if (routeThreadKey === null) return []; + const routeThread = snoozedThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + + const orderedThreads = useMemo( + () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + // handleNewThread is inherently unstable (depends on the projects list); + // a ref keeps it out of attemptSettle's dependency array. + const handleNewThreadRef = useRef(newThreadContext.handleNewThread); + handleNewThreadRef.current = newThreadContext.handleNewThread; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + const snoozedThreadKeys = useMemo( + () => + new Set( + snoozedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [snoozedThreads], + ); + const snoozedThreadKeysRef = useRef(snoozedThreadKeys); + snoozedThreadKeysRef.current = snoozedThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + // Settled threads are live shells, so opening one is plain navigation: + // history stays readable without un-settling, and sending a message or + // starting a session un-settles server-side. + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { setOpenMobile(false); } void router.navigate({ @@ -1968,4069 +2276,1934 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec params: buildThreadRouteParams(threadRef), }); }, - [ - clearSelection, - isMobile, - rangeSelectTo, - router, - setOpenMobile, - setSelectionAnchor, - toggleThreadSelection, - ], + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; - if (threadKeys.length === 0) return; - const count = threadKeys.length; - const selectedThreadEntries = threadKeys.flatMap((threadKey) => { - const threadRef = parseScopedThreadKey(threadKey); - const thread = threadRef ? readThreadShell(threadRef) : null; - return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; - }); - const hasRunningThread = selectedThreadEntries.some( - ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, - ); - - const clicked = await api.contextMenu.show( - buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), - position, - ); - - if (clicked === "mark-unread") { - for (const { threadKey, thread } of selectedThreadEntries) { - markThreadUnread(threadKey, thread.latestTurn?.completedAt); - } - clearSelection(); + const clearThreadSearch = useCallback(() => { + setThreadSearchQuery(""); + setActiveSearchResultIndex(0); + }, []); + const selectThreadSearchResult = useCallback( + (thread: EnvironmentThreadShell) => { + clearThreadSearch(); + navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); + }, + [clearThreadSearch, navigateToThread], + ); + const handleThreadSearchKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // IME composition (Japanese/Chinese input) uses the same keys; committing + // a candidate must not move the highlight or navigate away mid-compose. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Escape" && isSearchingThreads) { + event.preventDefault(); + event.stopPropagation(); + clearThreadSearch(); + return; + } + if (threadSearchResults.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveSearchResultIndex( + (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length, + ); return; } + if (event.key === "Enter") { + event.preventDefault(); + const result = threadSearchResults[activeSearchResultIndex]; + if (result) selectThreadSearchResult(result); + } + }, + [ + activeSearchResultIndex, + clearThreadSearch, + isSearchingThreads, + selectThreadSearchResult, + threadSearchResults, + ], + ); - if (clicked === "archive") { - if (appSettingsConfirmThreadArchive) { - const confirmed = await api.dialogs.confirm( - `Archive ${count} thread${count === 1 ? "" : "s"}?`, - ); - if (!confirmed) return; + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); + const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); + const commitThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + void (async () => { + const trimmed = title.trim(); + setRenamingThreadKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; } - - const archiveOutcome = await archiveSelectedThreadEntries({ - entries: selectedThreadEntries, - archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + if (trimmed === originalTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, }); - for (const failure of archiveOutcome.followupFailures) { - if (isAtomCommandInterrupted(failure)) continue; - const error = squashAtomCommandFailure(failure); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Thread archived, but navigation failed", + title: "Failed to rename thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } - if (archiveOutcome.mutationFailure) { - removeFromSelection(archiveOutcome.archivedThreadKeys); - if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { - const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to archive threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - removeFromSelection(threadKeys); + })(); + }, + [updateThreadMetadata], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); return; } - - if (clicked !== "delete") return; - - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ); - if (!confirmed) return; + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); - const deletedThreadKeys = new Set(threadKeys); - for (const { threadRef } of selectedThreadEntries) { - const result = await deleteThread(threadRef, { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + // A settle per thread at a time: double clicks and repeated menu picks + // must not dispatch a second settle that fails and toasts a false error. + const settlingThreadKeysRef = useRef(new Set()); + // Parking the thread you're looking at (settle or snooze) moves you + // forward: the next remaining card (never a settled or snoozed row, never + // one leaving in the same batch), or a fresh draft in this project when it + // was the last active one. Callers snapshot the plan BEFORE the command + // mutates the partition; background parks never navigate (null plan). + const planForwardNavigation = useCallback( + (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { + if (routeThreadKeyRef.current !== threadKey) return null; + const shell = threadByKeyRef.current.get(threadKey); + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const snoozedKeys = snoozedThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( + (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), + ) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + return nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + : () => void router.navigate({ to: "/" }); + }, + [navigateToThread, router], + ); + + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + try { + const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); + const result = await settleThread(threadRef); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not settle. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: message, + }), + ); + } + return; } - return; + // Only move forward if the user is still on the settled thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSettle?.(); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); } - } - removeFromSelection(threadKeys); + })(); }, - [ - appSettingsConfirmThreadArchive, - appSettingsConfirmThreadDelete, - archiveThread, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - ], + [planForwardNavigation, settleThread], ); - - const createThreadForProjectMember = useCallback( - (member: SidebarProjectGroupMember) => { - if (isMobile) { - setOpenMobile(false); - } + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { void (async () => { - // No options: branch, worktree, and env mode come from the user's - // configured defaults, never from the currently viewed thread. - const result = await settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id)), - ); - if (result._tag === "Failure") { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Could not create thread", + title: "Failed to un-settle thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, - [handleNewThread, isMobile, setOpenMobile], + [unsettleThread], ); - - const handleCreateThreadClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (project.memberProjects.length === 1) { - createThreadForProjectMember(project.memberProjects[0]!); - return; - } - + const attemptUnsnooze = useCallback( + (threadRef: ScopedThreadRef) => { void (async () => { - const api = readLocalApi(); - if (!api) { - return; - } - const clickedResult = await settlePromise(() => - api.contextMenu.show( - project.memberProjects.map((member) => ({ - id: member.physicalProjectKey, - label: formatProjectMemberActionLabel(member, project.groupedProjectCount), - })), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (clickedResult._tag === "Failure") { - const error = squashAtomCommandFailure(clickedResult); + const result = await unsnoozeThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Could not choose environment", + title: "Failed to wake thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); - return; - } - const clicked = clickedResult.value; - if (!clicked) { - return; - } - const targetMember = project.memberProjects.find( - (member) => member.physicalProjectKey === clicked, - ); - if (!targetMember) { - return; } - createThreadForProjectMember(targetMember); })(); }, - [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], + [unsnoozeThread], ); - - const attemptArchiveThread = useCallback( - async (threadRef: ScopedThreadRef) => { - const result = await archiveThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, - [archiveThread], + // Drag-to-reorder for the pinned block. A drop computes ONE fractional key + // for the moved thread and sends it to that thread's own server (see + // planPinnedReorder for the keyless-neighbor materialization case). The + // optimistic order keeps the card where it was dropped until the + // confirming event round-trips; canonical order matching it releases the + // override, and a failed write clears it (the card snaps back) with a toast. + // ANY membership change (new pin, unpin, snooze/wake) also releases it: + // the override can't say where members it never saw belong, and holding it + // would misplace them and launder the stale order into later drags. + const pinnedDndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); - - const cancelRename = useCallback(() => { - setRenamingThreadKey(null); - renamingInputRef.current = null; - }, []); - - const startThreadRename = useCallback((threadKey: string, title: string) => { - setRenamingThreadKey(threadKey); - setRenamingTitle(title); - renamingCommittedRef.current = false; - }, []); - - const commitRename = useCallback( - async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { - const threadKey = scopedThreadKey(threadRef); - const finishRename = () => { - setRenamingThreadKey((current) => { - if (current !== threadKey) return current; - renamingInputRef.current = null; - return null; - }); - }; - - const trimmed = newTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Thread title cannot be empty", - }); - finishRename(); - return; - } - if (trimmed === originalTitle) { - finishRename(); - return; - } - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - title: trimmed, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - finishRename(); - }, - [updateThreadMetadata], - ); - - const closeProjectRenameDialog = useCallback(() => { - setProjectRenameTarget(null); - setProjectRenameTitle(""); - }, []); - - const submitProjectRename = useCallback(async () => { - if (!projectRenameTarget) { - return; - } - - const trimmed = projectRenameTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Project title cannot be empty", - }); - return; - } - - if (trimmed === projectRenameTarget.title) { - closeProjectRenameDialog(); - return; - } - - const result = await updateProject({ - environmentId: projectRenameTarget.environmentId, - input: { - projectId: projectRenameTarget.id, - title: trimmed, - }, + const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ + readonly order: readonly string[]; + /** pinOrderKey per thread as of the drop, so ANY landed write (ours + confirming, or a concurrent one from another client) releases the + override rather than fighting canonical state. */ + readonly keysAtDrop: ReadonlyMap; + } | null>(null); + const orderedPinnedThreads = useMemo(() => { + if (optimisticPinnedOrder === null) return pinnedThreads; + return orderItemsByPreferredIds({ + items: pinnedThreads, + preferredIds: optimisticPinnedOrder.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); - if (result._tag === "Success") { - closeProjectRenameDialog(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); - - const closeProjectGroupingDialog = useCallback(() => { - setProjectGroupingTarget(null); - setProjectGroupingSelection("inherit"); - }, []); - - const saveProjectGroupingPreference = useCallback(() => { - if (!projectGroupingTarget) { - return; - } - - const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); - const nextOverrides = { - ...projectGroupingSettings.sidebarProjectGroupingOverrides, - }; - if (projectGroupingSelection === "inherit") { - delete nextOverrides[overrideKey]; - } else { - nextOverrides[overrideKey] = projectGroupingSelection; + }, [optimisticPinnedOrder, pinnedThreads]); + useEffect(() => { + if (optimisticPinnedOrder === null) return; + const canonical = pinnedThreads.filter((thread) => + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const canonicalKeys = canonical.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + // The override represents one drop against one snapshot of the world. + // Release it as soon as the world moves on in any way: membership + // changed (pin/unpin/snooze/wake — the override can't say where members + // it never saw belong), a key changed (our write confirming, or a + // concurrent client's reorder that must win), or canonical already + // matches. Holding it longer would misplace newcomers and launder the + // stale order into later drags. + const membershipChanged = + canonicalKeys.length !== optimisticPinnedOrder.order.length || + canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); + const anyKeyLanded = canonical.some( + (thread, index) => + optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== + (thread.pinOrderKey ?? null), + ); + const orderConfirmed = + !membershipChanged && + canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); + if (membershipChanged || anyKeyLanded || orderConfirmed) { + setOptimisticPinnedOrder(null); } - updateSettings({ - sidebarProjectGroupingOverrides: nextOverrides, - }); - closeProjectGroupingDialog(); - }, [ - closeProjectGroupingDialog, - projectGroupingSelection, - projectGroupingSettings.sidebarProjectGroupingOverrides, - projectGroupingTarget, - updateSettings, - ]); - - const handleThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const threadKey = scopedThreadKey(threadRef); - const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; - if (!thread) return; - const threadProject = memberProjectByScopedKey.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ); - const threadWorkspacePath = - thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; - const isPinned = useUiStateStore.getState().pinnedThreadKeys.includes(threadKey); - const isSettled = settledThreadKeys.has(threadKey); - const supportsSettlement = readEnvironmentSupportsSettlement(thread.environmentId); - const clicked = await api.contextMenu.show( - [ - ...(thread.branch - ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] - : []), - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - { id: "pin", label: isPinned ? "Unpin thread" : "Pin thread" }, - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy Path" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], - position, - ); - - if (clicked === "new-thread-on-branch") { - // Explicit branch carry-over: reuse the thread's worktree when it - // has one, otherwise its branch on the local checkout. - const result = await settlePromise(() => - handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, - }), - ); - if (result._tag === "Failure") { + }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); + const attemptPin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + // Fresh pins take the top of the arranged run: pinThread computes a + // key before the smallest key across ALL pinned shells — including + // snoozed pins hidden from this list, whose keys are still part of + // the run — so the new pin can't land beneath a hidden head. + const result = await pinThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Could not create thread", + title: "Failed to pin thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } - return; - } - - if (clicked === "settle" || clicked === "unsettle") { - const result = - clicked === "settle" ? await settleThread(threadRef) : await unsettleThread(threadRef); + })(); + }, + [pinThread], + ); + const attemptUnpin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unpinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); - const message = error instanceof Error ? error.message : "An error occurred."; - if (isIdentityClaimRequiredMessage(message)) { - requestIdentityClaimGate(threadRef.environmentId); - } toastManager.add( stackedThreadToast({ type: "error", - title: - clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: message, + title: "Failed to unpin thread", + description: error instanceof Error ? error.message : "An error occurred.", }), ); } - return; - } + })(); + }, + [unpinThread], + ); - if (clicked === "pin") { - toggleThreadPinned(threadKey); - return; - } - if (clicked === "rename") { - startThreadRename(threadKey, thread.title); - return; + const handlePinnedDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const overKey = event.over === null ? null : String(event.over.id); + if (overKey === null || activeKey === overKey) return; + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const fromIndex = keys.indexOf(activeKey); + const toIndex = keys.indexOf(overKey); + if (fromIndex === -1 || toIndex === -1) return; + const newOrder = arrayMove([...keys], fromIndex, toIndex); + const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); + const keysAtDrop = new Map( + reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), + ); + const assignments = planPinnedReorder({ + orderedIds: newOrder, + keysById: keysAtDrop, + movedId: activeKey, + }); + if (assignments.length === 0) return; + setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); + void (async () => { + // Sequential, stop on first failure. There is deliberately no + // rollback: every key write is a complete, valid placement on its + // own, so a partial materialization leaves a sensible order (and + // the next drag repairs the rest) — unwinding writes across + // servers would trade that for real inconsistency windows. + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + const result = await reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setOptimisticPinnedOrder(null); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + })(); + }, + [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + ); + // One snooze per thread at a time — same double-dispatch guard as settle. + const snoozingThreadKeysRef = useRef(new Set()); + const performSnooze = useCallback( + async ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) { + return { status: "skipped" } as const; } - - if (clicked === "mark-unread") { - markThreadUnread(threadKey, thread.latestTurn?.completedAt); - return; + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + return isAtomCommandInterrupted(result) + ? ({ status: "interrupted" } as const) + : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); + } + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + return { status: "success" } as const; + } finally { + snoozingThreadKeysRef.current.delete(threadKey); } - if (clicked === "copy-path") { - if (!threadWorkspacePath) { + }, + [planForwardNavigation, snoozeThread], + ); + const attemptSnooze = useCallback( + ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + void (async () => { + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); + return; + } + if (outcome.status !== "success") return; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); + })(); + }, + [attemptUnsnooze, performSnooze, timestampFormat], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + // One exact actionable set: keys whose rows are actually rendered + // right now. Selections can outlive their rows (settled-tail paging, + // thread deletion elsewhere) and the menu labels must count only what + // the actions will touch. + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( + (threadKey) => threadByKeyRef.current.has(threadKey), + ); + if (threadKeys.length === 0) return; + const count = threadKeys.length; + // Snooze (N) is offered when every selected thread can actually take + // it — a mixed selection with blocked-on-you work would half-apply. + const selectionNow = new Date().toISOString(); + const selectedThreads = threadKeys.flatMap((threadKey) => { + const thread = threadByKeyRef.current.get(threadKey); + return thread ? [thread] : []; + }); + const canSnoozeSelection = selectedThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + canSnooze(thread, { now: selectionNow }), + ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, + ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => thread.titleRegeneration == null, + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + ...(canSnoozeSelection + ? [ + { + id: "snooze", + label: `Snooze (${count})`, + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) { + // Post-snooze navigation must skip threads snoozing in this same + // batch — they are all leaving the card block together. + const coSnoozingKeys = new Set(threadKeys); + clearSelection(); + const outcomes = await Promise.all( + selectedThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); + return { outcome, threadRef }; }), ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: + firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + } + return; + } + if (clicked.value === "regenerate-title") { + for (const thread of regeneratableTitleThreads) { + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Success") continue; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread titles", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } return; } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + clearSelection(); return; } - if (clicked === "copy-thread-id") { - copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + if (clicked.value === "settle") { + // Post-settle navigation must skip threads settling in this same + // batch — they are all leaving the card block together. Rows that + // are already explicitly settled are skipped: nothing to do on a + // valid mixed selection. Pinned rows ARE included: the decider + // clears the pin as part of settling, so they park like the rest. + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + clearSelection(); return; } - if (clicked !== "delete") return; - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); } + clearSelection(); + return; } - const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + // Grown as deletions actually land, never seeded with the whole batch: + // orphaned-worktree detection must only discount threads that are + // really gone, or the first delete would treat still-alive batch mates + // as deleted and remove a worktree they still point at. + const deletedThreadKeys = new Set(); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + deletedThreadKeys.add(threadKey); } + removeFromSelection(threadKeys); }, [ - appSettingsConfirmThreadDelete, - copyPathToClipboard, - copyThreadIdToClipboard, + attemptSettle, + attemptSnooze, + clearSelection, + confirmThreadDelete, deleteThread, - handleNewThread, markThreadUnread, - memberProjectByScopedKey, - project.workspaceRoot, - settleThread, - settledThreadKeys, - startThreadRename, - toggleThreadPinned, - unsettleThread, + performSnooze, + removeFromSelection, + serverConfigs, + attemptUnsnooze, + updateThreadMetadata, + timestampFormat, ], ); - return ( - <> -
    - - {!projectExpanded && projectStatus ? ( - - - } - > - - - - - - {projectStatus.label} - - ) : ( - - )} - - - - {project.displayName} - - {project.groupedProjectCount > 1 ? ( - - {project.groupedProjectCount} projects - - ) : null} - - - {/* Environment badge – visible by default, crossfades with the - "new thread" button on hover using the same pointer-events + - opacity pattern as the thread row archive/timestamp swap. */} - {project.environmentPresence === "remote-only" && ( - - - } - > - {project.allRemoteMembersAreDesktopLocal ? ( - - ) : ( - - )} - - - {project.allRemoteMembersAreDesktopLocal - ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}` - : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`} - - - )} - - - -
    + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + const threadWorkspacePath = + thread.worktreePath ?? + projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null; + // Un-settle works on every settled row: for explicit settles it + // clears the override, for auto-settled rows it pins the thread + // active until real activity clears the pin. Environments without + // the settlement capability get no lifecycle items at all. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsPinning = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + const supportsTitleRegeneration = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true; + const isRegeneratingTitle = thread.titleRegeneration != null; + const isSettled = settledThreadKeysRef.current.has(threadKey); + const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + const isPinned = thread.pinnedAt != null; + // Presets resolve at menu-open time (same as the popover). + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const clicked = await settlePromise(() => + api.contextMenu.show( + buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned, + isSettled, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + supports: { + settlement: supportsSettlement, + snooze: supportsSnooze, + pinning: supportsPinning, + titleRegeneration: supportsTitleRegeneration, + }, + snoozePresets, + }), + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) attemptSnooze(threadRef, preset); + return; + } + switch (clicked.value) { + case "new-thread-on-branch": { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - - - - - { - if (!open) { - closeProjectRenameDialog(); + return; } - }} - > - - - Rename project - - {projectRenameTarget - ? `Update the title for ${projectRenameTarget.workspaceRoot}.` - : "Update the project title."} - - - -
    - Project title - setProjectRenameTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void submitProjectRename(); - } - }} - /> -
    - {projectRenameTarget?.environmentLabel ? ( -

    - Environment: {projectRenameTarget.environmentLabel} -

    - ) : null} -
    - - - - -
    -
    - - { - if (!open) { - closeProjectGroupingDialog(); + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "unsnooze": + attemptUnsnooze(threadRef); + return; + case "pin": + attemptPin(threadRef); + return; + case "unpin": + attemptUnpin(threadRef); + return; + case "rename": + startThreadRename(threadRef, thread.title); + return; + case "regenerate-title": { + if (!supportsTitleRegeneration || isRegeneratingTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; } - }} - > - - - Project grouping - - {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` - : "Choose how this project should be grouped in the sidebar."} - - - -
    - Grouping rule - -
    -

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

    -
    - - - - -
    -
    - + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "copy-path": + if (!threadWorkspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); + } + return; + case "copy-thread-id": + copyThreadId(thread.id, { threadId: thread.id }); + return; + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } + default: + return; + } + })(); + }, + [ + attemptPin, + attemptSettle, + attemptSnooze, + attemptUnpin, + attemptUnsettle, + attemptUnsnooze, + confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, + copyThreadId, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + projectCwdByKey, + serverConfigs, + startThreadRename, + updateThreadMetadata, + timestampFormat, + ], ); -}); -const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, ); -}); - -function LocalSecondaryStatus() { - const { environments } = useEnvironments(); - // The desktop reports which local secondary backends (e.g. the WSL backend) - // exist; the hook polls because the bridge has no change event. A backend that - // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we - // surface "Connecting" straight from the bootstrap list and clear it once the - // matching environment reports a connected phase. - const secondaries = useDesktopLocalBootstraps(); - - // Connected desktop-local environments keyed by their backend URL so we can - // match a bootstrap (which only knows the URL) to its connection phase. - const localEnvByUrl = useMemo(() => { - const map = new Map(); - for (const environment of environments) { - if ( - isDesktopLocalConnectionTarget(environment.entry.target) && - environment.displayUrl !== null - ) { - map.set(environment.displayUrl, { - phase: environment.connection.phase, - error: environment.connection.error, - }); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command === "board.open") { + event.preventDefault(); + event.stopPropagation(); + if (isMobile) setOpenMobile(false); + void router.navigate({ to: "/board" }); + return; } - } - return map; - }, [environments]); - - const connecting: string[] = []; - const failed: Array<{ label: string; error: string | null }> = []; - for (const bootstrap of secondaries) { - const env = - bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; - if (env?.phase === "connected") { - continue; - } - if (env?.phase === "error") { - failed.push({ label: bootstrap.label, error: env.error }); - continue; - } - connecting.push(bootstrap.label); - } - - if (connecting.length === 0 && failed.length === 0) { - return null; - } - - return ( - - {connecting.length > 0 ? ( - - - - Connecting {connecting.join(", ")} - - - ) : null} - {failed.length > 0 ? ( - - - Couldn't connect {failed.map((entry) => entry.label).join(", ")} - - {failed - .map((entry) => entry.error) - .filter(Boolean) - .join("; ") || "The backend didn't respond."} - - - ) : null} - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; - -function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - threadPreviewCount, - onProjectSortOrderChange, - onThreadSortOrderChange, - onThreadPreviewCountChange, -}: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; -}) { - const handleThreadPreviewCountChange = useCallback( - (nextValue: number | null) => { - if (nextValue === null) { + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); return; } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + isMobile, + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + router, + setOpenMobile, + threadByKey, + ]); - const clampedValue = clampSidebarThreadPreviewCount(nextValue); - if (clampedValue !== threadPreviewCount) { - onThreadPreviewCountChange(clampedValue); - } - }, - [onThreadPreviewCountChange, threadPreviewCount], + // Same predicate as v1: hints show only while the held modifiers exactly + // match a thread-jump binding. Adding Shift (screenshots) or Alt no + // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. + const shortcutModifiers = useShortcutModifierState(); + const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { platform: navigator.platform }, ); + useEffect(() => { + setShowJumpHints(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow]); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projectGroups.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); + }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + + const pathname = useLocation({ select: (l) => l.pathname }); + const isBoardActive = pathname === "/board"; + const handleBoardClick = useCallback(() => { + if (isMobile) setOpenMobile(false); + void router.navigate({ to: "/board" }); + }, [isMobile, router, setOpenMobile]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + const boardShortcutLabel = shortcutLabelForCommand(keybindings, "board.open"); + // The button mirrors chat.new: in multi-project setups both route through + // the command palette's "New thread in..." picker, and in single-project + // setups both create immediately. chat.newLocal always creates directly, so + // it is only a correct label when chat.new is unbound. + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.new") ?? + shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( - - - - } - > - - - Sidebar options - - - -
    - Sort projects -
    - { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
    - -
    - Sort threads -
    - { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
    - -
    - Visible threads -
    -
    - - - - { - event.stopPropagation(); + <> + + +
    +
    + + { + setThreadSearchQuery(event.currentTarget.value); + setActiveSearchResultIndex(0); }} + onKeyDown={handleThreadSearchKeyDown} + placeholder="Search" + aria-label="Search threads" + role="combobox" + aria-autocomplete="list" + aria-expanded={isSearchingThreads && threadSearchResults.length > 0} + aria-controls={ + isSearchingThreads && threadSearchResults.length > 0 + ? "sidebar-thread-search-results" + : undefined + } + aria-activedescendant={ + isSearchingThreads && threadSearchResults[activeSearchResultIndex] + ? `sidebar-thread-search-result-${activeSearchResultIndex}` + : undefined + } + className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" /> - - - -
    - - -
    - ); -} - -function SortableProjectItem({ - projectId, - disabled = false, - children, -}: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; -}) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); -} - -interface SidebarProjectsContentProps { - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType; - openAddProject: () => void; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - settleThread: ReturnType["settleThread"]; - unsettleThread: ReturnType["unsettleThread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - recentThreads: readonly SidebarRecentThread[]; - threadByKey: ReadonlyMap; - navigateToThread: (threadRef: ScopedThreadRef) => void; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - newThreadShortcutLabel: string | null; - commandPaletteShortcutLabel: string | null; - listMode: WebListMode; - onListModeChange: (mode: WebListMode) => void; - threadGrouping: WebThreadGrouping; - onThreadGroupingChange: (grouping: WebThreadGrouping) => void; - environmentFilterOptions: readonly { environmentId: EnvironmentId; label: string }[]; - selectedEnvironmentIds: readonly EnvironmentId[]; - onSelectedEnvironmentIdsChange: (next: readonly EnvironmentId[]) => void; - projectFilterOptions: readonly { - projectKey: string; - displayName: string; - environmentId: EnvironmentId; - workspaceRoot: string; - }[]; - selectedProjectFilterKey: string | null; - onSelectedProjectFilterKeyChange: (key: string | null) => void; - ownershipFilter: SidebarOwnershipFilter; - onOwnershipFilterChange: (filter: SidebarOwnershipFilter) => void; - ownershipRelation: OwnershipRelation; - onOwnershipRelationChange: (relation: OwnershipRelation) => void; - claimPersonIdByEnvironment: ReadonlyMap; - hideSettledThreads: boolean; - onHideSettledThreadsChange: (hide: boolean) => void; - settledThreadKeys: ReadonlySet; - threadJumpLabelByKey: ReadonlyMap; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; -} - -interface SidebarRecentThread { - thread: SidebarThreadSummary; - project: SidebarProjectSnapshot; -} - -const RECENT_PROJECT_BADGE_CLASSES = [ - "bg-blue-500/12 text-blue-700 dark:text-blue-300", - "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", - "bg-violet-500/12 text-violet-700 dark:text-violet-300", - "bg-amber-500/14 text-amber-700 dark:text-amber-300", - "bg-rose-500/12 text-rose-700 dark:text-rose-300", - "bg-cyan-500/12 text-cyan-700 dark:text-cyan-300", -] as const; - -const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { - entry: SidebarRecentThread; - isActive: boolean; - jumpLabel: string | null; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - settleThread: ReturnType["settleThread"]; - unsettleThread: ReturnType["unsettleThread"]; - isSettled: boolean; - orderedRecentThreadKeys: readonly string[]; - threadByKey: ReadonlyMap; -}) { - const { project, thread } = props.entry; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const hasDraft = useComposerDraftStore((state) => - hasComposerDraftMessage(state.draftsByThreadKey[threadKey]), - ); - const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); - const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); - const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); - const serverConfigs = useServerConfigs(); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const discoveredPorts = useThreadDiscoveredPorts({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); - const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const openPrLink = useOpenPrLink(); - const confirmThreadArchive = useClientSettings( - (settings) => settings.confirmThreadArchive, - ); - const hideProviderIcons = useClientSettings( - (settings) => settings.sidebarHideProviderIcons ?? false, - ); - const revealHeld = useModifierRevealHeld(hideProviderIcons); - const [confirmingArchive, setConfirmingArchive] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); - const [renamingTitle, setRenamingTitle] = useState(thread.title); - const renameInputRef = useRef(null); - const renameCommitStartedRef = useRef(false); - const confirmThreadDelete = useClientSettings( - (settings) => settings.confirmThreadDelete, - ); - const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); - const isPinned = useUiStateStore((state) => state.pinnedThreadKeys.includes(threadKey)); - const toggleThreadPinned = useUiStateStore((state) => state.toggleThreadPinned); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); - const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: ThreadId }>({ - onCopy: ({ threadId }) => - toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }), - }); - const { copyToClipboard: copyPath } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => - toastManager.add({ type: "success", title: "Path copied", description: path }), - }); - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt } }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const isDesktopLocalThread = - environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const gitCwd = thread.worktreePath ?? project.workspaceRoot; - // Settled shelf rows match Sidebar V2 history: no list VCS subscription - // (PR auto-settle already applied or isn't needed; badges aren't live on history). - const gitStatus = useEnvironmentQuery( - !props.isSettled && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.listStatus({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data ?? null, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - // Report PR state for partition; settled history keeps last reported value. - const onChangeRequestState = useContext(SidebarChangeRequestStateContext); - const prState = pr?.state ?? null; - useEffect(() => { - if (props.isSettled) return; - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, props.isSettled, threadKey]); - const threadModelPresentation = useMemo( - () => - resolveThreadModelPresentation( - thread.modelSelection, - serverConfigs.get(thread.environmentId), - ), - [serverConfigs, thread.environmentId, thread.modelSelection], - ); - const ProviderIcon = - getDriverOption(threadModelPresentation.driverKind ?? undefined)?.icon ?? BotIcon; - const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId); - const threadUsage = useMemo( - () => - resolveDriverUsage( - aiUsageSnapshot, - threadModelPresentation.driverKind, - thread.modelSelection.model, - ), - [aiUsageSnapshot, thread.modelSelection.model, threadModelPresentation.driverKind], - ); - const usageDotClass = threadUsage ? usageDotFillClass(threadUsage.marker) : undefined; - const usageRingColor = threadUsage ? usageDotRingColor(threadUsage.marker) : undefined; - const showProviderIcon = !hideProviderIcons || revealHeld; - const showProviderMarker = - showProviderIcon || (threadUsage && hasUsageMarker(threadUsage.marker)); - const badgeColorClass = - RECENT_PROJECT_BADGE_CLASSES[ - resolveSidebarProjectBadgeColorIndex(project.projectKey, RECENT_PROJECT_BADGE_CLASSES.length) - ]; - const settledTimestamp = resolveSettledTimestamp(thread); - const settledTimeLabel = - settledTimestamp === null - ? "" - : (() => { - const label = formatRelativeTimeLabel(settledTimestamp); - if (label === "just now") return "now"; - return label.endsWith(" ago") ? label.slice(0, -4) : label; - })(); - - const attemptArchive = useCallback(() => { - setConfirmingArchive(false); - void props.archiveThread(threadRef).then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }); - }, [props, threadRef]); - - const createThreadFromRecent = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const worktreePath = thread.worktreePath?.trim(); - void props.handleNewThread( - scopeProjectRef(thread.environmentId, thread.projectId), - worktreePath - ? { - ...(thread.branch !== null ? { branch: thread.branch } : {}), - worktreePath, - envMode: "local", - } - : { - ...(thread.branch !== null ? { branch: thread.branch } : {}), - envMode: "worktree", - }, - ); - }, - [props, thread], - ); - - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port) return; - event.preventDefault(); - event.stopPropagation(); - props.navigateToThread(threadRef); - void openDiscoveredPort({ threadRef, port, openPreview }); - }, - [discoveredPorts, openPreview, props.navigateToThread, threadRef], - ); - - const commitRename = useCallback(async () => { - if (renameCommitStartedRef.current) return; - renameCommitStartedRef.current = true; - const trimmed = renamingTitle.trim(); - setIsRenaming(false); - if (!trimmed) { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - return; - } - if (trimmed === thread.title) return; - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, title: trimmed }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, [renamingTitle, thread.environmentId, thread.id, thread.title, updateThreadMetadata]); - - const handleContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (!api) return; - void (async () => { - const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; - if (selectedThreadKeys.length > 0 && isSelected) { - const count = selectedThreadKeys.length; - const selectedAction = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - { x: event.clientX, y: event.clientY }, - ); - if (selectedAction === "mark-unread") { - for (const selectedThreadKey of selectedThreadKeys) { - const selectedThread = props.threadByKey.get(selectedThreadKey); - markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); - } - clearSelection(); - } else if (selectedAction === "delete") { - if ( - confirmThreadDelete && - !(await api.dialogs.confirm( - `Delete ${count} thread${count === 1 ? "" : "s"}?\nThis permanently clears conversation history for these threads.`, - )) - ) { - return; - } - const deletedThreadKeys = new Set(selectedThreadKeys); - for (const selectedThreadKey of selectedThreadKeys) { - const selectedThread = props.threadByKey.get(selectedThreadKey); - if (!selectedThread) continue; - const result = await props.deleteThread( - scopeThreadRef(selectedThread.environmentId, selectedThread.id), - { deletedThreadKeys }, - ); - if (result._tag === "Failure") return; - } - removeFromSelection(selectedThreadKeys); - } - return; - } - if (selectedThreadKeys.length > 0) clearSelection(); - const supportsSettlement = readEnvironmentSupportsSettlement(thread.environmentId); - const clicked = await api.contextMenu.show( - [ - ...(supportsSettlement - ? [ - props.isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - { id: "pin", label: isPinned ? "Unpin thread" : "Pin thread" }, - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy Path" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "settle" || clicked === "unsettle") { - const result = - clicked === "settle" - ? await props.settleThread(threadRef) - : await props.unsettleThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - const message = error instanceof Error ? error.message : "An error occurred."; - if (isIdentityClaimRequiredMessage(message)) { - requestIdentityClaimGate(threadRef.environmentId); - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: - clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: message, - }), - ); - } - } else if (clicked === "pin") { - toggleThreadPinned(threadKey); - } else if (clicked === "rename") { - renameCommitStartedRef.current = false; - setRenamingTitle(thread.title); - setIsRenaming(true); - requestAnimationFrame(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }); - } else if (clicked === "mark-unread") { - markThreadUnread(threadKey, thread.latestTurn?.completedAt); - } else if (clicked === "copy-path") { - copyPath(gitCwd, { path: gitCwd }); - } else if (clicked === "copy-thread-id") { - copyThreadId(thread.id, { threadId: thread.id }); - } else if (clicked === "delete") { - if ( - confirmThreadDelete && - !(await api.dialogs.confirm( - `Delete thread "${thread.title}"?\nThis permanently clears conversation history for this thread.`, - )) - ) { - return; - } - const result = await props.deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } - })(); - }, - [ - confirmThreadDelete, - clearSelection, - copyPath, - copyThreadId, - gitCwd, - isPinned, - isSelected, - markThreadUnread, - props, - removeFromSelection, - thread, - threadKey, - threadRef, - toggleThreadPinned, - ], - ); - - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - const isModClick = isMacPlatform(navigator.platform) ? event.metaKey : event.ctrlKey; - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - if (event.shiftKey) { - event.preventDefault(); - rangeSelectTo(threadKey, props.orderedRecentThreadKeys); - return; - } - if (isTrailingDoubleClick(event.detail)) return; - props.navigateToThread(threadRef); - }, - [ - props.navigateToThread, - props.orderedRecentThreadKeys, - rangeSelectTo, - threadKey, - threadRef, - toggleThreadSelection, - ], - ); - - const handleRowDoubleClick = useCallback( - (event: React.MouseEvent) => { - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if ((event.target as HTMLElement).closest("button, a, input")) return; - event.preventDefault(); - setRenamingTitle(thread.title); - setIsRenaming(true); - renameCommitStartedRef.current = false; - requestAnimationFrame(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }); - }, - [thread.title], - ); - - const handleUnsettleClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void props.unsettleThread(threadRef).then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }); - }, - [props, threadRef], - ); - - // Settled shelf: Sidebar V2 slim history chrome (dimmed favicon, muted - // title, settle-time label, un-settle on hover) — not the dense inbox row. - if (props.isSettled) { - const supportsSettlement = readEnvironmentSupportsSettlement(thread.environmentId); - return ( - - } - size="sm" - isActive={props.isActive} - data-testid={`recent-thread-${thread.id}`} - className={cn( - resolveThreadRowClassName({ - isActive: props.isActive, - isSelected, - }), - "relative isolate min-h-9 items-center gap-2.5 py-1.5", - )} - onClick={handleRowClick} - onDoubleClick={handleRowDoubleClick} - onContextMenu={handleContextMenu} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.navigateToThread(threadRef); - }} - > - - - -
    -
    - {isRenaming ? ( - setRenamingTitle(event.target.value)} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - void commitRename(); - } else if (event.key === "Escape") { - setIsRenaming(false); - } - }} - onBlur={() => void commitRename()} - /> - ) : ( - <> - { + clearThreadSearch(); + threadSearchInputRef.current?.focus(); + }} > - {thread.title} - - - - )} - {hasDraft ? : null} -
    - - {project.displayName} - {environment?.label ? ( - <> - - · - - - {isRemoteThread ? ( - - ) : null} - {environment.label} - - - ) : null} - -
    - - - {settledTimeLabel} - - {supportsSettlement ? ( - - ) : null} - - {props.jumpLabel ? ( - - {props.jumpLabel} - - ) : null} -
    -
    - ); - } - - return ( - setConfirmingArchive(false)} - > - } - size="sm" - isActive={props.isActive} - data-testid={`recent-thread-${thread.id}`} - className={`${resolveThreadRowClassName({ - isActive: props.isActive, - isSelected, - })} relative isolate`} - onClick={handleRowClick} - onDoubleClick={handleRowDoubleClick} - onContextMenu={handleContextMenu} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.navigateToThread(threadRef); - }} - > -
    - - - } - > - {resolveSidebarProjectBadgeLabel(project.displayName)} - - {project.displayName} - -
    -
    - {threadStatus ? : null} - {isRenaming ? ( - setRenamingTitle(event.target.value)} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - void commitRename(); - } else if (event.key === "Escape") { - setIsRenaming(false); - } - }} - onBlur={() => void commitRename()} - /> - ) : ( - <> - {thread.title} - - - )} - {hasDraft ? : null} - {prStatus && pr ? ( + + + ) : null} +
    +
    openPrLink(event, prStatus.url)} + onClick={handleBoardClick} + aria-label="Board" + aria-current={isBoardActive ? "page" : undefined} + data-testid="sidebar-board-link" /> } > - #{pr.number} + + - - + + {boardShortcutLabel ? `Board (${boardShortcutLabel})` : "Board"} - ) : null} +
    +
    + + + } + > + + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
    - {/* Cross-project recency rows: project · server, matching mobile + - Sidebar V2's environment context (icon when remote). */} - - {project.displayName} - {environment?.label ? ( - <> - - · - - - {isRemoteThread ? ( - 0 ? ( +
    + + - ) : null} - {environment.label} - - - ) : null} - -
    -
    -
    - - handleContextMenu(event)} - /> - } - > - - - Thread actions - - {isPinned ? ( - - { - event.preventDefault(); - event.stopPropagation(); - toggleThreadPinned(threadKey); - }} - /> - } - > - - - Unpin thread - - ) : null} - {discoveredPorts.length > 0 ? ( - - - } - > - - - Open localhost:{discoveredPorts[0]?.port} - - ) : null} - {props.jumpLabel ? ( - - - } - > - {props.jumpLabel} - - {props.jumpLabel} - - ) : null} - - {terminalStatus ? ( - - - } - > - - - {terminalStatus.label} - - ) : null} - {showProviderMarker ? ( - - - } - > - {showProviderIcon ? : null} - {usageDotClass ? ( - - ) : null} - - - {threadUsage ? ( -
    - {threadModelPresentation.tooltip} - -
    - ) : ( - threadModelPresentation.tooltip - )} -
    -
    - ) : null} -
    - {/* Trailing remote cue kept for parity with project-thread rows; - subtitle already names the server when the label is available. */} - {isRemoteThread && !isDesktopLocalThread && !environment?.label ? ( - - - } - > - - - Remote - - ) : null} - - {formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - )} - - {!isThreadRunning ? ( - confirmingArchive ? ( - - ) : ( + } + > + {threadGrouping === "project" ? : } + + + setThreadGrouping(value as WebThreadGrouping)} + > + {WEB_THREAD_GROUPINGS.filter((grouping) => grouping !== "none").map( + (grouping) => ( + + {grouping === "project" ? : } + {WEB_THREAD_GROUPING_LABELS[grouping]} + + ), + )} + + + + + + } + > + {scopedProjectGroup ? ( + + ) : ( + + )} + + {scopedProjectGroup?.displayName ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) + } + > + + + All projects + + {projectGroups.map((project) => { + const scopeKey = project.projectKey; + return ( + + + {project.displayName} + + + ); + })} + + + + + + + } + /> + } + > + + {listOptionsActive ? ( + + ) : null} + + View & filters + + + +
    + Ownership +
    + { + if (value !== "any" && value !== "mine" && value !== "theirs") return; + setOwnershipFilter(value); + try { + window.localStorage.setItem( + SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, + value, + ); + } catch { + // ignore + } + }} + > + {SIDEBAR_OWNERSHIP_FILTERS.map((value) => ( + + {SIDEBAR_OWNERSHIP_FILTER_LABELS[value]} + + ))} + +
    + {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( + <> + + +
    + {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} +
    + { + if (!isOwnershipRelation(value)) return; + setOwnershipRelation(value); + try { + window.localStorage.setItem( + SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, + value, + ); + } catch { + // ignore + } + }} + > + {SIDEBAR_OWNERSHIP_RELATIONS.map((value) => ( + + {SIDEBAR_OWNERSHIP_RELATION_LABELS[value]} + + ))} + +
    + + ) : null} + + +
    + Settled shelf +
    + setSettledShelfExpanded(checked === true)} + > + Expand settled shelf + +
    + {environments.length > 1 ? ( + <> + + +
    + Environment +
    + setStoredEnvironmentFilter([])} + > + All environments + + {environments.map((environment) => ( + { + setStoredEnvironmentFilter([ + ...toggleEnvironmentId( + selectedEnvironmentIds, + environment.environmentId, + ), + ]); + }} + > + {environment.label} + + ))} +
    + + ) : null} +
    +
    { - event.preventDefault(); - event.stopPropagation(); - if (confirmThreadArchive) setConfirmingArchive(true); - else attemptArchive(); - }} + aria-label="New project" /> } > - + + - Archive thread + New project - ) - ) : null} -
    -
    -
    -
    - ); -}); - -const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { - recentThreads: readonly SidebarRecentThread[]; - /** - * When true, partition by recency. Section headers render only when more - * than one non-empty bucket is present (Last Hour / Earlier Today / …). - */ - groupByRecency: boolean; - /** - * When true, settled threads leave the main list and sit in a collapsible - * shelf at the bottom (same idea as Sidebar V2 — out of the way, never gone). - */ - hideSettledThreads: boolean; - routeThreadKey: string | null; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - settleThread: ReturnType["settleThread"]; - unsettleThread: ReturnType["unsettleThread"]; - settledThreadKeys: ReadonlySet; - threadJumpLabelByKey: ReadonlyMap; - threadByKey: ReadonlyMap; -}) { - const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( - SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, - DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, - ListHideSettledSchema, - ); - const [settledRecencyHeadersEnabled] = useLocalStorage( - SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, - DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, - ListHideSettledSchema, - ); - const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const nowMinute = useNowMinute(); - - const { activeEntries, settledEntries } = useMemo(() => { - if (!props.hideSettledThreads) { - return { - activeEntries: props.recentThreads, - settledEntries: [] as SidebarRecentThread[], - }; - } - const active: SidebarRecentThread[] = []; - const settled: SidebarRecentThread[] = []; - for (const entry of props.recentThreads) { - const threadKey = scopedThreadKey( - scopeThreadRef(entry.thread.environmentId, entry.thread.id), - ); - if (props.settledThreadKeys.has(threadKey)) { - settled.push(entry); - } else { - active.push(entry); - } - } - // Settled is history: order by when work ended, matching V2 shelf sort. - if (settled.length <= 1) { - return { activeEntries: active, settledEntries: settled }; - } - const sortedThreads = sortSettledThreadsForSidebarV2(settled.map((entry) => entry.thread)); - const entryByKey = new Map( - settled.map((entry) => [ - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), - entry, - ]), - ); - return { - activeEntries: active, - settledEntries: sortedThreads.flatMap((thread) => { - const entry = entryByKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return entry ? [entry] : []; - }), - }; - }, [props.hideSettledThreads, props.recentThreads, props.settledThreadKeys]); - - // When hide-settled turns off or the settled tail empties, drop a deep page - // so the next shelf open starts from the initial window again. - const settledPagingActive = props.hideSettledThreads && settledEntries.length > 0; - const lastSettledPagingActiveRef = useRef(settledPagingActive); - if (lastSettledPagingActiveRef.current !== settledPagingActive) { - lastSettledPagingActiveRef.current = settledPagingActive; - if (!settledPagingActive && settledVisibleCount !== SETTLED_TAIL_INITIAL_COUNT) { - setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); - } - } - - const pagedSettledEntries = useMemo(() => { - if (settledEntries.length <= settledVisibleCount) return settledEntries; - const visible = settledEntries.slice(0, settledVisibleCount); - // Open thread must stay reachable under "Show more". - if (props.routeThreadKey !== null) { - const routeEntry = settledEntries - .slice(settledVisibleCount) - .find( - (entry) => - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === - props.routeThreadKey, - ); - if (routeEntry !== undefined) visible.push(routeEntry); - } - return visible; - }, [props.routeThreadKey, settledEntries, settledVisibleCount]); - - const renderedSettledEntries = useMemo(() => { - if (!props.hideSettledThreads || settledEntries.length === 0) return []; - if (settledShelfExpanded) return pagedSettledEntries; - if (props.routeThreadKey === null) return []; - const routeEntry = pagedSettledEntries.find( - (entry) => - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === - props.routeThreadKey, - ); - return routeEntry === undefined ? [] : [routeEntry]; - }, [ - pagedSettledEntries, - props.hideSettledThreads, - props.routeThreadKey, - settledEntries.length, - settledShelfExpanded, - ]); - - const hiddenSettledCount = settledEntries.length - pagedSettledEntries.length; - const showMoreSettled = useCallback( - () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), - [], - ); - const toggleSettledShelf = useCallback( - () => setSettledShelfExpanded((value) => !value), - [setSettledShelfExpanded], - ); - - // Date headers under Settled (Last Hour / Earlier Today / …) — same helper - // and rules as Sidebar V2. Single-bucket pages omit headers; View menu can - // disable headers without changing settle-time sort order. - const settledRecencyLayout = useMemo(() => { - void nowMinute; - const layout = groupSettledThreadsByRecencyForSidebarV2( - renderedSettledEntries.map((entry) => entry.thread), - new Date(), - ); - if (!settledRecencyHeadersEnabled) { - return { groups: layout.groups, showHeaders: false }; - } - return layout; - }, [nowMinute, renderedSettledEntries, settledRecencyHeadersEnabled]); - - const settledEntryByThreadKey = useMemo( - () => - new Map( - renderedSettledEntries.map((entry) => [ - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), - entry, - ]), - ), - [renderedSettledEntries], - ); - - // Multi-select range walks rendered rows only (collapsed shelf is out). - const orderedRecentThreadKeys = useMemo( - () => - [...activeEntries, ...renderedSettledEntries].map(({ thread }) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - [activeEntries, renderedSettledEntries], - ); - - if (props.recentThreads.length === 0) { - return ( - -
    No threads
    -
    - ); - } - - const renderThreadRow = (entry: SidebarRecentThread) => { - const threadKey = scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)); - return ( - - ); - }; - - const renderSettledRows = () => { - if (renderedSettledEntries.length === 0) { - return null; - } - if (settledRecencyLayout.showHeaders) { - return ( - <> - {settledRecencyLayout.groups.map((group) => ( -
    -
    - {group.label}
    - - {group.threads.flatMap((thread) => { - const entry = settledEntryByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return entry ? [renderThreadRow(entry)] : []; - })} - + ) : null} + + } + > + + {isSearchingThreads ? ( + threadSearchResults.length > 0 ? ( + + + + ) : ( +

    + No threads found +

    + ) + ) : null} + {!isSearchingThreads ? ( + +
      + {(() => { + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const appendRecencyRows = ( + items: ReactNode[], + rows: readonly EnvironmentThreadShell[], + section: "active" | "settled", + ) => { + const groups = groupSortedThreadsByRecency( + rows, + new Date(`${nowMinute}:00.000Z`), + ); + if (threadGrouping !== "recency" || !shouldShowRecencySectionHeaders(groups)) { + for (const thread of rows) items.push(renderThreadRow(thread, section)); + return; + } + for (const group of groups) { + items.push( +
    • + {group.label} +
    • , + ); + for (const thread of group.threads) { + items.push(renderThreadRow(thread, section)); + } + } + }; + // Pinned block: full cards above the inbox, closed by a + // thin divider (the pin glyphs carry the meaning, so no + // header text). Vanishes entirely at count 0. + // Rows render in the one shared pinned order; only + // reorder-capable rows register as sortable (legacy-server + // pins render in place as plain rows). + const items: ReactNode[] = [ + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} + strategy={verticalListSortingStrategy} + > + {orderedPinnedThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (!reorderablePinnedKeys.has(threadKey)) { + return renderThreadRow(thread, "pinned"); + } + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} + + , + ]; + if (pinnedThreads.length > 0) { + items.push( +
    • , + ); + } + appendRecencyRows(items, activeThreads, "active"); + // Snoozed shelf: between the inbox and Settled — out of the + // way, never gone. The header always renders while anything + // is snoozed (the count is the whole footprint when + // collapsed); rows only when expanded. Vanishes entirely at + // count 0. + if (snoozedThreads.length > 0) { + items.push( +
    • + +
    • , + ); + for (const thread of visibleSnoozedThreads) { + items.push(renderThreadRow(thread, "snoozed")); + } + } + // Settled shelf: upstream Sidebar V2 history below the active inbox. + if (settledThreads.length > 0) { + items.push( +
    • + +
    • , + ); + } + appendRecencyRows(items, renderedSettledThreads, "settled"); + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
    • + +
    • + ) : null} +
    +
    + ) : null} + {!isSearchingThreads && + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProjectGroup ? ( + `No threads in ${scopedProjectGroup.displayName} yet` + ) : ( + "No threads yet" + )}
    - ))} - - ); - } - return ( - - {renderedSettledEntries.map(renderThreadRow)} - - ); - }; - - const renderActiveList = () => { - if (activeEntries.length === 0) { - return null; - } - - if (!props.groupByRecency) { - return ( - - - {activeEntries.map(renderThreadRow)} - + ) : null} - ); - } - - const recencyGroups = groupSortedThreadsByRecency(activeEntries.map((entry) => entry.thread)); - const showSectionHeaders = shouldShowRecencySectionHeaders(recencyGroups); - const entryByThreadKey = new Map( - activeEntries.map((entry) => [ - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), - entry, - ]), - ); - - // Single non-empty bucket: skip headers (e.g. everything is "Last Hour"). - if (!showSectionHeaders) { - return ( - - - {activeEntries.map(renderThreadRow)} - - - ); - } - - return ( - <> - {recencyGroups.map((group) => ( - -
    - {group.label} -
    - - {group.threads.flatMap((thread) => { - const entry = entryByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return entry ? [renderThreadRow(entry)] : []; - })} - -
    - ))} - - ); - }; - - const renderSettledShelf = () => { - if (!props.hideSettledThreads || settledEntries.length === 0) { - return null; - } - return ( - - - {renderSettledRows()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( - - ) : null} - - ); - }; - - return ( - <> - {renderActiveList()} - {renderSettledShelf()} - - ); -}); - -const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, -) { - const { - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - threadPreviewCount, - updateSettings, - openAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - settleThread, - unsettleThread, - sortedProjects, - recentThreads, - threadByKey, - navigateToThread, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - listMode, - onListModeChange, - threadGrouping, - onThreadGroupingChange, - environmentFilterOptions, - selectedEnvironmentIds, - onSelectedEnvironmentIdsChange, - projectFilterOptions, - selectedProjectFilterKey, - onSelectedProjectFilterKeyChange, - ownershipFilter, - onOwnershipFilterChange, - ownershipRelation, - onOwnershipRelationChange, - claimPersonIdByEnvironment, - hideSettledThreads, - onHideSettledThreadsChange, - settledThreadKeys, - threadJumpLabelByKey, - attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - const showThreadListChrome = listMode === "threads"; - const showProjectGroups = showThreadListChrome && usesProjectThreadGrouping(threadGrouping); - const showFlatOrRecencyList = showThreadListChrome && usesFlatThreadGrouping(threadGrouping); - - const selectedProjectFilterValue = - selectedProjectFilterKey !== null && - projectFilterOptions.some((project) => project.projectKey === selectedProjectFilterKey) - ? selectedProjectFilterKey - : LIST_PROJECT_FILTER_ALL; - - // Dot on the filter button when anything is non-default (active filters / - // non-default grouping or hide-settled). Matches Sidebar V2 “scoped” cues. - const defaultHideSettled = usesProjectThreadGrouping(threadGrouping) - ? DEFAULT_HIDE_SETTLED_PROJECTS - : DEFAULT_HIDE_SETTLED_RECENT; - const [settledRecencyHeadersEnabled, setSettledRecencyHeadersEnabled] = useLocalStorage( - SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, - DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, - ListHideSettledSchema, - ); - const listOptionsActive = - !isAllEnvironmentsSelected(selectedEnvironmentIds) || - selectedProjectFilterKey !== null || - threadGrouping !== DEFAULT_WEB_THREAD_GROUPING || - hideSettledThreads !== defaultHideSettled || - ownershipFilter !== DEFAULT_SIDEBAR_OWNERSHIP_FILTER || - ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || - (showFlatOrRecencyList && - settledRecencyHeadersEnabled !== DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS); - - const handleProjectSortOrderChange = useCallback( - (sortOrder: SidebarProjectSortOrder) => { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadSortOrderChange = useCallback( - (sortOrder: SidebarThreadSortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }, - [updateSettings], - ); - const handleThreadPreviewCountChange = useCallback( - (count: SidebarThreadPreviewCount) => { - updateSettings({ sidebarThreadPreviewCount: count }); - }, - [updateSettings], - ); - - const { isMobile, setOpenMobile } = useSidebar(); - const canCreateThread = sortedProjects.length > 0; - const scopedNewThreadProject = - selectedProjectFilterKey === null - ? null - : (sortedProjects.find((project) => project.projectKey === selectedProjectFilterKey) ?? null); - // Multi-project: show a project picker menu (especially useful when - // grouping by project). Single project or a project filter: create immediately. - const needsNewThreadProjectMenu = scopedNewThreadProject === null && sortedProjects.length > 1; - - const createThreadInProject = useCallback( - (project: SidebarProjectSnapshot) => { - const member = project.memberProjects[0]; - if (!member) return; - if (isMobile) { - setOpenMobile(false); - } - void settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id)), - ).then((result) => { - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not create thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }); - }, - [handleNewThread, isMobile, setOpenMobile], - ); - - const handleHeaderNewThreadClick = useCallback(() => { - if (!canCreateThread) return; - if (scopedNewThreadProject) { - createThreadInProject(scopedNewThreadProject); - return; - } - if (sortedProjects.length === 1) { - createThreadInProject(sortedProjects[0]!); - return; - } - // Multi-project without a scope: prefer the command palette "New thread - // in…" flow (same as Sidebar V2) when not in project grouping; when - // grouping by project we still offer an inline menu below. - if (!usesProjectThreadGrouping(threadGrouping)) { - if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - } - }, [ - canCreateThread, - createThreadInProject, - isMobile, - scopedNewThreadProject, - setOpenMobile, - sortedProjects, - threadGrouping, - ]); - - const newThreadButtonClassName = - "relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-sidebar-muted-foreground outline-none transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"; - - return ( - - - {/* Search + New thread on one row (Sidebar V2 layout). */} -
    -
    - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - -
    - {needsNewThreadProjectMenu ? ( - - - - } - > - - - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - - -
    - New thread in -
    - {sortedProjects.map((project) => ( - createThreadInProject(project)} + + { + if (!open) setProjectActionsTarget(null); + }} + > + + + Project settings + + Manage project names, grouping rules, and environments. + +
    + {projectActionsTarget?.memberProjects.map((member) => ( +
    + + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
    + ))} +
    +
    + +
    + {projectActionsTarget?.memberProjects.map((member) => ( +
    - Browse all… - - -
    - ) : ( - - - } - > - - - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - )} -
    - {/* Compact chrome: Threads|Board + view/filter menu. */} -
    - { - const next = value[0]; - if (isWebListMode(next)) { - onListModeChange(next); - } - }} - data-testid="sidebar-list-mode-switcher" - > - {WEB_LIST_MODES.map((mode) => ( - - {WEB_LIST_MODE_LABELS[mode]} - - ))} - - {showThreadListChrome ? ( - <> - - - - } - > - - {listOptionsActive ? ( - + - View & filters - - - -
    - Group threads -
    - { - if (isWebThreadGrouping(value)) { - onThreadGroupingChange(value); + + +
    + {projectActionsTarget.memberProjects.length > 1 ? ( +
    +
    - { - if (value !== "any" && value !== "mine" && value !== "theirs") return; - onOwnershipFilterChange(value); - }} - > - {SIDEBAR_OWNERSHIP_FILTERS.map((value) => ( - - {SIDEBAR_OWNERSHIP_FILTER_LABELS[value]} - - ))} - - - {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( - <> - - -
    - {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} -
    - { - if (!isOwnershipRelation(value)) return; - onOwnershipRelationChange(value); - }} - > - {SIDEBAR_OWNERSHIP_RELATIONS.map((value) => ( - - {SIDEBAR_OWNERSHIP_RELATION_LABELS[value]} - - ))} - -
    - - ) : null} - - onHideSettledThreadsChange(checked === true)} - > - Hide settled - - {showFlatOrRecencyList && hideSettledThreads ? ( - - setSettledRecencyHeadersEnabled(checked === true) - } - > - Date headers on settled - ) : null} - - - {showFlatOrRecencyList ? ( - - - } - > - - - Add project - - ) : null} - - ) : null} -
    - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - + + ))} + + {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( +
    +
    +

    + Remove this project everywhere +

    +

    + Deletes all grouped entries and their conversation history. +

    +
    - +
    ) : null} -
    -
    - ) : null} - - {showFlatOrRecencyList ? ( - - ) : null} - {showProjectGroups ? ( - -
    - Projects -
    - - - - } - > - - - Add project - -
    -
    - - {isManualProjectSorting ? ( - - - project.projectKey)} - strategy={verticalListSortingStrategy} - > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - ))} - - )} - - {projectsLength === 0 ? ( -
    - No projects yet -
    - ) : sortedProjects.length === 0 ? ( -
    - No projects in selected environments -
    - ) : null} -
    - ) : null} - {listMode === "board" ? ( - -
    - Board view is open in the main panel -
    -
    - ) : null} - - ); -}); - -export default function Sidebar() { - const projects = useProjects(); - const sidebarThreads = useThreadShells(); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); - const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - const updateSettings = useUpdateClientSettings(); - const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread, settleThread, unsettleThread } = useThreadActions(); - const serverConfigs = useServerConfigs(); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const nowMinute = useNowMinute(); - const { isMobile, setOpenMobile } = useSidebar(); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const openAddProjectCommandPalette = useCallback( - () => openCommandPalette({ open: "add-project" }), - [], - ); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const desktopUpdateState = useDesktopUpdateState(); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const platform = navigator.platform; - const shortcutModifiers = useShortcutModifierState(); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const [storedListMode, setStoredListMode] = useLocalStorage( - LIST_MODE_STORAGE_KEY, - DEFAULT_WEB_LIST_MODE, - WebListModeSchema, - ); - const defaultThreadGrouping = useMemo(() => { - if (typeof window === "undefined") return DEFAULT_WEB_THREAD_GROUPING; - try { - return defaultThreadGroupingFromLegacyModeStorage( - window.localStorage.getItem(LIST_MODE_STORAGE_KEY), - ); - } catch { - return DEFAULT_WEB_THREAD_GROUPING; - } - }, []); - const [storedThreadGrouping, setStoredThreadGrouping] = useLocalStorage( - LIST_THREAD_GROUPING_STORAGE_KEY, - defaultThreadGrouping, - WebThreadGroupingSchema, - ); - const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( - LIST_ENVIRONMENT_FILTER_STORAGE_KEY, - EMPTY_LIST_ENVIRONMENT_FILTER, - ListEnvironmentFilterSchema, - ); - const [storedProjectFilter, setStoredProjectFilter] = useLocalStorage( - LIST_PROJECT_FILTER_STORAGE_KEY, - null as string | null, - ListProjectFilterSchema, - ); - const [hideSettledRecent, setHideSettledRecent] = useLocalStorage( - LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, - DEFAULT_HIDE_SETTLED_RECENT, - ListHideSettledSchema, - ); - const [hideSettledProjects, setHideSettledProjects] = useLocalStorage( - LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, - DEFAULT_HIDE_SETTLED_PROJECTS, - ListHideSettledSchema, - ); - const [ownershipFilter, setOwnershipFilter] = useState(() => { - try { - return parseSidebarOwnershipFilter( - window.localStorage.getItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY), - ); - } catch { - return DEFAULT_SIDEBAR_OWNERSHIP_FILTER; - } - }); - const handleOwnershipFilterChange = useCallback((filter: SidebarOwnershipFilter) => { - setOwnershipFilter(filter); - try { - window.localStorage.setItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, filter); - } catch { - // ignore - } - }, []); - const [ownershipRelation, setOwnershipRelation] = useState(() => { - try { - const raw = window.localStorage.getItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY); - if (isOwnershipRelation(raw)) return raw; - } catch { - // ignore - } - return DEFAULT_OWNERSHIP_RELATION; - }); - const handleOwnershipRelationChange = useCallback((relation: OwnershipRelation) => { - setOwnershipRelation(relation); - try { - window.localStorage.setItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, relation); - } catch { - // ignore - } - }, []); - const hideSettledThreads = usesProjectThreadGrouping(storedThreadGrouping) - ? hideSettledProjects - : hideSettledRecent; - const handleHideSettledThreadsChange = useCallback( - (hide: boolean) => { - if (usesProjectThreadGrouping(storedThreadGrouping)) { - setHideSettledProjects(hide); - return; - } - setHideSettledRecent(hide); - }, - [setHideSettledProjects, setHideSettledRecent, storedThreadGrouping], - ); - const availableEnvironmentIds = useMemo( - () => new Set(environments.map((environment) => environment.environmentId)), - [environments], - ); - const selectedEnvironmentIds = useMemo( - () => - resolveSelectedEnvironmentIds( - storedEnvironmentFilter as readonly EnvironmentId[], - availableEnvironmentIds, - ), - [availableEnvironmentIds, storedEnvironmentFilter], - ); - const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); - const environmentFilterOptions = useMemo( - () => - environments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - })), - [environments], - ); - const handleListModeChange = useCallback( - (mode: WebListMode) => { - setStoredListMode(mode); - if (mode === "board") { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/board" }); - return; - } - if (pathname === "/board") { - void navigate({ to: "/" }); - } - }, - [isMobile, navigate, pathname, setOpenMobile, setStoredListMode], - ); - const handleSelectedEnvironmentIdsChange = useCallback( - (next: readonly EnvironmentId[]) => { - setStoredEnvironmentFilter([...next]); - }, - [setStoredEnvironmentFilter], - ); - const environmentLabelById = useMemo( - () => - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - const desktopLocalEnvironmentIds = useMemo( - () => - new Set( - environments - .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ), - [environments], - ); - const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - }, [projectOrder, projects]); - - // Build a mapping from physical project key → logical project key for - // cross-environment grouping. Projects that share a repositoryIdentity - // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - return buildPhysicalToLogicalProjectKeyMap({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }); - }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); - const projectPhysicalKeyByScopedRef = useMemo( - () => - new Map( - orderedProjects.map((project) => [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - derivePhysicalProjectKey(project), - ]), - ), - [orderedProjects], - ); - - const sidebarProjects = useMemo(() => { - return buildSidebarProjectSnapshots({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), - }); - }, [ - environmentLabelById, - desktopLocalEnvironmentIds, - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - ]); - - const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], - ); - const sidebarThreadByKey = useMemo( - () => - new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [sidebarThreads], - ); - // Resolve the active route's project key to a logical key so it matches the - // sidebar's grouped project entries. - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; - } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); - - // Group threads by logical project key so all threads from grouped projects - // are displayed together. - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreads) { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }), - [routeTerminalOpen], - ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], - ); - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], - ); - - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); - }, []); - - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjects.map( - (member) => member.physicalProjectKey, - ); - const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); - reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); - }, - [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], - ); - - const handleProjectDragStart = useCallback( - (_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, - [sidebarProjectSortOrder], - ); - - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }, []); - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }, []); - - const visibleThreads = useMemo( - () => - sidebarThreads.filter( - (thread) => - thread.archivedAt === null && - matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && - threadMatchesMine({ - claimPersonId: claimPersonIdForEnvironment( - claimPersonIdByEnvironment, - thread.environmentId, - ), - originPersonId: thread.originSource?.personId ?? null, - participantPersonIds: (thread.participantSummaries ?? []).map( - (participant) => participant.personId, - ), - mode: ownershipFilter, - relation: ownershipRelation, - }), - ), - [ - claimPersonIdByEnvironment, - ownershipFilter, - ownershipRelation, - selectedEnvironmentIds, - sidebarThreads, - ], - ); - const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - return sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - if (!resolvedProject) { - return []; - } - if ( - !resolvedProject.memberProjects.some((member) => - matchesEnvironmentFilter(member.environmentId, selectedEnvironmentIds), - ) - ) { - return []; - } - return [resolvedProject]; - }); - }, [ - sidebarProjectSortOrder, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - selectedEnvironmentIds, - sidebarProjectByKey, - sidebarProjects, - visibleThreads, - ]); - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next classification pass — same - // path Sidebar V2 and the board use so hide-settled matches across surfaces. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); - const settledThreadKeys = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - const keys = new Set(); - for (const thread of visibleThreads) { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if ( - isThreadSettledForDisplay(thread, { - serverConfigs, - now, - autoSettleAfterDays, - changeRequestState: changeRequestStateByKey.get(threadKey) ?? null, - }) - ) { - keys.add(threadKey); - } - } - return keys; - }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs, visibleThreads]); - const selectedProjectFilterKey = - storedProjectFilter !== null && - sortedProjects.some((project) => project.projectKey === storedProjectFilter) - ? storedProjectFilter - : null; - const projectFilteredProjects = useMemo( - () => - selectedProjectFilterKey === null - ? sortedProjects - : sortedProjects.filter((project) => project.projectKey === selectedProjectFilterKey), - [selectedProjectFilterKey, sortedProjects], - ); - const projectFilterOptions = useMemo( - () => - sortedProjects.map((project) => ({ - projectKey: project.projectKey, - displayName: project.displayName, - environmentId: project.environmentId, - workspaceRoot: project.workspaceRoot, - })), - [sortedProjects], - ); - /** - * Flat/recency groupings: unarchived threads sorted by latest activity. - * Settled rows stay in this list; when hide-settled is on, the recent list - * shelves them at the bottom instead of omitting them. - */ - const recentThreads = useMemo(() => { - const memberKeysForSelectedProject = - selectedProjectFilterKey === null - ? null - : new Set( - ( - sortedProjects.find((project) => project.projectKey === selectedProjectFilterKey) - ?.memberProjects ?? [] - ).map((member) => scopedProjectKey(scopeProjectRef(member.environmentId, member.id))), - ); - return sortThreads(visibleThreads, "updated_at").flatMap((thread) => { - const memberKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const physicalKey = projectPhysicalKeyByScopedRef.get(memberKey) ?? memberKey; - const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - if ( - memberKeysForSelectedProject !== null && - !memberKeysForSelectedProject.has(memberKey) && - projectKey !== selectedProjectFilterKey - ) { - return []; - } - const project = sidebarProjectByKey.get(projectKey); - return project ? [{ thread, project }] : []; - }); - }, [ - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - selectedProjectFilterKey, - sidebarProjectByKey, - sortedProjects, - visibleThreads, - ]); - // Jump shortcuts target the main inbox only when settled are shelved — - // collapsed history shouldn't consume 1–9 slots. - const recentThreadKeys = useMemo( - () => - recentThreads.flatMap(({ thread }) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if ( - hideSettledRecent && - usesFlatThreadGrouping(storedThreadGrouping) && - settledThreadKeys.has(threadKey) - ) { - return []; - } - return [threadKey]; - }), - [hideSettledRecent, recentThreads, settledThreadKeys, storedThreadGrouping], - ); - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => - thread.archivedAt === null && - matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && - threadMatchesMine({ - claimPersonId: claimPersonIdForEnvironment( - claimPersonIdByEnvironment, - thread.environmentId, - ), - originPersonId: thread.originSource?.personId ?? null, - participantPersonIds: (thread.participantSummaries ?? []).map( - (participant) => participant.personId, - ), - mode: ownershipFilter, - relation: ownershipRelation, - }), - ), - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - claimPersonIdByEnvironment, - ownershipFilter, - ownershipRelation, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - selectedEnvironmentIds, - sortedProjects, - threadsByProjectKey, - ], - ); - const jumpCandidateThreadKeys = useMemo( - () => - storedListMode === "threads" && usesFlatThreadGrouping(storedThreadGrouping) - ? recentThreadKeys - : visibleSidebarThreadKeys, - [recentThreadKeys, storedListMode, storedThreadGrouping, visibleSidebarThreadKeys], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of jumpCandidateThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [jumpCandidateThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const sidebarShortcutContext = { - terminalFocus: false, - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - const threadJumpLabelByKey = useMemo( - () => - buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: sidebarShortcutContext.terminalOpen, - threadJumpCommandByKey, - }), - [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], - ); - const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { - platform, - context: sidebarShortcutContext, - }, - ); - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - const prewarmedSidebarThreadKeys = useMemo( - // Browser clients can sit behind constrained remote links. Prewarming every - // visible thread hydrates several full detail windows before the user opens - // any of them, so keep the eager cache warm-up desktop-only. The active - // route still subscribes to its selected thread normally in either mode. - () => (isElectron ? getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys) : []), - [visibleSidebarThreadKeys], - ); - const prewarmedSidebarThreadRefs = useMemo( - () => - prewarmedSidebarThreadKeys.flatMap((threadKey) => { - const ref = parseScopedThreadKey(threadKey); - return ref ? [ref] : []; - }), - [prewarmedSidebarThreadKeys], - ); - - useEffect(() => { - updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); - }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); - - useEffect(() => { - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - const shortcutContext = getCurrentSidebarShortcutContext(); - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - if (command === "board.open") { - event.preventDefault(); - event.stopPropagation(); - setStoredListMode("board"); - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/board" }); - return; - } - - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - window.addEventListener("keydown", onWindowKeyDown); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - }; - }, [ - getCurrentSidebarShortcutContext, - isMobile, - keybindings, - navigate, - navigateToThread, - orderedSidebarThreadKeys, - platform, - routeThreadKey, - setStoredListMode, - sidebarThreadByKey, - setOpenMobile, - threadJumpThreadKeys, - ]); - - useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (!useThreadSelectionStore.getState().hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); - return () => { - window.removeEventListener("mousedown", onMouseDown); - }; - }, [clearSelection]); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const commandPaletteShortcutLabel = shortcutLabelForCommand( - keybindings, - "commandPalette.toggle", - newThreadShortcutLabelOptions, - ); - const handleDesktopUpdateButtonClick = useCallback(() => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }); - return; - } - - if (desktopUpdateButtonAction === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform), - ); - if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }); - } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - - const expandThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }, []); - - const collapseThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }, []); - - useEffect( - () => - subscribeToProjectReveal(({ environmentId, projectId }) => { - const physicalProjectKey = `${environmentId}:${projectId}`; - const projectKey = physicalToLogicalKey.get(physicalProjectKey) ?? physicalProjectKey; - if (!sidebarProjectByKey.has(projectKey)) return; - expandThreadListForProject(projectKey); - requestAnimationFrame(() => { - const rows = document.querySelectorAll("[data-project-key]"); - for (const row of rows) { - if (row.dataset.projectKey !== projectKey) continue; - row.scrollIntoView({ behavior: "smooth", block: "nearest" }); - break; - } - }); - }), - [expandThreadListForProject, physicalToLogicalKey, sidebarProjectByKey], - ); - - return ( - - {prewarmedSidebarThreadRefs.map((threadRef) => ( - - ))} - - - {isOnSettings ? ( - - ) : ( - <> - - - - )} - + + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + + + + + + ); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx deleted file mode 100644 index 69cc3fe7bdfd..000000000000 --- a/apps/web/src/components/SidebarV2.tsx +++ /dev/null @@ -1,4207 +0,0 @@ -import { autoAnimate } from "@formkit/auto-animate"; -import { useAtomValue } from "@effect/atom-react"; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; -import { - groupSortedThreadsByRecency, - shouldShowRecencySectionHeaders, -} from "@t3tools/client-runtime/state/thread-recency-groups"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { - scopeProjectRef, - scopeThreadRef, - scopedThreadKey, -} from "@t3tools/client-runtime/environment"; -import type { - EnvironmentId, - ScopedThreadRef, - SidebarProjectGroupingMode, -} from "@t3tools/contracts"; -import type { TimestampFormat } from "@t3tools/contracts/settings"; -import { - AlarmClockIcon, - AlarmClockOffIcon, - CheckIcon, - ChevronDownIcon, - CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, - ClockIcon, - CopyIcon, - FolderIcon, - FolderPlusIcon, - GitBranchIcon, - EllipsisIcon, - ListIcon, - MessageSquareIcon, - ListFilterIcon, - PinIcon, - PlusIcon, - SearchIcon, - ServerIcon, - SquareKanbanIcon, - SquarePenIcon, - TerminalIcon, - Trash2Icon, - Undo2Icon, - XIcon, -} from "lucide-react"; -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - type ReactNode, -} from "react"; -import { useLocation, useParams, useRouter } from "@tanstack/react-router"; - -import { - isAtomCommandInterrupted, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import { isElectron } from "../env"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHintsForModifiers, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; -import { useShortcutModifierState } from "../shortcutModifierState"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isModelPickerOpen } from "../modelPickerVisibility"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { isMacPlatform } from "~/lib/utils"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { readLocalApi } from "../localApi"; -import { - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; -import { - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useThreadActions } from "../hooks/useThreadActions"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; -import { subscribeToProjectReveal } from "../projectJump"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { useNowMinute } from "../hooks/useNowMinute"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; -import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; -import { vcsEnvironment } from "../state/vcs"; -import { threadEnvironment } from "../state/threads"; -import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { - buildThreadRouteParams, - resolveActiveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; -import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; -import type { SidebarThreadSummary } from "../types"; -import { cn } from "~/lib/utils"; -import { ThreadIdentityMark } from "./identity/ParticipantStack"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; -import { - isIdentityClaimRequiredMessage, - requestIdentityClaimGate, -} from "./identity/IdentityClaimGate"; -import { - claimPersonIdForEnvironment, - DEFAULT_OWNERSHIP_RELATION, - isOwnershipRelation, - threadMatchesMine, - type OwnershipRelation, -} from "@t3tools/client-runtime/state/identity"; -import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; -import { - SETTLED_TAIL_INITIAL_COUNT, - SETTLED_TAIL_PAGE_COUNT, - buildBulkTitleRegenerationContextMenuItem, - formatWorkingDurationLabel, - firstValidTimestampMs, - hasUnseenCompletion, - isTrailingDoubleClick, - orderItemsByPreferredIds, - planPinnedReorder, - resolveAdjacentThreadId, - resolveSettledTimestamp, - resolveSidebarV2Status, - searchSidebarThreadsByTitle, - resolveWorkingStartedAt, - shouldNavigateAfterProjectRemoval, - sortLogicalProjectsForSidebar, - sortPinnedThreadsForSidebarV2, - sortSettledThreadsForSidebarV2, - sortThreadsForSidebarV2, -} from "./Sidebar.logic"; -import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; -import { - prStatusIndicator, - resolveThreadPr, - settledPrHoverColorClass, - terminalStatusFromRunningIds, - type TerminalStatusIndicator, -} from "./ThreadStatusIndicators"; -import { - resolveSnoozePresets, - snoozeWakeDescription, - snoozeWakeLabel, - type SnoozePreset, -} from "./Sidebar.snooze"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { AiUsageStats } from "./chat/AiUsageStats"; -import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; -import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { resolveDriverUsage, usageDotFillClass, usageDotRingColor } from "../aiUsageState"; -import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot"; -import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Kbd } from "./ui/kbd"; -import { - Menu, - MenuCheckboxItem, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "./ui/menu"; -import { useLocalStorage } from "~/hooks/useLocalStorage"; -import { - DEFAULT_SIDEBAR_OWNERSHIP_FILTER, - DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, - DEFAULT_WEB_THREAD_GROUPING, - EMPTY_LIST_ENVIRONMENT_FILTER, - LIST_ENVIRONMENT_FILTER_STORAGE_KEY, - LIST_MODE_STORAGE_KEY, - LIST_THREAD_GROUPING_STORAGE_KEY, - ListEnvironmentFilterSchema, - ListHideSettledSchema, - parseSidebarOwnershipFilter, - SIDEBAR_OWNERSHIP_FILTER_LABELS, - SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, - SIDEBAR_OWNERSHIP_FILTERS, - SIDEBAR_OWNERSHIP_RELATION_LABELS, - SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, - SIDEBAR_OWNERSHIP_RELATIONS, - SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, - WEB_THREAD_GROUPING_LABELS, - WEB_THREAD_GROUPINGS, - WebThreadGroupingSchema, - defaultThreadGroupingFromLegacyModeStorage, - isAllEnvironmentsSelected, - isEnvironmentSelected, - matchesEnvironmentFilter, - resolveSelectedEnvironmentIds, - toggleEnvironmentId, - usesFlatThreadGrouping, - type SidebarOwnershipFilter, - type WebThreadGrouping, -} from "./listEnvironmentFilter"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; -import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { useComposerDraftStore } from "../composerDraftStore"; - -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; - -function compactSidebarTimeLabel(label: string): string { - if (label === "just now") return "now"; - return label.endsWith(" ago") ? label.slice(0, -4) : label; -} - -function threadTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; - return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); -} - -// Settled rows read "how long ago did this wrap up", matching their sort -// key: both go through resolveSettledTimestamp so label and order can't -// disagree. -function settledTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = resolveSettledTimestamp(thread); - return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); -} - -// Floats at the row's right edge, vertically centered, while the jump -// modifier is held. An overlay pill instead of an inline slot: the hint -// must neither displace the status/time label (holding ⌘ used to blank -// out "Working") nor shift any layout when it appears. pointer-events-none -// so it never swallows clicks meant for the settle/un-settle buttons it -// can overlap. -function JumpHintBadge(props: { label: string }) { - return ( - - {props.label} - - ); -} - -// Self-ticking so only this span re-renders each second, not the whole row. -function WorkingDuration(props: { startedAt: string | null }) { - const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; - const [, setTick] = useState(0); - useEffect(() => { - if (Number.isNaN(startedMs)) return; - const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); - return () => window.clearInterval(id); - }, [startedMs]); - if (Number.isNaN(startedMs)) return null; - return ( - - {formatWorkingDurationLabel(Date.now() - startedMs)} - - ); -} - -function terminalProcessLabel(count: number): string { - return `${count} terminal ${count === 1 ? "process" : "processes"} running`; -} - -function SidebarV2ThreadTooltip({ - thread, - projectTitle, - projectCwd, - environmentLabel, - driverKind, - modelInstanceId, - modelLabel, - branchMismatch, - usageDotClass, - usageRingColor, - threadUsage, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - projectTitle: string | null; - projectCwd: string | null; - environmentLabel: string | null; - driverKind: ProviderInstanceEntry["driverKind"] | null; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - usageDotClass?: string | undefined; - usageRingColor?: string | undefined; - threadUsage?: ReturnType | undefined; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - return ( - -
    -
    - {thread.title} -
    -
    - {projectTitle ? ( -
    - -
    {projectTitle}
    -
    - ) : null} - {environmentLabel ? ( -
    - -
    {environmentLabel}
    -
    - ) : null} - {thread.branch ? ( -
    - -
    {thread.branch}
    -
    - ) : null} - {branchMismatch ? ( -
    - -
    - You're currently checked out on another branch. -
    -
    - ) : null} - {driverKind ? ( -
    - -
    {modelLabel}
    -
    - ) : null} - {threadUsage ? ( -
    - -
    - ) : null} - {terminalStatus ? ( -
    - -
    - {terminalProcessLabel(terminalProcessCount)} -
    -
    - ) : null} - {thread.session?.lastError ? ( -
    - -
    Error occurred
    -
    - ) : null} -
    -
    -
    - ); -} - -/** - * Hover entry point for snooze: a clock button opening the preset menu. - * Controlled by the row (which also uses the open state to pin its hover - * actions while the menu is up). - */ -function SnoozePopoverButton(props: { - open: boolean; - onOpenChange: (open: boolean) => void; - onSnooze: (preset: SnoozePreset) => void; - timestampFormat: TimestampFormat; -}) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; - // Presets resolve at open time so "In 1 hour" is relative to the click, - // not to when the row mounted. - const presets = useMemo( - () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), - [open, timestampFormat], - ); - return ( - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - > - - - - {presets.map((preset) => ( - - ))} - - - ); -} - -// Subset of useSortable applied to a pinned card's root
  • . Listeners go -// on the whole card (no dedicated handle): the pointer sensor's distance -// constraint keeps plain clicks working, and we skip dnd-kit's aria -// attributes since there is no keyboard sensor and the card body already -// carries its own button semantics. -type SortablePinnedRowBag = Pick< - ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" ->; - -function SortablePinnedThreadRow(props: { - id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; -}) { - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.id, - }); - return props.children({ listeners, setNodeRef, transform, transition, isDragging }); -} - -const SidebarV2Row = memo(function SidebarV2Row(props: { - thread: SidebarThreadSummary; - variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; - // False on environments whose server predates thread.settle/unsettle: - // the lifecycle affordances hide entirely rather than fail on click. - settlementSupported: boolean; - // Same contract for thread.snooze/unsnooze. - snoozeSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. - pinningSupported: boolean; - isPinned: boolean; - // Present only on pinned cards whose server supports reordering: dnd-kit - // sortable bag applied to the card root so the whole card drags (the - // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; - // Compact wake countdown ("2h") for rows in the snoozed shelf. - snoozeWakeLabelText: string | null; - // When a snooze ended (timer or early wake); drives the Woke pill until - // the user visits the thread. - wokeAt: string | null; - isActive: boolean; - jumpLabel: string | null; - currentEnvironmentId: string | null; - environmentLabel: string | null; - projectCwd: string | null; - projectTitle: string | null; - providerEntryByInstanceId: ReadonlyMap; - timestampFormat: TimestampFormat; - onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; - onThreadActivate: (threadRef: ScopedThreadRef) => void; - onStartRename: (threadRef: ScopedThreadRef, title: string) => void; - onRenameTitleChange: (title: string) => void; - onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; - onCancelRename: () => void; - isRenaming: boolean; - renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; - onSettle: (threadRef: ScopedThreadRef) => void; - onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; - onUnsnooze: (threadRef: ScopedThreadRef) => void; - onUnpin: (threadRef: ScopedThreadRef) => void; - onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; -}) { - const { - isRenaming, - onChangeRequestState, - onCancelRename, - onCommitRename, - onContextMenu, - onAcknowledgeWoke, - onRenameTitleChange, - onSettle, - onSnooze, - onStartRename, - onThreadActivate, - onThreadClick, - onUnsettle, - onUnsnooze, - onUnpin, - renamingTitle, - thread, - variant, - variantAction, - } = props; - const threadRef = useMemo( - () => scopeThreadRef(thread.environmentId, thread.id), - [thread.environmentId, thread.id], - ); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const openPrLink = useOpenPrLink(); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const terminalProcessCount = runningTerminalIds.length; - - // Same semantics as v1 (never-visited counts as read): flipping the beta - // flag must not light up every historical thread as unread. - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarV2Status(thread); - // Screen-reader status for an in-flight title regeneration: the v2 rows are - // a fork rewrite of upstream's row, so this never came across with the rest - // of that surface even though the projection field did. - const isRegeneratingTitle = thread.titleRegeneration != null; - // A woken thread reappears at its original position (the sort is - // deliberately static), so the pill has to carry the weight. Snoozing is - // an explicit act, so the pill clears only when the user re-engages: - // reading a completion-triggered wake, clicking the pill, sending a - // message, settling, archiving — or finishing the work outright (merged - // or closed PR). Timer wakes survive a mere visit. An unparseable visit - // timestamp counts as never-visited — corrupt local data must not eat - // the wake signal. - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.listStatus({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); - const prState = pr?.state ?? null; - - const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); - const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = - wokeAtDate !== null && - (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - prState !== "merged" && - prState !== "closed"; - // In-flight rows (working, or waiting on approval/input) fade as a whole: - // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, failed, and - // freshly woken. The status label keeps its hue, so waiting rows stay - // findable. In-flight rows recede the same as read-ready ones (inbox-zero: - // working threads aren't your problem yet) — only the colored status label - // stands out. - const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; - const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. - const topStatus = - status === "working" - ? { - label: "Working", - icon: "working" as const, - // No shimmer: a label that animates forever is noise in a sidebar - // full of them (and repaints every vsync on high-refresh displays). - // Working is a background state, so it rests at the dim end of what - // the old pulse cycled through; only the thread you have open gets - // the label at full strength. - className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), - } - : status === "monitoring" - ? { - // Monitoring is calm background presence, not active progress - // (monitoring-pill D6), so it keeps the label at full strength. - label: "Monitoring", - icon: null, - className: "text-sky-600 dark:text-sky-400", - } - : status === "approval" - ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", - } - : status === "input" - ? { - label: "Input", - icon: null, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "failed" - ? { - label: "Failed", - icon: null, - className: "text-red-700 dark:text-red-300", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } - : null; - const isWokeStatus = topStatus?.icon === "woke"; - - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. - useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); - - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId); - const threadUsage = useMemo( - () => resolveDriverUsage(aiUsageSnapshot, driverKind, thread.modelSelection.model), - [aiUsageSnapshot, driverKind, thread.modelSelection.model], - ); - const usageDotClass = threadUsage ? usageDotFillClass(threadUsage.marker) : undefined; - const usageRingColor = threadUsage ? usageDotRingColor(threadUsage.marker) : undefined; - - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; - - const detailsTooltip = ( - - ); - - const handleClick = useCallback( - (event: ReactMouseEvent) => { - onThreadClick(event, threadRef); - }, - [onThreadClick, threadRef], - ); - const handleAcknowledgeWokeClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (props.wokeAt === null) return; - onAcknowledgeWoke(threadRef, props.wokeAt); - }, - [onAcknowledgeWoke, props.wokeAt, threadRef], - ); - const handleContextMenu = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); - }, - [onContextMenu, threadRef], - ); - const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onThreadActivate(threadRef); - }, - [onThreadActivate, threadRef], - ); - const handleDoubleClick = useCallback( - (event: ReactMouseEvent) => { - if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - if ((event.target as HTMLElement).closest("button, a, input")) return; - event.preventDefault(); - onStartRename(threadRef, thread.title); - }, - [isRenaming, onStartRename, thread.title, threadRef], - ); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } - }, - [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], - ); - const handleRenameBlur = useCallback(() => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }, [onCommitRename, renamingTitle, thread.title, threadRef]); - const handleSettleClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onSettle(threadRef); - }, - [onSettle, threadRef], - ); - const handleUnsettleClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsettle(threadRef); - }, - [onUnsettle, threadRef], - ); - const handleUnsnoozeClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsnooze(threadRef); - }, - [onUnsnooze, threadRef], - ); - const handleUnpinClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnpin(threadRef); - }, - [onUnpin, threadRef], - ); - const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { - onSnooze(threadRef, preset); - }, - [onSnooze, threadRef], - ); - // While the snooze popover is open the pointer leaves the row, which - // would fade the hover actions out from under the open menu; pin them. - const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); - // Snooze is offered only where it can succeed: capability-gated and never - // on blocked-on-you work or queued turns (the server rejects both). - const showSnoozeButton = - props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); - // If the thread becomes blocked while the popover is open, the button - // unmounts without firing onOpenChange(false). Deriving the flag keeps a - // stale true from permanently hiding the status label / pinning the - // hover actions, and the effect clears the raw state so the popover - // doesn't resurrect if the button later remounts. - const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; - useEffect(() => { - if (!showSnoozeButton) setSnoozeMenuOpen(false); - }, [showSnoozeButton]); - const handlePrClick = useCallback( - (event: ReactMouseEvent) => { - if (pr?.url) openPrLink(event, pr.url); - }, - [openPrLink, pr], - ); - - // All Sidebar V2 rows share one surface model. Live threads used to look - // like elevated cards while settled threads were plain rows, leaving neither - // a useful hierarchy nor a reliable hover cue. Status now lives in the row - // content; surface is reserved for interaction (hover, multi-select, route). - const rowSurfaceClassName = cn( - "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", - props.isActive - ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected - ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", - ); - - const participants = thread.participantSummaries ?? []; - const originChannel = thread.originSource?.channel ?? participants[0]?.firstChannel ?? null; - - const title = ( -
    - {isRenaming ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" - /> - ) : ( - - {thread.title} - - )} - {!isRenaming ? ( - - ) : null} -
    - ); - - const prBadge = - prStatus && pr ? ( - - ) : null; - const terminalStatusIcon = terminalStatus ? ( - - - - ) : null; - - if (variant === "slim") { - return ( -
  • - - - } - > - {/* Settled history recedes: dimmed favicon at rest, restored on - hover so the tail stays scannable when you're hunting. */} - - - - {title} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {/* The PR badge stays outside the hover-fading slot: it must - remain visible AND clickable while the row is hovered. Only - the time/jump label yields to the settle affordance. */} - {prBadge} - - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. - - ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - - {props.jumpLabel ? : null} - - {detailsTooltip} - -
  • - ); - } - - const diff = latestTurnDiff(thread); - - const sortable = props.sortable; - return ( -
  • - - - } - > -
    -
    - - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - {props.isPinned ? ( - props.pinningSupported ? ( - - ) : ( - - ) - ) : null} - {/* The visible state owns this slot's width: status at rest, - actions on hover/keyboard focus or while the popover is open. Keeping - the hidden state out of flow lets the project label reclaim - space without either state overlapping it. */} - - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} - - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( - - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - ) : null} - - ) : null} - -
    -
    - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    -
    - {/* While working, the current plan step outranks the branch: - it's the one line that says what the thread is doing. */} - {status === "working" && thread.planProgress ? ( - - {thread.planProgress.step} - {/* Completed count, matching the transcript chip's n/m. */} - - {" "} - {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} - - - ) : thread.branch ? ( - {thread.branch} - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - - - ) : null} - -
    -
    - {props.jumpLabel ? : null} -
    - {detailsTooltip} -
    -
  • - ); -}); - -function latestTurnDiff( - thread: SidebarThreadSummary, -): { insertions: number; deletions: number } | null { - // Shells don't carry checkpoint summaries; diff stats render only when the - // shell projection grows them. Kept as a seam so the row layout is ready. - void thread; - return null; -} - -const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { - thread: SidebarThreadSummary; - projectCwd: string | null; - projectTitle: string | null; - environmentLabel: string | null; - providerEntryByInstanceId: ReadonlyMap; - isHighlighted: boolean; - isRouteActive: boolean; - resultId: string; - onHighlight: () => void; - onSelect: () => void; -}) { - const { thread } = props; - // Same details tooltip as the regular rows: a search hit is still a thread, - // and the hover card is how you disambiguate identically-titled results. - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.listStatus({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, - }); - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - return ( -
  • - - - } - > - - {thread.title} - - {threadTimeLabel(thread)} - - - - -
  • - ); -}); - -export default function SidebarV2() { - const projects = useProjects(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const threads = useThreadShells(); - const router = useRouter(); - const { isMobile, setOpenMobile } = useSidebar(); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const timestampFormat = useClientSettings((s) => s.timestampFormat); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const [threadGrouping, setThreadGrouping] = useLocalStorage( - LIST_THREAD_GROUPING_STORAGE_KEY, - DEFAULT_WEB_THREAD_GROUPING, - WebThreadGroupingSchema, - ); - const { - settleThread, - unsettleThread, - snoozeThread, - unsnoozeThread, - pinThread, - unpinThread, - reorderPinnedThread, - deleteThread, - } = useThreadActions(); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { - reportFailure: false, - }); - const updateProject = useAtomCommand(projectEnvironment.update, { - reportFailure: false, - }); - const updateSettings = useUpdateClientSettings(); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: path, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ - target: "branch name", - onCopy: ({ branch }) => { - toastManager.add({ - type: "success", - title: "Branch copied", - description: branch, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy branch", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: string }>({ - onCopy: ({ threadId }) => { - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: threadId, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const [projectActionsTarget, setProjectActionsTarget] = useState( - null, - ); - const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); - const newThreadContext = useHandleNewThread(); - const openAddProjectCommandPalette = useCallback( - () => openCommandPalette({ open: "add-project" }), - [], - ); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); - const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); - const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); - const acknowledgeWoke = useCallback( - (threadRef: ScopedThreadRef, visitedAt: string) => { - markThreadVisited(scopedThreadKey(threadRef), visitedAt); - }, - [markThreadVisited], - ); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTargetRef = useRef(routeTarget); - routeTargetRef.current = routeTarget; - // Post-settle navigation validates against the CURRENT route, not the one - // captured when the settle started: if the user navigated elsewhere while - // the command was in flight, completing it must not yank them away. - const routeThreadKeyRef = useRef(routeThreadKey); - routeThreadKeyRef.current = routeThreadKey; - - const environmentLabelById = useMemo( - () => - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - const [ownershipFilter, setOwnershipFilter] = useState(() => { - try { - return parseSidebarOwnershipFilter( - window.localStorage.getItem(SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY), - ); - } catch { - return DEFAULT_SIDEBAR_OWNERSHIP_FILTER; - } - }); - const [ownershipRelation, setOwnershipRelation] = useState(() => { - try { - const raw = window.localStorage.getItem(SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY); - if (isOwnershipRelation(raw)) return raw; - } catch { - return DEFAULT_OWNERSHIP_RELATION; - } - return DEFAULT_OWNERSHIP_RELATION; - }); - // Per-environment claims (not primary-only): smart has no map while t3vm does. - const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); - - // Shared with classic list / Board so multi-env filters (e.g. hide t3vm) stick - // when switching sidebars. - const [storedEnvironmentFilter, setStoredEnvironmentFilter] = useLocalStorage( - LIST_ENVIRONMENT_FILTER_STORAGE_KEY, - EMPTY_LIST_ENVIRONMENT_FILTER, - ListEnvironmentFilterSchema, - ); - const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( - SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, - DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, - ListHideSettledSchema, - ); - const availableEnvironmentIds = useMemo( - () => new Set(environments.map((environment) => environment.environmentId)), - [environments], - ); - const selectedEnvironmentIds = useMemo( - () => - resolveSelectedEnvironmentIds( - storedEnvironmentFilter as readonly EnvironmentId[], - availableEnvironmentIds, - ), - [availableEnvironmentIds, storedEnvironmentFilter], - ); - - const listOptionsActive = - ownershipFilter !== DEFAULT_SIDEBAR_OWNERSHIP_FILTER || - ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || - !isAllEnvironmentsSelected(selectedEnvironmentIds) || - settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED; - const orderedProjects = useMemo( - () => - orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }), - [projectOrder, projects], - ); - const unsortedProjectGroups = useMemo( - () => - buildSidebarProjectSnapshots({ - projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }), - [ - environmentLabelById, - orderedProjects, - primaryEnvironmentId, - projectGroupingSettings, - projects, - sidebarProjectSortOrder, - ], - ); - const projectGroups = useMemo( - () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), - [sidebarProjectSortOrder, threads, unsortedProjectGroups], - ); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const providerEntryByInstanceId = useMemo( - () => - new Map( - deriveProviderInstanceEntries(serverProviders).map( - (entry) => [entry.instanceId as string, entry] as const, - ), - ), - [serverProviders], - ); - const projectCwdByKey = useMemo( - () => - new Map( - projects.map((project) => [ - `${project.environmentId}:${project.id}`, - project.workspaceRoot, - ]), - ), - [projects], - ); - const projectDisplayNameByKey = useMemo( - () => - new Map( - projectGroups.flatMap((group) => - group.memberProjects.map( - (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, - ), - ), - ), - [projectGroups], - ); - const orderForThreadGrouping = useCallback( - (ordered: EnvironmentThreadShell[]) => { - if (threadGrouping !== "recency") return ordered; - return ordered.toSorted( - (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt, right.createdAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt, left.createdAt) || - left.id.localeCompare(right.id), - ); - }, - [threadGrouping], - ); - - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. - const nowMinute = useNowMinute(); - // Snooze wake times are second-precise, so classifying with the quantized - // minute would hold a woken thread on the shelf for up to a minute. The - // tick is a plain counter bumped exactly at the next wake boundary (armed - // below, after the partition knows the boundary); the partition reads a - // fresh clock whenever it recomputes. - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); - - // Project scope: one menu above the list. Scoping filters the list without - // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); - useEffect( - () => - subscribeToProjectReveal(({ environmentId, projectId }) => { - const projectGroup = projectGroups.find((project) => - project.memberProjectRefs.some( - (ref) => ref.environmentId === environmentId && ref.projectId === projectId, - ), - ); - if (projectGroup !== undefined) { - setProjectScopeKey(projectGroup.projectKey); - } - }), - [projectGroups], - ); - const scopedProjectGroup = useMemo( - () => - projectScopeKey === null - ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), - [projectGroups, projectScopeKey], - ); - const scopedProjectKeys = useMemo( - () => - scopedProjectGroup === null - ? null - : new Set( - scopedProjectGroup.memberProjectRefs.map( - (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, - ), - ), - [scopedProjectGroup], - ); - useEffect(() => { - if (projectScopeKey !== null && scopedProjectGroup === null) { - setProjectScopeKey(null); - } - }, [projectScopeKey, scopedProjectGroup]); - // Scope flips drop the selection: rows selected under the old scope may be - // hidden now, and bulk actions must never count or touch invisible rows. - useEffect(() => { - clearSelection(); - }, [clearSelection, projectScopeKey]); - - const handleRemoveProjectMembers = useCallback( - async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { - const api = readLocalApi(); - if (!api) return; - - const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); - const projectThreads = threads.filter((thread) => - memberKeys.has(`${thread.environmentId}:${thread.projectId}`), - ); - const isWholeGroup = members.length === projectGroup.memberProjects.length; - const singleMember = members.length === 1 ? members[0]! : null; - const targetLabel = singleMember?.title ?? projectGroup.displayName; - const confirmed = await settlePromise(() => - api.dialogs.confirm( - projectThreads.length > 0 - ? [ - `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - "This permanently clears conversation history for those threads.", - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${targetLabel}"?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - - const draftStore = useComposerDraftStore.getState(); - let shouldNavigate = false; - for (const project of members) { - const memberThreads = projectThreads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ); - const projectRef = scopeProjectRef(project.environmentId, project.id); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ - routeTarget: routeTargetRef.current, - projectThreads: memberThreads, - projectDraftId: projectDraftThread?.draftId ?? null, - }); - - const result = await deleteProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...(memberThreads.length > 0 ? { force: true } : {}), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${project.title}"`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - return; - } - - shouldNavigate ||= memberRemovalNeedsNavigation; - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); - } - draftStore.clearProjectDraftThreadId(projectRef); - } - - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - }, - [deleteProject, router, threads], - ); - - const renameProjectMember = useCallback( - async (member: SidebarProjectGroupMember, nextTitle: string) => { - const title = nextTitle.trim(); - if (!title) { - toastManager.add({ type: "warning", title: "Project title cannot be empty" }); - return; - } - if (title === member.title) return; - const result = await updateProject({ - environmentId: member.environmentId, - input: { projectId: member.id, title }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, - [updateProject], - ); - - const updateProjectGroupingPreference = useCallback( - (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { - const overrideKey = deriveProjectGroupingOverrideKey(member); - const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; - if (selection === "inherit") { - delete nextOverrides[overrideKey]; - } else { - nextOverrides[overrideKey] = selection; - } - updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); - }, - [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], - ); - - const handleProjectActions = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - setProjectScopeMenuOpen(false); - window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); - }, - [], - ); - - // Settled threads stay in the live shell stream (settled ≠ archived), so - // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. - const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { - pinnedThreads, - reorderablePinnedKeys, - activeThreads, - snoozedThreads, - settledThreads, - snoozeNow, - } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && - threadMatchesMine({ - claimPersonId: claimPersonIdForEnvironment( - claimPersonIdByEnvironment, - thread.environmentId, - ), - originPersonId: thread.originSource?.personId ?? null, - participantPersonIds: (thread.participantSummaries ?? []).map( - (participant) => participant.personId, - ), - mode: ownershipFilter, - relation: ownershipRelation, - }), - ); - const pinned: EnvironmentThreadShell[] = []; - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze temporarily suspends a pin; the pin survives and resumes on wake. - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - } else if (thread.pinnedAt != null) { - pinned.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); - } - } - return { - // One shared rule on every platform (see sortPinnedThreadsByOrderKey): - // user-arranged keys first, keyless threads in creation order below. - // Server capability only gates DRAGGING — it must not influence the - // sort, or mixed-version fleets would render different pinned orders - // on web and mobile from the same data. The fork's grouping - // preference therefore applies to the active rows, not to pins. - pinnedThreads: sortPinnedThreadsForSidebarV2(pinned), - reorderablePinnedKeys: new Set( - pinned - .filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === - true, - ) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - activeThreads: orderForThreadGrouping(sortThreadsForSidebarV2(active)), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - claimPersonIdByEnvironment, - nowMinute, - orderForThreadGrouping, - ownershipFilter, - ownershipRelation, - scopedProjectKeys, - selectedEnvironmentIds, - serverConfigs, - snoozeWakeTick, - threads, - ]); - - const threadSearchInputRef = useRef(null); - const [threadSearchQuery, setThreadSearchQuery] = useState(""); - const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); - const isSearchingThreads = threadSearchQuery.trim().length > 0; - const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], - ); - const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], - ); - const threadSearchResultOrderKey = threadSearchResults - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) - .join("\0"); - - useEffect(() => { - setActiveSearchResultIndex(0); - }, [threadSearchResultOrderKey]); - - useEffect(() => { - if (!isSearchingThreads) return; - document - .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); - - // Arm a timeout for the earliest upcoming wake so the shelf empties the - // moment a snooze expires instead of on the next minute tick. Sorted - // soonest-first, so entry 0 is the boundary. - useEffect(() => { - const nextWakeAtMs = - snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null - ? Date.parse(snoozedThreads[0].snoozedUntil) - : Number.NaN; - if (Number.isNaN(nextWakeAtMs)) return; - // setTimeout delays are signed 32-bit: anything larger overflows and - // fires immediately, turning a far-future wake (event-condition snoozes - // synced from elsewhere) into a tight re-arm loop. Clamped, the timer - // just re-arms every ~24.8 days until the wake is in range. - const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); - return () => window.clearTimeout(id); - }, [snoozedThreads]); - - // The settled tail renders in pages: history shouldn't dominate the - // sidebar, and the common lookups are recent. Expansion resets when the - // filter context changes so a scope/search flip never inherits a deep - // page state. - const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = `${projectScopeKey ?? "all"}:${selectedEnvironmentIds.join(",")}`; - const lastSettledResetKeyRef = useRef(settledResetKey); - if (lastSettledResetKeyRef.current !== settledResetKey) { - lastSettledResetKeyRef.current = settledResetKey; - setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); - } - const visibleSettledThreads = useMemo(() => { - if (settledThreads.length <= settledVisibleCount) return settledThreads; - const visible = settledThreads.slice(0, settledVisibleCount); - // The open thread must never hide under "Show more": navigating into a - // deep settled thread (search, deep link) pulls its row into the visible - // tail so the highlight and the un-settle affordance stay reachable. - if (routeThreadKey !== null) { - const routeThread = settledThreads - .slice(settledVisibleCount) - .find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - if (routeThread !== undefined) visible.push(routeThread); - } - return visible; - }, [routeThreadKey, settledThreads, settledVisibleCount]); - const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; - const showMoreSettled = useCallback( - () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), - [], - ); - const toggleSettledShelf = useCallback( - () => setSettledShelfExpanded((value) => !value), - [setSettledShelfExpanded], - ); - const renderedSettledThreads = useMemo(() => { - if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; - const routeThread = visibleSettledThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); - - // The snoozed shelf is collapsed by default: out of the way, never gone. - // Collapsed threads don't render (and so don't participate in jump - // shortcuts or multi-select), matching the settled tail's paging model. - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const visibleSnoozedThreads = useMemo(() => { - if (snoozedShelfExpanded) return snoozedThreads; - // The open thread must never vanish behind the collapsed shelf: a - // snoozed thread reached by route (deep link, open before snoozing - // elsewhere) keeps its row — with highlight and wake affordance — same - // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; - const routeThread = snoozedThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); - - const orderedThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], - ); - const orderedThreadKeys = useMemo( - () => - orderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - [orderedThreads], - ); - // Rows call back into the click handler without carrying the ordered list as - // a prop — a fresh array identity per shell update would defeat every row's - // memoization. The ref keeps shift-range-select working against the list as - // rendered at click time. - const orderedThreadKeysRef = useRef(orderedThreadKeys); - orderedThreadKeysRef.current = orderedThreadKeys; - const threadByKey = useMemo( - () => - new Map( - orderedThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ), - [orderedThreads], - ); - // Handlers read these through refs: depending on per-update Map/Set - // identities would give every row a fresh callback prop on each shell - // event and defeat row memoization during streaming. - const threadByKeyRef = useRef(threadByKey); - threadByKeyRef.current = threadByKey; - // handleNewThread is inherently unstable (depends on the projects list); - // a ref keeps it out of attemptSettle's dependency array. - const handleNewThreadRef = useRef(newThreadContext.handleNewThread); - handleNewThreadRef.current = newThreadContext.handleNewThread; - const settledThreadKeys = useMemo( - () => - new Set( - settledThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - [settledThreads], - ); - const settledThreadKeysRef = useRef(settledThreadKeys); - settledThreadKeysRef.current = settledThreadKeys; - const snoozedThreadKeys = useMemo( - () => - new Set( - snoozedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - [snoozedThreads], - ); - const snoozedThreadKeysRef = useRef(snoozedThreadKeys); - snoozedThreadKeysRef.current = snoozedThreadKeys; - - const jumpLabelByKey = useMemo(() => { - const mapping = new Map(); - for (const [index, threadKey] of orderedThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(index); - if (!jumpCommand) break; - const label = shortcutLabelForCommand(keybindings, jumpCommand); - if (label) mapping.set(threadKey, label); - } - return mapping; - }, [keybindings, orderedThreadKeys]); - const [showJumpHints, setShowJumpHints] = useState(false); - - // Settled threads are live shells, so opening one is plain navigation: - // history stays readable without un-settling, and sending a message or - // starting a session un-settles server-side. - const navigateToThread = useCallback( - (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], - ); - - const clearThreadSearch = useCallback(() => { - setThreadSearchQuery(""); - setActiveSearchResultIndex(0); - }, []); - const selectThreadSearchResult = useCallback( - (thread: EnvironmentThreadShell) => { - clearThreadSearch(); - navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); - }, - [clearThreadSearch, navigateToThread], - ); - const handleThreadSearchKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - // IME composition (Japanese/Chinese input) uses the same keys; committing - // a candidate must not move the highlight or navigate away mid-compose. - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Escape" && isSearchingThreads) { - event.preventDefault(); - event.stopPropagation(); - clearThreadSearch(); - return; - } - if (threadSearchResults.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveSearchResultIndex( - (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length, - ); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - const result = threadSearchResults[activeSearchResultIndex]; - if (result) selectThreadSearchResult(result); - } - }, - [ - activeSearchResultIndex, - clearThreadSearch, - isSearchingThreads, - selectThreadSearchResult, - threadSearchResults, - ], - ); - - const [renamingThreadKey, setRenamingThreadKey] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, []); - const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); - const commitThreadRename = useCallback( - (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { - void (async () => { - const trimmed = title.trim(); - setRenamingThreadKey(null); - if (trimmed.length === 0) { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - return; - } - if (trimmed === originalTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, title: trimmed }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [updateThreadMetadata], - ); - - const handleThreadClick = useCallback( - (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { - const isMac = isMacPlatform(navigator.platform); - const isModClick = isMac ? event.metaKey : event.ctrlKey; - const threadKey = scopedThreadKey(threadRef); - if (isModClick) { - event.preventDefault(); - toggleThreadSelection(threadKey); - return; - } - if (event.shiftKey) { - event.preventDefault(); - rangeSelectTo(threadKey, orderedThreadKeysRef.current); - return; - } - if (isTrailingDoubleClick(event.detail)) { - return; - } - navigateToThread(threadRef); - }, - [navigateToThread, rangeSelectTo, toggleThreadSelection], - ); - - // A settle per thread at a time: double clicks and repeated menu picks - // must not dispatch a second settle that fails and toasts a false error. - const settlingThreadKeysRef = useRef(new Set()); - // Parking the thread you're looking at (settle or snooze) moves you - // forward: the next remaining card (never a settled or snoozed row, never - // one leaving in the same batch), or a fresh draft in this project when it - // was the last active one. Callers snapshot the plan BEFORE the command - // mutates the partition; background parks never navigate (null plan). - const planForwardNavigation = useCallback( - (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { - if (routeThreadKeyRef.current !== threadKey) return null; - const shell = threadByKeyRef.current.get(threadKey); - const orderedKeys = orderedThreadKeysRef.current; - const settledKeys = settledThreadKeysRef.current; - const snoozedKeys = snoozedThreadKeysRef.current; - const currentIndex = orderedKeys.indexOf(threadKey); - const nextCardKey = - currentIndex === -1 - ? null - : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( - (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), - ) ?? null); - const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; - return nextThread - ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : shell - ? () => - void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) - : () => void router.navigate({ to: "/" }); - }, - [navigateToThread, router], - ); - - const attemptSettle = useCallback( - (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { - void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (settlingThreadKeysRef.current.has(threadKey)) return; - settlingThreadKeysRef.current.add(threadKey); - try { - const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - const message = error instanceof Error ? error.message : "An error occurred."; - if (isIdentityClaimRequiredMessage(message)) { - requestIdentityClaimGate(threadRef.environmentId); - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to settle thread", - description: message, - }), - ); - } - return; - } - // Only move forward if the user is still on the settled thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSettle?.(); - } - } finally { - settlingThreadKeysRef.current.delete(threadKey); - } - })(); - }, - [planForwardNavigation, settleThread], - ); - const attemptUnsettle = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - const result = await unsettleThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [unsettleThread], - ); - const attemptUnsnooze = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - const result = await unsnoozeThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [unsnoozeThread], - ); - // Drag-to-reorder for the pinned block. A drop computes ONE fractional key - // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case). The - // optimistic order keeps the card where it was dropped until the - // confirming event round-trips; canonical order matching it releases the - // override, and a failed write clears it (the card snaps back) with a toast. - // ANY membership change (new pin, unpin, snooze/wake) also releases it: - // the override can't say where members it never saw belong, and holding it - // would misplace them and launder the stale order into later drags. - const pinnedDndSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ - readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop, so ANY landed write (ours - confirming, or a concurrent one from another client) releases the - override rather than fighting canonical state. */ - readonly keysAtDrop: ReadonlyMap; - } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); - useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - // The override represents one drop against one snapshot of the world. - // Release it as soon as the world moves on in any way: membership - // changed (pin/unpin/snooze/wake — the override can't say where members - // it never saw belong), a key changed (our write confirming, or a - // concurrent client's reorder that must win), or canonical already - // matches. Holding it longer would misplace newcomers and launder the - // stale order into later drags. - const membershipChanged = - canonicalKeys.length !== optimisticPinnedOrder.order.length || - canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const anyKeyLanded = canonical.some( - (thread, index) => - optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== - (thread.pinOrderKey ?? null), - ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || anyKeyLanded || orderConfirmed) { - setOptimisticPinnedOrder(null); - } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); - const attemptPin = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - // Fresh pins take the top of the arranged run: pinThread computes a - // key before the smallest key across ALL pinned shells — including - // snoozed pins hidden from this list, whose keys are still part of - // the run — so the new pin can't land beneath a hidden head. - const result = await pinThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to pin thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [pinThread], - ); - const attemptUnpin = useCallback( - (threadRef: ScopedThreadRef) => { - void (async () => { - const result = await unpinThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unpin thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, - [unpinThread], - ); - - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, - }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); - void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) — unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setOptimisticPinnedOrder(null); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - } - })(); - }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], - ); - // One snooze per thread at a time — same double-dispatch guard as settle. - const snoozingThreadKeysRef = useRef(new Set()); - const performSnooze = useCallback( - async ( - threadRef: ScopedThreadRef, - preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) { - return { status: "skipped" } as const; - } - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - return isAtomCommandInterrupted(result) - ? ({ status: "interrupted" } as const) - : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); - } - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - return { status: "success" } as const; - } finally { - snoozingThreadKeysRef.current.delete(threadKey); - } - }, - [planForwardNavigation, snoozeThread], - ); - const attemptSnooze = useCallback( - ( - threadRef: ScopedThreadRef, - preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - void (async () => { - const outcome = await performSnooze(threadRef, preset, opts); - if (outcome.status === "failure") { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: - outcome.error instanceof Error ? outcome.error.message : "An error occurred.", - }), - ); - return; - } - if (outcome.status !== "success") return; - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); - })(); - }, - [attemptUnsnooze, performSnooze, timestampFormat], - ); - - const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - // One exact actionable set: keys whose rows are actually rendered - // right now. Selections can outlive their rows (settled-tail paging, - // thread deletion elsewhere) and the menu labels must count only what - // the actions will touch. - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( - (threadKey) => threadByKeyRef.current.has(threadKey), - ); - if (threadKeys.length === 0) return; - const count = threadKeys.length; - // Snooze (N) is offered when every selected thread can actually take - // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const selectedThreads = threadKeys.flatMap((threadKey) => { - const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; - }); - const canSnoozeSelection = selectedThreads.every( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow }), - ); - const titleRegenerationThreads = selectedThreads.filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true, - ); - const regeneratableTitleThreads = titleRegenerationThreads.filter( - (thread) => thread.titleRegeneration == null, - ); - const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ - supportedCount: titleRegenerationThreads.length, - actionableCount: regeneratableTitleThreads.length, - }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); - const clicked = await settlePromise(() => - api.contextMenu.show( - [ - { id: "settle", label: `Settle (${count})` }, - ...(canSnoozeSelection - ? [ - { - id: "snooze", - label: `Snooze (${count})`, - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ), - ); - if (clicked._tag === "Failure") return; - if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); - if (preset) { - // Post-snooze navigation must skip threads snoozing in this same - // batch — they are all leaving the card block together. - const coSnoozingKeys = new Set(threadKeys); - clearSelection(); - const outcomes = await Promise.all( - selectedThreads.map(async (thread) => { - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); - return { outcome, threadRef }; - }), - ); - const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => - outcome.status === "success" ? [threadRef] : [], - ); - const failures = outcomes.flatMap(({ outcome }) => - outcome.status === "failure" ? [outcome.error] : [], - ); - - if (snoozedThreadRefs.length > 0) { - const snoozedCount = snoozedThreadRefs.length; - const failedCount = failures.length; - toastManager.add( - stackedThreadToast({ - type: failedCount > 0 ? "warning" : "success", - title: - failedCount > 0 - ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` - : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, - description: - failedCount > 0 - ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` - : undefined, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); - }, - }, - }), - ); - } else if (failures.length > 0) { - const firstError = failures[0]; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze threads", - description: - firstError instanceof Error ? firstError.message : "An error occurred.", - }), - ); - } - } - return; - } - if (clicked.value === "regenerate-title") { - for (const thread of regeneratableTitleThreads) { - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, - }); - if (result._tag === "Success") continue; - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread titles", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - clearSelection(); - return; - } - if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. Pinned rows ARE included: the decider - // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } - clearSelection(); - return; - } - if (clicked.value === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); - } - clearSelection(); - return; - } - if (clicked.value !== "delete") return; - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - // Grown as deletions actually land, never seeded with the whole batch: - // orphaned-worktree detection must only discount threads that are - // really gone, or the first delete would treat still-alive batch mates - // as deleted and remove a worktree they still point at. - const deletedThreadKeys = new Set(); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - deletedThreadKeys.add(threadKey); - } - removeFromSelection(threadKeys); - }, - [ - attemptSettle, - attemptSnooze, - clearSelection, - confirmThreadDelete, - deleteThread, - markThreadUnread, - performSnooze, - removeFromSelection, - serverConfigs, - attemptUnsnooze, - updateThreadMetadata, - timestampFormat, - ], - ); - - const handleThreadContextMenu = useCallback( - (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - void (async () => { - const api = readLocalApi(); - if (!api) return; - const threadKey = scopedThreadKey(threadRef); - const selectionState = useThreadSelectionStore.getState(); - if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { - await handleMultiSelectContextMenu(position); - return; - } - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) return; - const threadWorkspacePath = - thread.worktreePath ?? - projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? - null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without - // the settlement capability get no lifecycle items at all. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const supportsPinning = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; - const supportsTitleRegeneration = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true; - const isRegeneratingTitle = thread.titleRegeneration != null; - const isSettled = settledThreadKeysRef.current.has(threadKey); - const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); - const isPinned = thread.pinnedAt != null; - // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); - const clicked = await settlePromise(() => - api.contextMenu.show( - buildThreadActionMenuItems({ - branch: thread.branch ?? null, - isPinned, - isSettled, - isSnoozed, - canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), - isRegeneratingTitle, - supports: { - settlement: supportsSettlement, - snooze: supportsSnooze, - pinning: supportsPinning, - titleRegeneration: supportsTitleRegeneration, - }, - snoozePresets, - }), - position, - ), - ); - if (clicked._tag === "Failure") return; - if (clicked.value?.startsWith("snooze:")) { - const preset = snoozePresets.find( - (candidate) => `snooze:${candidate.id}` === clicked.value, - ); - if (preset) attemptSnooze(threadRef, preset); - return; - } - switch (clicked.value) { - case "new-thread-on-branch": { - // Explicit branch carry-over: reuse the thread's worktree when it - // has one, otherwise its branch on the local checkout. - const result = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not create thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - case "settle": - attemptSettle(threadRef); - return; - case "unsettle": - attemptUnsettle(threadRef); - return; - case "unsnooze": - attemptUnsnooze(threadRef); - return; - case "pin": - attemptPin(threadRef); - return; - case "unpin": - attemptUnpin(threadRef); - return; - case "rename": - startThreadRename(threadRef, thread.title); - return; - case "regenerate-title": { - if (!supportsTitleRegeneration || isRegeneratingTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread title", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - case "mark-unread": - markThreadUnread(threadKey, thread.latestTurn?.completedAt); - return; - case "copy-path": - if (!threadWorkspacePath) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }), - ); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); - return; - case "copy-branch": - if (thread.branch) { - copyBranchToClipboard(thread.branch, { branch: thread.branch }); - } - return; - case "copy-thread-id": - copyThreadId(thread.id, { threadId: thread.id }); - return; - case "delete": { - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - return; - } - default: - return; - } - })(); - }, - [ - attemptPin, - attemptSettle, - attemptSnooze, - attemptUnpin, - attemptUnsettle, - attemptUnsnooze, - confirmThreadDelete, - copyBranchToClipboard, - copyPathToClipboard, - copyThreadId, - deleteThread, - handleMultiSelectContextMenu, - markThreadUnread, - projectCwdByKey, - serverConfigs, - startThreadRename, - updateThreadMetadata, - timestampFormat, - ], - ); - - // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as - // v1 — the keybinding layer is shared, only the ordered list differs. - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) return; - const command = resolveShortcutCommand(event, keybindings, { - platform: navigator.platform, - context: { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }, - }); - if (command === "board.open") { - event.preventDefault(); - event.stopPropagation(); - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - return; - } - const navigateToThreadKey = (targetThreadKey: string | null) => { - if (!targetThreadKey) return false; - const targetThread = threadByKey.get(targetThreadKey); - if (!targetThread) return false; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return true; - }; - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }), - ); - return; - } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) return; - navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); - }; - window.addEventListener("keydown", onWindowKeyDown); - return () => window.removeEventListener("keydown", onWindowKeyDown); - }, [ - isMobile, - keybindings, - navigateToThread, - orderedThreadKeys, - routeTerminalOpen, - routeThreadKey, - router, - setOpenMobile, - threadByKey, - ]); - - // Same predicate as v1: hints show only while the held modifiers exactly - // match a thread-jump binding. Adding Shift (screenshots) or Alt no - // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. - const shortcutModifiers = useShortcutModifierState(); - const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { platform: navigator.platform }, - ); - useEffect(() => { - setShowJumpHints(shouldShowJumpHintsNow); - }, [shouldShowJumpHintsNow]); - - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); - - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. - const handleNewThreadClick = useCallback(() => { - // One project: nothing to pick, create immediately. - if (projectGroups.length <= 1) { - if (isMobile) setOpenMobile(false); - void startNewThreadFromContext({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); - return; - } - if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); - - const pathname = useLocation({ select: (l) => l.pathname }); - const isBoardActive = pathname === "/board"; - const handleBoardClick = useCallback(() => { - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - }, [isMobile, router, setOpenMobile]); - - const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); - const boardShortcutLabel = shortcutLabelForCommand(keybindings, "board.open"); - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. chat.newLocal always creates directly, so - // it is only a correct label when chat.new is unbound. - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - shortcutLabelForCommand(keybindings, "chat.newLocal"); - return ( - <> - - -
    -
    - - { - setThreadSearchQuery(event.currentTarget.value); - setActiveSearchResultIndex(0); - }} - onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" - aria-label="Search threads" - role="combobox" - aria-autocomplete="list" - aria-expanded={isSearchingThreads && threadSearchResults.length > 0} - aria-controls={ - isSearchingThreads && threadSearchResults.length > 0 - ? "sidebar-thread-search-results" - : undefined - } - aria-activedescendant={ - isSearchingThreads && threadSearchResults[activeSearchResultIndex] - ? `sidebar-thread-search-result-${activeSearchResultIndex}` - : undefined - } - className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" - /> - {isSearchingThreads ? ( - - ) : null} -
    -
    - - - } - > - - - - {boardShortcutLabel ? `Board (${boardShortcutLabel})` : "Board"} - - -
    -
    - - - } - > - - - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    -
    - {projectGroups.length > 0 ? ( -
    - - - } - > - {threadGrouping === "project" ? : } - - - setThreadGrouping(value as WebThreadGrouping)} - > - {WEB_THREAD_GROUPINGS.filter((grouping) => grouping !== "none").map( - (grouping) => ( - - {grouping === "project" ? : } - {WEB_THREAD_GROUPING_LABELS[grouping]} - - ), - )} - - - - - - } - > - {scopedProjectGroup ? ( - - ) : ( - - )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - - - - - setProjectScopeKey(value === "all" ? null : (value as string)) - } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - - - {project.displayName} - - - ); - })} - - - - - - - } - /> - } - > - - {listOptionsActive ? ( - - ) : null} - - View & filters - - - -
    - Ownership -
    - { - if (value !== "any" && value !== "mine" && value !== "theirs") return; - setOwnershipFilter(value); - try { - window.localStorage.setItem( - SIDEBAR_OWNERSHIP_FILTER_STORAGE_KEY, - value, - ); - } catch { - // ignore - } - }} - > - {SIDEBAR_OWNERSHIP_FILTERS.map((value) => ( - - {SIDEBAR_OWNERSHIP_FILTER_LABELS[value]} - - ))} - -
    - {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( - <> - - -
    - {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} -
    - { - if (!isOwnershipRelation(value)) return; - setOwnershipRelation(value); - try { - window.localStorage.setItem( - SIDEBAR_OWNERSHIP_RELATION_STORAGE_KEY, - value, - ); - } catch { - // ignore - } - }} - > - {SIDEBAR_OWNERSHIP_RELATIONS.map((value) => ( - - {SIDEBAR_OWNERSHIP_RELATION_LABELS[value]} - - ))} - -
    - - ) : null} - - -
    - Settled shelf -
    - setSettledShelfExpanded(checked === true)} - > - Expand settled shelf - -
    - {environments.length > 1 ? ( - <> - - -
    - Environment -
    - setStoredEnvironmentFilter([])} - > - All environments - - {environments.map((environment) => ( - { - setStoredEnvironmentFilter([ - ...toggleEnvironmentId( - selectedEnvironmentIds, - environment.environmentId, - ), - ]); - }} - > - {environment.label} - - ))} -
    - - ) : null} -
    -
    - - - } - > - - - New project - -
    - ) : null} - - } - > - - {isSearchingThreads ? ( - threadSearchResults.length > 0 ? ( - - - - ) : ( -

    - No threads found -

    - ) - ) : null} - {!isSearchingThreads ? ( - -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - const appendRecencyRows = ( - items: ReactNode[], - rows: readonly EnvironmentThreadShell[], - section: "active" | "settled", - ) => { - const groups = groupSortedThreadsByRecency( - rows, - new Date(`${nowMinute}:00.000Z`), - ); - if (threadGrouping !== "recency" || !shouldShowRecencySectionHeaders(groups)) { - for (const thread of rows) items.push(renderThreadRow(thread, section)); - return; - } - for (const group of groups) { - items.push( -
    • - {group.label} -
    • , - ); - for (const thread of group.threads) { - items.push(renderThreadRow(thread, section)); - } - } - }; - // Pinned block: full cards above the inbox, closed by a - // thin divider (the pin glyphs carry the meaning, so no - // header text). Vanishes entirely at count 0. - // Rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} - - , - ]; - if (pinnedThreads.length > 0) { - items.push( -
    • , - ); - } - appendRecencyRows(items, activeThreads, "active"); - // Snoozed shelf: between the inbox and Settled — out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
    • - -
    • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } - } - // Settled shelf: upstream Sidebar V2 history below the active inbox. - if (settledThreads.length > 0) { - items.push( -
    • - -
    • , - ); - } - appendRecencyRows(items, renderedSettledThreads, "settled"); - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
    • - -
    • - ) : null} -
    -
    - ) : null} - {!isSearchingThreads && - pinnedThreads.length + - activeThreads.length + - snoozedThreads.length + - settledThreads.length === - 0 ? ( -
    - {projects.length === 0 ? ( - <> - No projects yet - - - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` - ) : ( - "No threads yet" - )} -
    - ) : null} -
    -
    - { - if (!open) setProjectActionsTarget(null); - }} - > - - - Project settings - - Manage project names, grouping rules, and environments. - -
    - {projectActionsTarget?.memberProjects.map((member) => ( -
    - - - {member.workspaceRoot} - - - - - - {member.environmentLabel ?? "Current environment"} - - -
    - ))} -
    -
    - -
    - {projectActionsTarget?.memberProjects.map((member) => ( -
    -
    - - -
    - {projectActionsTarget.memberProjects.length > 1 ? ( -
    - -
    - ) : null} -
    - ))} -
    - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( -
    -
    -

    - Remove this project everywhere -

    -

    - Deletes all grouped entries and their conversation history. -

    -
    - -
    - ) : null} -
    - - {projectActionsTarget?.memberProjects.length === 1 ? ( - - ) : null} - - -
    -
    - - - ); -} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 25b4abb3fbe0..c59f682c415e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -66,7 +66,11 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; -import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; +import { + resolveTerminalFontPreference, + resolveTerminalFontSizePreference, + TYPOGRAPHY_ADVANCED_STORAGE_KEY, +} from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -341,7 +345,13 @@ export function TerminalViewport({ terminal: settings.fontFamilyTerminal, }), ); - const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontSize = useClientSettings((settings) => + resolveTerminalFontSizePreference({ + advanced: advancedTypography, + code: settings.fontSizeCode, + terminal: settings.fontSizeTerminal, + }), + ); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, diff --git a/apps/web/src/components/board/BoardCard.tsx b/apps/web/src/components/board/BoardCard.tsx index 5d8522498e73..d7ec41c56706 100644 --- a/apps/web/src/components/board/BoardCard.tsx +++ b/apps/web/src/components/board/BoardCard.tsx @@ -13,7 +13,7 @@ import type { Project, SidebarThreadSummary } from "../../types"; import { useUiStateStore } from "../../uiStateStore"; import { hasUnseenCompletion, - resolveSidebarV2Status, + resolveSidebarThreadStatus, resolveSidebarV2TopStatus, } from "../Sidebar.logic"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -101,7 +101,7 @@ function BoardCardBody({ }); const prStatus = prStatusIndicator(pr, appliedGitStatus?.sourceControlProvider); const topStatus = resolveSidebarV2TopStatus({ - status: resolveSidebarV2Status(thread), + status: resolveSidebarThreadStatus(thread), isUnread: hasUnseenCompletion({ ...thread, lastVisitedAt }), }); const relativeTimeLabel = formatRelativeTimeLabel( diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index 968b8256d880..66c7c19049ff 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -1,39 +1,6 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - formatPendingPrimaryActionLabel, - shouldDisableCollapsedComposerSubmitAction, - shouldShowComposerInterruptAction, -} from "./ComposerPrimaryActions"; - -describe("shouldShowComposerInterruptAction", () => { - it("shows interrupt while running with an empty composer", () => { - expect( - shouldShowComposerInterruptAction({ - isRunning: true, - hasSendableContent: false, - promptHasText: false, - }), - ).toBe(true); - }); - - it("shows submit while running once the composer has sendable content", () => { - expect( - shouldShowComposerInterruptAction({ - isRunning: true, - hasSendableContent: true, - promptHasText: false, - }), - ).toBe(false); - expect( - shouldShowComposerInterruptAction({ - isRunning: true, - hasSendableContent: false, - promptHasText: true, - }), - ).toBe(false); - }); -}); +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; describe("shouldDisableCollapsedComposerSubmitAction", () => { it("keeps the collapsed mobile submit button disabled while a turn is running", () => { @@ -58,6 +25,67 @@ describe("shouldDisableCollapsedComposerSubmitAction", () => { ).toBe(false); }); }); +vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { + ComposerPrimaryActions, + formatPendingPrimaryActionLabel, + shouldDisableCollapsedComposerSubmitAction, +} from "./ComposerPrimaryActions"; + +function renderPendingActions(isRunning: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderStandaloneStop() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { @@ -148,3 +176,19 @@ describe("formatPendingPrimaryActionLabel", () => { ).toBe("Submit answers"); }); }); + +describe("ComposerPrimaryActions", () => { + it("offers Stop generation while a running turn is waiting for user input", () => { + expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); + }); + + it("does not offer Stop generation for a pending request without a running turn", () => { + expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); + }); + + it("matches the small pending action size without changing the standalone size", () => { + expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); + expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); + expect(renderStandaloneStop()).not.toContain("sm:size-7"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 375191091de7..cbd311a79605 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -31,12 +31,6 @@ interface ComposerPrimaryActionsProps { onImplementPlanInNewThread: () => void; } -export const shouldShowComposerInterruptAction = (input: { - isRunning: boolean; - hasSendableContent: boolean; - promptHasText: boolean; -}): boolean => input.isRunning && !input.hasSendableContent && !input.promptHasText; - export const shouldDisableCollapsedComposerSubmitAction = (input: { isRunning: boolean; isSendBusy: boolean; @@ -89,9 +83,27 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : undefined; const isSendDisabled = sendDisabledReason !== null; + const renderStopGenerationButton = (insidePendingAction: boolean) => ( + + ); + if (pendingAction) { return (
    + {isRunning ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( - ); + if (isRunning) { + return renderStopGenerationButton(false); } if (showPlanFollowUpPrompt) { diff --git a/apps/web/src/components/preview/BrowserMockup.tsx b/apps/web/src/components/preview/BrowserMockup.tsx index 3b1882bbda90..35cfbb421e7c 100644 --- a/apps/web/src/components/preview/BrowserMockup.tsx +++ b/apps/web/src/components/preview/BrowserMockup.tsx @@ -1,6 +1,6 @@ import { cn } from "~/lib/utils"; -/** Browser-window thumbnail glyph for the "Local" recommendation cards. */ +/** Browser-window thumbnail glyph for preview recommendation cards. */ export function BrowserMockup({ className }: { className?: string }) { return (
    ({ + servers: [] as Array<{ + host: string; + port: number; + url: string; + requestedUrl: string; + processName: string | null; + pid: number | null; + terminal: null; + source: "scanner"; + listening: boolean; + }>, +})); + +vi.mock("./useDiscoveredLocalServers", () => ({ + useDiscoveredLocalServers: () => mocks.servers, +})); + +import { PreviewEmptyState } from "./PreviewEmptyState"; + +const environmentId = EnvironmentId.make("env-1"); + +function server(port: number) { + return { + host: "localhost", + port, + url: `http://localhost:${port}`, + requestedUrl: `http://localhost:${port}`, + processName: "node", + pid: 1, + terminal: null, + source: "scanner" as const, + listening: true, + }; +} + +function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { + return renderToStaticMarkup( + undefined} + onOpenUrl={() => undefined} + />, + ); +} + +describe("PreviewEmptyState", () => { + it("renders a history entry in both groups when its host:port matches a live server", () => { + mocks.servers = [server(5173)]; + const html = render([ + { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, + { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, + ]); + expect(html).toContain("Recently used"); + expect(html).toContain("Local servers"); + expect(html).toContain("myapp.test/admin#users"); + expect(html).toContain("Admin"); + expect(html).toContain("Recent Local"); + expect(html).toContain("node"); + }); + + it("renders only the recents group when no servers are found", () => { + mocks.servers = []; + const html = render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); + expect(html).toContain("Recently used"); + expect(html).not.toContain("Local servers"); + }); + + it("keeps the original empty state when both groups are empty", () => { + mocks.servers = []; + const html = render([]); + expect(html).toContain("No preview yet"); + }); + + it("renders an out-of-range lastVisitedAt entry without throwing", () => { + mocks.servers = []; + let html = ""; + expect(() => { + html = render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); + }).not.toThrow(); + expect(html).toContain("myapp.test"); + expect(html).toContain("Remove"); + }); +}); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 12126c66408b..3b9aacf4dfd6 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,15 +1,19 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { Globe, RadioTower } from "lucide-react"; +import { Globe, History, RadioTower } from "lucide-react"; +import type { BrowserHistoryEntry } from "~/browserHistoryStore"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; +import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentlySeenUrls?: ReadonlyArray | undefined; + recentEntries: ReadonlyArray; + onRemoveRecent: (url: string) => void; onOpenUrl: (url: string) => void; } @@ -17,6 +21,8 @@ export function PreviewEmptyState({ environmentId, configuredUrls, recentlySeenUrls, + recentEntries, + onRemoveRecent, onOpenUrl, }: Props) { const servers = useDiscoveredLocalServers({ @@ -24,8 +30,9 @@ export function PreviewEmptyState({ configuredUrls, recentlySeenUrls, }); + const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8); - if (servers.length === 0) { + if (servers.length === 0 && recents.length === 0) { return ( @@ -42,23 +49,45 @@ export function PreviewEmptyState({ return (
    -
    -
    - -

    Local servers

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

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

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

    Recently used

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

    Local servers

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

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

    +
    + ) : null}
    ); diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx new file mode 100644 index 000000000000..892ff579d1d7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -0,0 +1,51 @@ +import { X } from "lucide-react"; + +import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { BrowserMockup } from "./BrowserMockup"; + +interface Props { + entry: BrowserHistoryEntry; + onOpen: () => void; + onRemove: () => void; +} + +export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { + const parsed = new URL(entry.url); + const path = parsed.pathname === "/" ? "" : parsed.pathname; + const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; + const visitedAt = isValidHistoryTimestamp(entry.lastVisitedAt) + ? formatRelativeTimeLabel(new Date(entry.lastVisitedAt).toISOString()) + : ""; + useNowMinute(); + return ( +
    + + +
    + ); +} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 4347a70d2b8d..0d38da956909 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -30,6 +30,17 @@ const mocks = vi.hoisted(() => ({ toggleAnnotation: null as (() => void) | null, pictureInPicture: false, showEmptyState: false, + recordVisitForThread: vi.fn(), +})); + +const EMPTY_HISTORY: never[] = []; + +vi.mock("~/browserHistoryStore", () => ({ + recordVisitForThread: mocks.recordVisitForThread, + setTitleForThreadUrl: vi.fn(), + removeUrlForThread: vi.fn(), + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + useThreadRecentHistory: () => EMPTY_HISTORY, })); vi.mock("~/state/session", () => ({ @@ -243,6 +254,7 @@ describe("PreviewView navigation", () => { mocks.toggleAnnotation = null; mocks.pictureInPicture = false; mocks.showEmptyState = false; + mocks.recordVisitForThread.mockClear(); }); // A typed localhost URL means "the dev server on the environment host", so it @@ -312,6 +324,64 @@ describe("PreviewView navigation", () => { ); }); + it("records a history visit with the normalized requested url on submit", async () => { + renderToStaticMarkup( + , + ); + + mocks.submittedUrl?.("localhost:3000/admin"); + await vi.waitFor(() => { + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:3000/admin", + ); + }); + }); + + it("maps an empty-state localhost server onto the WSL host", async () => { + mocks.showEmptyState = true; + renderToStaticMarkup( + , + ); + + expect(mocks.emptyStateUrl).not.toBeNull(); + mocks.emptyStateUrl?.("http://localhost:5173/app?mode=test#top"); + + await vi.waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith( + TEST_RUNTIME_TAB_ID, + "http://172.25.85.75:5173/app?mode=test#top", + ), + ); + expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( + { + environmentId: "environment-1", + threadId: "thread-1", + }, + "http://172.25.85.75:5173/app?mode=test#top", + ); + await vi.waitFor(() => + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:5173/app?mode=test#top", + ), + ); + }); + it("opens and closes a thread-scoped floating preview for the active tab", async () => { const props = { threadRef: { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 8503cd376f14..398fd5f18cc7 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -11,6 +11,13 @@ import { import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + recordVisitForThread, + removeUrlForThread, + setTitleForThreadUrl, + useThreadRecentHistory, +} from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; @@ -84,12 +91,24 @@ export function PreviewView({ const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); + // Kept in sync so the title effect can depend on the stable thread key + // instead of the thread object, which is recreated on every update. + const threadRefRef = useRef(threadRef); + threadRefRef.current = threadRef; const previewState = useThreadPreviewState(threadRef); + const recentHistoryEntries = useThreadRecentHistory( + threadRef, + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + ); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); + const environmentHostname = environmentHttpBaseUrl + ? new URL(environmentHttpBaseUrl).hostname + : null; const open = useAtomCommand(previewEnvironment.open); const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); @@ -129,20 +148,27 @@ export function PreviewView({ runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); + const navUrl = navStatus._tag === "Success" ? navStatus.url : null; + const navTitle = navStatus._tag === "Success" ? navStatus.title : null; + const latestHistoryUrl = recentHistoryEntries[0]?.url; + const threadKey = scopedThreadKey(threadRef); + useEffect(() => { + if (!navUrl || !navTitle || !latestHistoryUrl) return; + // Agent-driven pages only enrich an existing requested URL. + setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname); + // threadKey stands in for threadRef, whose identity churns on every thread update. + }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]); + const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { if (runtimeTabId && previewBridge) { - // Drive the webview imperatively; `usePreviewBridge` mirrors the - // resolved URL back to the server so other clients stay in sync. + // The bridge mirrors the resolved URL back to the server. await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); - } else { - await openPreviewSession({ - openPreview: open, - threadRef, - url: resolvedUrl, - }); + return true; } + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + return result._tag === "Success"; }, [open, runtimeTabId, threadRef], ); @@ -150,30 +176,36 @@ export function PreviewView({ const handleSubmitUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl( - await resolveNavigableUrl(threadRef.environmentId, { - kind: "url", - url: normalizePreviewUrl(next), - }), - ); + const normalized = normalizePreviewUrl(next); + if ( + await navigateToResolvedUrl( + await resolveNavigableUrl(threadRef.environmentId, { kind: "url", url: normalized }), + ) + ) { + recordVisitForThread(threadRef, normalized); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl, threadRef.environmentId], + [navigateToResolvedUrl, threadRef], ); const handleOpenServerUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl( - await resolveNavigableUrl(threadRef.environmentId, { kind: "url", url: next }), - ); + if ( + await navigateToResolvedUrl( + await resolveNavigableUrl(threadRef.environmentId, { kind: "url", url: next }), + ) + ) { + recordVisitForThread(threadRef, next); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl, threadRef.environmentId], + [navigateToResolvedUrl, threadRef], ); const handleRefresh = useCallback(() => { @@ -688,6 +720,8 @@ export function PreviewView({ environmentId={threadRef.environmentId} configuredUrls={configuredUrls} recentlySeenUrls={previewState.recentlySeenUrls} + recentEntries={recentHistoryEntries} + onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} onOpenUrl={(next) => void handleOpenServerUrl(next)} /> ) : null} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 22623a07c711..94b2e217dd72 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -6,6 +6,7 @@ import { import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -24,6 +25,7 @@ export async function openDiscoveredPort(input: { url: resolvedUrl, }); return mapAtomCommandResult(result, (snapshot) => { + recordVisitForThread(input.threadRef, input.port.url); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 312eab9eb357..f4e0373a73c3 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview( input.fallbackToBrowser(); return; } + recordVisitForThread(input.threadRef, input.url); applyPreviewServerSnapshot(input.threadRef, result.value); useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); return; diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index bb3b7cd6fa83..cdc927140257 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; -const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ +const scannerServer = ( + overrides: Partial, +): DiscoveredLocalServer & { requestedUrl: string } => ({ host: "localhost", port: 5173, url: "http://localhost:5173", + requestedUrl: overrides.url ?? "http://localhost:5173", processName: "vite", pid: 1234, terminal: null, @@ -24,6 +27,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ host: "localhost", port: 5173, + requestedUrl: "http://localhost:5173", source: "scanner", listening: true, processName: "vite", @@ -56,6 +60,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ source: "configured", listening: false, + requestedUrl: "http://localhost:5173/", }); }); @@ -68,6 +73,7 @@ describe("mergeServers", () => { expect(result.map((s) => s.port)).toEqual([5173, 8080]); expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/"); }); it("ignores non-loopback URLs in configured/recent inputs", () => { @@ -102,6 +108,22 @@ describe("mergeServers", () => { }); expect(result).toHaveLength(1); }); + + it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { + const result = mergeServers({ + scanner: [ + scannerServer({ + port: 5173, + url: "https://env-42.example.dev:5173/", + requestedUrl: "http://localhost:5173/", + }), + ], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); + }); }); describe("PreviewableServer interface", () => { diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 118a56b90682..77491a93c10c 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { * `configured` entry can also be `listening` when the scan enriched it. */ listening: boolean; + /** + * Pre-resolution loopback url. `url` is the resolved navigation target + * (volatile on a remote environment); history must key off this instead. + */ + requestedUrl: string; } interface UseDiscoveredLocalServersInput { @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( scanner: scannerSnapshot.map((server) => ({ ...server, url: resolveDiscoveredServerUrl(input.environmentId, server.url), + requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], recentlySeenUrls: input.recentlySeenUrls ?? [], @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( } export function mergeServers(input: { - scanner: ReadonlyArray; + scanner: ReadonlyArray; configuredUrls: ReadonlyArray; recentlySeenUrls: ReadonlyArray; }): ReadonlyArray { @@ -60,6 +66,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, @@ -95,6 +102,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx deleted file mode 100644 index 4b96fb15398d..000000000000 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useEffect, useState } from "react"; - -import { - useClientSettings, - useSidebarV2Enabled, - useUpdateClientSettings, -} from "../../hooks/useSettings"; -import { Input } from "../ui/input"; -import { Switch } from "../ui/switch"; -import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; - -const AUTO_SETTLE_MIN_DAYS = 1; -const AUTO_SETTLE_MAX_DAYS = 90; -const AUTO_SETTLE_DEFAULT_DAYS = 3; - -function AutoSettleDaysInput({ - value, - onCommit, -}: { - value: number; - onCommit: (days: number) => void; -}) { - // Local draft so the field can be emptied mid-edit; the setting only moves - // on valid input and snaps back to the persisted value on blur. - const [draft, setDraft] = useState(String(value)); - useEffect(() => { - setDraft(String(value)); - }, [value]); - - return ( - { - setDraft(event.target.value); - // Number(), not parseInt: "3.5" must be rejected (not truncated to a - // committed 3 while the field shows 3.5) — commit only when the - // persisted value matches the displayed one. - const parsed = Number(event.target.value); - if ( - Number.isInteger(parsed) && - parsed >= AUTO_SETTLE_MIN_DAYS && - parsed <= AUTO_SETTLE_MAX_DAYS - ) { - onCommit(parsed); - } - }} - onBlur={() => setDraft(String(value))} - aria-label="Days of inactivity before auto-settle" - /> - ); -} - -export function BetaSettingsPanel() { - const sidebarV2Enabled = useSidebarV2Enabled(); - const sidebarAutoSettleAfterDays = useClientSettings( - (settings) => settings.sidebarAutoSettleAfterDays, - ); - const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); - const updateSettings = useUpdateClientSettings(); - - return ( - - - - updateSettings({ - sidebarV2Enabled: Boolean(checked), - sidebarV2ConfiguredByUser: true, - }) - } - aria-label="Enable the sidebar v2 beta" - /> - } - /> - {sidebarV2Enabled ? ( - <> - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> - } - /> - {sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} - /> - } - /> - ) : null} - - ) : null} - updateSettings({ planModeEnabled: Boolean(checked) })} - aria-label="Restore plan mode (legacy)" - /> - } - /> - - - ); -} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6f6d16fad104..7c4794937901 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,11 +24,13 @@ import { MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -79,6 +81,7 @@ import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Dialog, DialogDescription, @@ -97,6 +100,7 @@ import { isMonospaceFamily, resolveDefaultFamilyLabel, resolveTerminalFontPreference, + resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; @@ -464,6 +468,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarAutoSettleAfterDays !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays + ? ["Auto-settle inactive threads"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans ? ["Interface font"] @@ -478,8 +486,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming - ? ["Assistant output"] + ...(settings.enableLegacyTokenStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming + ? ["Stream token by token"] : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks @@ -531,8 +540,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizePrompt, settings.fontSizeTerminal, settings.glassOpacity, - settings.enableAssistantStreaming, + settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, + settings.sidebarAutoSettleAfterDays, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -611,7 +621,8 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, @@ -1274,7 +1285,11 @@ function SimpleFontRows() { code: settings.fontFamilyCode, terminal: settings.fontFamilyTerminal, })} - size={settings.fontSizeTerminal} + size={resolveTerminalFontSizePreference({ + advanced: false, + code: settings.fontSizeCode, + terminal: settings.fontSizeTerminal, + })} /> } @@ -1516,6 +1531,153 @@ function FontFamilySettingsRow({ ); } +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; + +function AutoSettleDaysInput({ + value, + onCommit, +}: { + value: number; + onCommit: (days: number) => void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + // Number(), not parseInt: "3.5" must be rejected (not truncated to a + // committed 3 while the field shows 3.5) — commit only when the + // persisted value matches the displayed one. + const parsed = Number(event.target.value); + if ( + Number.isInteger(parsed) && + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +// The legacy rows sit behind the fold, so a settings-search jump has to +// expand the section before its target can mount and scroll. +const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ + "legacy-plan-mode", + "legacy-token-streaming", + "legacy-sidebar", +]); + +/** + * Retired features kept only for users who still depend on them. Collapsed by + * default so they stay out of the everyday settings path; a settings-search + * jump to one of the rows unfolds the section. + */ +function LegacyFeaturesSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const [open, setOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); + // Unfold once per search jump; tracking the handled id lets the user fold + // the section back up without the still-set target immediately reopening it. + const lastExpandedTargetRef = useRef(null); + useEffect(() => { + if (searchTargetId === null) { + // A handled jump clears the target; forgetting it here lets a later + // jump to the same row expand the section again. + lastExpandedTargetRef.current = null; + return; + } + if (!LEGACY_FEATURE_TARGET_IDS.has(searchTargetId)) return; + if (lastExpandedTargetRef.current === searchTargetId) return; + lastExpandedTargetRef.current = searchTargetId; + setOpen(true); + }, [searchTargetId]); + + return ( +
    + + +

    + Legacy features +

    + +
    + +
    + + updateSettings({ planModeEnabled: Boolean(checked) }) + } + aria-label="Plan mode (legacy)" + /> + } + /> + { + if (!checked) { + updateSettings({ enableLegacyTokenStreaming: false }); + return; + } + void (async () => { + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( + [ + "Turn on token-by-token output?", + "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", + ].join("\n"), + ); + if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); + })(); + }} + aria-label="Stream token by token (legacy)" + /> + } + /> + + updateSettings({ legacySidebarEnabled: Boolean(checked) }) + } + aria-label="Sidebar (legacy)" + /> + } + /> +
    +
    +
    +
    + ); +} + export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -1608,6 +1770,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + - - updateSettings({ - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ enableAssistantStreaming: Boolean(checked) }) - } - aria-label="Stream assistant messages" - /> - } - /> - + + ); } diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index b7ca9afcf83a..7efe61015966 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -11,7 +11,6 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, - FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -52,7 +51,6 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/providers": BotIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, - "/settings/beta": FlaskConicalIcon, "/settings/archived": ArchiveIcon, }; diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0074ac89304e..f015fce03d0d 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -165,6 +165,7 @@ export function ThemeEditorPanel({ const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [sidebarArtwork, setSidebarArtwork] = useState(false); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState(() => getThemeEditorColorsByAppearance(), @@ -178,6 +179,7 @@ export function ThemeEditorPanel({ const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState(null); const [usageCount, setUsageCount] = useState(null); + const previousMergeTargetIdRef = useRef(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -259,6 +261,9 @@ export function ThemeEditorPanel({ setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); + // Artwork is opt-in for new themes, including duplicates. Editing keeps + // the theme's existing choice. + setSidebarArtwork(editingTheme?.sidebarArtwork === true); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A @@ -315,6 +320,18 @@ export function ThemeEditorPanel({ // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (previousMergeTargetIdRef.current === mergeTargetId) return; + previousMergeTargetIdRef.current = mergeTargetId; + // A matching name makes that existing theme the surviving merge target. + // Seed theme-level options from it so adding a palette or renaming onto it + // does not silently reset them. Leaving the merge restores the edited + // theme's option (or the off-by-default choice for a new theme). + setSidebarArtwork( + mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true, + ); + }, [editingTheme, mergeTarget, mergeTargetId]); + useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; @@ -330,8 +347,8 @@ export function ThemeEditorPanel({ // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; - applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); - }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]); useEffect(() => { if (!open) return; @@ -657,6 +674,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -687,6 +705,7 @@ export function ThemeEditorPanel({ ...(getThemeModes(editingTheme).length > 1 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } : {}), + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -713,6 +732,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, [activeAppearance]: colorsForSave[activeAppearance], }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -723,6 +743,7 @@ export function ThemeEditorPanel({ name, appearance: activeAppearance, colors: colorsForSave[activeAppearance], + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -773,6 +794,7 @@ export function ThemeEditorPanel({ name, onOpenChange, onSaved, + sidebarArtwork, simpleColorsDirtyByAppearance, takenAppearances, ]); @@ -832,6 +854,20 @@ export function ThemeEditorPanel({
    ); + const renderSidebarArtworkToggle = () => ( + + ); + const renderColorsHeader = () => (
    @@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({

    ) : null} {renderAppearanceButtons()} + {renderSidebarArtworkToggle()}
    {renderColorsHeader()} {renderColorFields()} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 29568e55d430..9ff094a132c0 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -5,7 +5,6 @@ export type SettingsPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" - | "/settings/beta" | "/settings/archived"; export interface SettingsSearchItem { @@ -26,7 +25,6 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/providers": "Providers", "/settings/source-control": "Source Control", "/settings/connections": "Connections", - "/settings/beta": "Beta", "/settings/archived": "Archive", }; @@ -100,6 +98,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Project grouping", to: "/settings/general", }, + { + id: "auto-settle-inactive-threads", + title: "Auto-settle inactive threads", + to: "/settings/general", + }, { id: "time-format", title: "Time format", @@ -110,11 +113,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Hide whitespace changes", to: "/settings/general", }, - { - id: "assistant-output", - title: "Assistant output", - to: "/settings/general", - }, { id: "provider-update-checks", title: "Provider update checks", @@ -161,6 +159,21 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Diagnostics", to: "/settings/general", }, + { + id: "legacy-plan-mode", + title: "Plan mode (legacy)", + to: "/settings/general", + }, + { + id: "legacy-token-streaming", + title: "Stream token by token (legacy)", + to: "/settings/general", + }, + { + id: "legacy-sidebar", + title: "Sidebar (legacy)", + to: "/settings/general", + }, { id: "keybindings", title: "Keybindings", @@ -181,22 +194,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remote environments", to: "/settings/connections", }, - { - id: "sidebar-v2", - title: "Sidebar v2", - to: "/settings/beta", - }, - { - id: "auto-settle-inactive-threads", - title: "Auto-settle inactive threads", - to: "/settings/beta", - targetId: "sidebar-v2", - }, - { - id: "restore-plan-mode", - title: "Restore plan mode (legacy)", - to: "/settings/beta", - }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca09128..9029f1204d36 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -279,6 +279,11 @@ describe("environment grouping", () => { expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( repositoryIdentity.canonicalKey, ); + // Deriving from the stale project alone misses the identity its sibling + // carries, so consumers must go through the map to match the sidebar. + expect( + deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings), + ).not.toBe(repositoryIdentity.canonicalKey); }); it("builds one picker entry per logical project and targets the preferred environment", () => { diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts index 232484c4b908..7f1997d41d23 100644 --- a/apps/web/src/forkSurfaceExistence.test.ts +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -19,7 +19,7 @@ function readSrc(relativePath: string): string { describe("fork surface existence (anti stack-drop)", () => { it("classic sidebar keeps the collapsible Settled shelf chrome", () => { - const sidebar = readSrc("components/Sidebar.tsx"); + const sidebar = readSrc("components/LegacySidebar.tsx"); expect(sidebar).toContain('data-testid="sidebar-v1-settled-shelf-toggle"'); expect(sidebar).toContain("Hide settled"); expect(sidebar).toContain('data-testid="sidebar-v1-settled-recency-headers"'); @@ -36,23 +36,23 @@ describe("fork surface existence (anti stack-drop)", () => { }); it("Sidebar V2 keeps Settled shelf labeling and new-thread affordance", () => { - const sidebarV2 = readSrc("components/SidebarV2.tsx"); + const sidebarV2 = readSrc("components/Sidebar.tsx"); expect(sidebarV2).toContain("Settled shelf"); expect(sidebarV2).toMatch(/New thread|new thread/i); - expect(sidebarV2).toContain("sidebar-v2-pinned-divider"); - expect(sidebarV2).toContain("sidebar-v2-snoozed-shelf-toggle"); - expect(sidebarV2).toContain("sidebar-v2-settled-shelf-toggle"); + expect(sidebarV2).toContain("sidebar-pinned-divider"); + expect(sidebarV2).toContain("sidebar-snoozed-shelf-toggle"); + expect(sidebarV2).toContain("sidebar-settled-shelf-toggle"); expect(sidebarV2).toContain("attemptPin"); expect(sidebarV2).toContain("attemptUnpin"); }); it("Sidebar V2 View & filters keeps multi-env environment filter (shared storage)", () => { - const sidebarV2 = readSrc("components/SidebarV2.tsx"); + const sidebarV2 = readSrc("components/Sidebar.tsx"); // Restacked ownership work once dropped this; without it multi-env users // cannot hide t3vm / secondary machines from the V2 inbox. - expect(sidebarV2).toContain('data-testid="sidebar-v2-view-options-trigger"'); - expect(sidebarV2).toContain('data-testid="sidebar-v2-environment-filter-all"'); - expect(sidebarV2).toContain("sidebar-v2-environment-filter-${environment.environmentId}"); + expect(sidebarV2).toContain('data-testid="sidebar-view-options-trigger"'); + expect(sidebarV2).toContain('data-testid="sidebar-environment-filter-all"'); + expect(sidebarV2).toContain("sidebar-environment-filter-${environment.environmentId}"); expect(sidebarV2).toContain("LIST_ENVIRONMENT_FILTER_STORAGE_KEY"); expect(sidebarV2).toContain( "matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds)", @@ -61,7 +61,7 @@ describe("fork surface existence (anti stack-drop)", () => { }); it("classic sidebar marks composer draft threads", () => { - const sidebar = readSrc("components/Sidebar.tsx"); + const sidebar = readSrc("components/LegacySidebar.tsx"); expect(sidebar).toContain("ComposerDraftDot"); expect(sidebar).toContain("hasComposerDraftMessage"); }); @@ -123,7 +123,7 @@ describe("fork surface existence (anti stack-drop)", () => { }); it("classic sidebar thread rows keep provider usage dots + stats", () => { - const sidebar = readSrc("components/Sidebar.tsx"); + const sidebar = readSrc("components/LegacySidebar.tsx"); expect(sidebar).toContain("useAiUsageSnapshot"); expect(sidebar).toContain("resolveDriverUsage"); expect(sidebar).toContain("usageDotFillClass"); @@ -132,7 +132,7 @@ describe("fork surface existence (anti stack-drop)", () => { }); it("Sidebar V2 thread rows keep provider usage dots + stats", () => { - const sidebarV2 = readSrc("components/SidebarV2.tsx"); + const sidebarV2 = readSrc("components/Sidebar.tsx"); expect(sidebarV2).toContain("useAiUsageSnapshot"); expect(sidebarV2).toContain("resolveDriverUsage"); expect(sidebarV2).toContain("usageDotFillClass"); @@ -142,18 +142,18 @@ describe("fork surface existence (anti stack-drop)", () => { }); it("Sidebar V2 grouping changes ordering and headers without changing its row surface", () => { - const sidebarV2 = readSrc("components/SidebarV2.tsx"); + const sidebarV2 = readSrc("components/Sidebar.tsx"); const webGrouping = readSrc("components/listEnvironmentFilter.ts"); const mobileGrouping = readSrc("../../mobile/src/features/home/homeListMode.ts"); const orderingContract = readSrc("../../../docs/sidebar-v2.md"); - expect(sidebarV2).toContain("sidebar-v2-thread-grouping-${grouping}"); - expect(sidebarV2).toContain('data-testid="sidebar-v2-thread-grouping"'); + expect(sidebarV2).toContain("sidebar-thread-grouping-${grouping}"); + expect(sidebarV2).toContain('data-testid="sidebar-thread-grouping"'); expect(sidebarV2).toMatch(/size="icon"\s+type="button"\s+aria-label={`Thread ordering:/); expect(sidebarV2).toContain('aria-label="Filter threads by project"'); expect(sidebarV2).toContain('grouping !== "none"'); expect(sidebarV2).toContain('threadGrouping !== "recency"'); - expect(sidebarV2).toContain("orderForThreadGrouping(sortThreadsForSidebarV2(active))"); - expect(sidebarV2).toContain("sidebar-v2-${section}-recency-${group.id}"); + expect(sidebarV2).toContain("orderForThreadGrouping(sortThreadsForSidebar(active))"); + expect(sidebarV2).toContain("sidebar-${section}-recency-${group.id}"); expect(sidebarV2).toContain('const rowVariant = isCard ? "card" : "slim"'); expect(webGrouping).toContain('project: "Group by default"'); expect(mobileGrouping).toContain('project: "Group by default"'); @@ -233,8 +233,8 @@ describe("fork surface existence (anti stack-drop)", () => { expect(stack).toContain( " { const chat = readSrc("components/ChatView.tsx"); expect(chat).toContain("requestIdentityClaimGate"); expect(sidebarV2).toContain("ThreadIdentityMark"); - expect(sidebarV2).toContain("sidebar-v2-ownership-filter-"); + expect(sidebarV2).toContain("sidebar-ownership-filter-"); expect(sidebarV1).toContain("ThreadIdentityMark"); expect(sidebarV1).not.toContain("ThreadIdentityLeading"); expect(sidebarV2).not.toContain("ThreadIdentityLeading"); }); it("sidebar v2 uses budgeted list VCS status so PR markers and auto-settle stay fresh", () => { - const sidebar = readSrc("components/SidebarV2.tsx"); + const sidebar = readSrc("components/Sidebar.tsx"); expect(sidebar).toContain("vcsEnvironment.listStatus({"); expect(sidebar).not.toContain("vcsEnvironment.status({"); }); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 741579661e73..b332fe13c2f1 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => { "pill", ); }); + + it("uses a pill instead of artwork with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("pill"); + }); + + it("respects none with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "none", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("none"); + }); + + it("keeps artwork when the palette theme opts into it", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + paletteThemeAllowsArtwork: true, + }), + ).toBe("artwork"); + }); }); describe("mergeEnvironmentSettings", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f70..bf273879dc43 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -25,13 +25,18 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import { APP_STAGE_LABEL } from "~/branding"; -import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; +import { + getThemeDefinition, + getThemePreviewSidebarArtwork, + resolveThemeHalf, + subscribeToThemePreview, +} from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -226,41 +231,51 @@ export function useClientSettings( export function resolveEnvironmentIdentificationMode(input: { mode: EnvironmentIdentificationMode; settingsHydrated: boolean; + paletteThemeActive?: boolean; + paletteThemeAllowsArtwork?: boolean; }): EnvironmentIdentificationMode { // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. - return input.settingsHydrated ? input.mode : "none"; + if (!input.settingsHydrated) return "none"; + // Stage artwork has fixed colors that can clash with palette themes. Keep an + // explicit "none", but use the theme-aware pill in place of artwork. + return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" + ? "pill" + : input.mode; } export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { const settingsHydrated = useClientSettingsHydrated(); const mode = useClientSettingsValue().environmentIdentificationMode; - return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); + const { resolvedTheme, theme, themeHalves } = useTheme(); + const previewSidebarArtwork = useSyncExternalStore( + subscribeToThemePreview, + getThemePreviewSidebarArtwork, + () => null, + ); + const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme); + const activeThemeDefinition = getThemeDefinition(activeTheme); + return resolveEnvironmentIdentificationMode({ + mode, + settingsHydrated, + paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, + paletteThemeAllowsArtwork: + previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, + }); } /** - * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user - * has made one, otherwise the default for this build stage (on for nightly and - * dev, off for production). Every consumer must read through this rather than - * `settings.sidebarV2Enabled`, which is only meaningful alongside - * `sidebarV2ConfiguredByUser`. + * Whether the legacy sidebar (Settings → General → Legacy features) replaces + * the default one. * - * Held at v1 until client settings hydrate. The pre-hydration snapshot is just - * the schema defaults, so resolving against it would mount one sidebar and then - * swap it out once persisted settings land — remounting the whole tree. + * Held at the default sidebar until client settings hydrate: the pre-hydration + * snapshot is just the schema defaults, so resolving against it could mount one + * sidebar and then swap it out once persisted settings land — remounting the + * whole tree for everyone instead of only for legacy opt-ins. */ -export function useSidebarV2Enabled(): boolean { +export function useLegacySidebarEnabled(): boolean { const settingsHydrated = useClientSettingsHydrated(); - const settings = useClientSettingsValue(); - return useMemo( - () => - resolveSidebarV2Enabled({ - enabled: settings.sidebarV2Enabled, - configuredByUser: settings.sidebarV2ConfiguredByUser, - settingsHydrated, - stageLabel: APP_STAGE_LABEL, - }), - [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated], - ); + const legacySidebarEnabled = useClientSettingsValue().legacySidebarEnabled; + return settingsHydrated && legacySidebarEnabled; } /** Read current settings for one environment, merged with client-local preferences. */ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6dfef37c7dfb..2ae0e37d296e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -995,12 +995,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -/* Keep both navigation implementations on the same quiet zinc hierarchy: - zinc-50 navigation, zinc-25 hover, and white selected/raised surfaces. - The version attribute remains useful for layout-specific styling without - changing the color system when the beta is toggled. */ -[data-sidebar-version="v1"], -[data-sidebar-version="v2"] { +/* Keep both sidebar implementations (default and legacy) on the same quiet + zinc hierarchy: zinc-50 navigation, zinc-25 hover, and white selected/raised + surfaces. */ +[data-app-sidebar] { --background: var(--color-zinc-25); --foreground: var(--color-zinc-800); --card: var(--color-white); @@ -1023,8 +1021,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background-color: var(--sidebar); } -.dark [data-sidebar-version="v1"], -.dark [data-sidebar-version="v2"] { +.dark [data-app-sidebar] { --background: #000; --foreground: #f1f3f7; --card: #000; @@ -1321,8 +1318,7 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action[aria-pressed="tr color: var(--code-foreground); } -html[data-theme-id] [data-sidebar-version="v1"], -html[data-theme-id] [data-sidebar-version="v2"] { +html[data-theme-id] [data-app-sidebar] { --background: var(--app-theme-canvas); --foreground: var(--app-theme-text); --card: var(--app-theme-surface); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 67642bfa03dd..bbce087dc706 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,7 +20,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' -import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' @@ -83,11 +82,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) -const SettingsBetaRoute = SettingsBetaRouteImport.update({ - id: '/beta', - path: '/beta', - getParentRoute: () => SettingsRoute, -} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -135,7 +129,6 @@ export interface FileRoutesByFullPath { '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -154,7 +147,6 @@ export interface FileRoutesByTo { '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -176,7 +168,6 @@ export interface FileRoutesById { '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -199,7 +190,6 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -218,7 +208,6 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -239,7 +228,6 @@ export interface FileRouteTypes { | '/connect_/callback' | '/settings/appearance' | '/settings/archived' - | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -338,13 +326,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } - '/settings/beta': { - id: '/settings/beta' - path: '/beta' - fullPath: '/settings/beta' - preLoaderRoute: typeof SettingsBetaRouteImport - parentRoute: typeof SettingsRoute - } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -418,7 +399,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsArchivedRoute: typeof SettingsArchivedRoute - SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -430,7 +410,6 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsArchivedRoute: SettingsArchivedRoute, - SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 75c517dc33f6..e084e22c2cbb 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const sidebarV2Enabled = useSidebarV2Enabled(); + const legacySidebarEnabled = useLegacySidebarEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -92,10 +92,10 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - // Sidebar v2 routes creation through the command palette whenever - // there is a real choice to make; v1 (and single-project setups) - // keep the immediate contextual create. - if (sidebarV2Enabled && projectGroupCount > 1) { + // The default sidebar routes creation through the command palette + // whenever there is a real choice to make; the legacy sidebar (and + // single-project setups) keep the immediate contextual create. + if (!legacySidebarEnabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -167,7 +167,7 @@ function ChatRouteGlobalShortcuts() { projectGroupCount, routeThreadRef, selectedThreadKeysSize, - sidebarV2Enabled, + legacySidebarEnabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx deleted file mode 100644 index a1e78f2dff73..000000000000 --- a/apps/web/src/routes/settings.beta.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; - -function SettingsBetaRoute() { - return ; -} - -export const Route = createFileRoute("/settings/beta")({ - component: SettingsBetaRoute, -}); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 2ed4ff3891dc..671b5dbb76d2 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + applyThemeColorPreview, + applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, + getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, @@ -15,6 +18,7 @@ import { resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, + subscribeToThemePreview, subscribeToCustomThemes, T3_CHAT_THEME, EMBER_THEME, @@ -200,6 +204,49 @@ describe("theme files", () => { }); }); + it("keeps sidebar artwork opt-in through theme files", () => { + const withoutArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Plain sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + }); + const withArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Art sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + sidebarArtwork: true, + }); + + expect(withoutArtwork.sidebarArtwork).toBeUndefined(); + expect(withArtwork.sidebarArtwork).toBe(true); + expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true); + }); + + it("publishes sidebar artwork changes from the live theme preview", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToThemePreview(listener); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + style: { removeProperty: vi.fn(), setProperty: vi.fn() }, + }, + }); + + applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true); + expect(getThemePreviewSidebarArtwork()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + + applyThemePalette("system"); + expect(getThemePreviewSidebarArtwork()).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + vi.unstubAllGlobals(); + }); + it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, @@ -387,6 +434,7 @@ describe("theme files", () => { name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", accent: "#5b6cff" }, + sidebarArtwork: true, }), ); const updatedTheme = updateCustomTheme({ @@ -395,11 +443,17 @@ describe("theme files", () => { colors: { ...createdTheme.colors, accent: "#7c3aed" }, }); - expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" }); + expect(updatedTheme).toMatchObject({ + id: "aurora", + label: "Aurora Night", + sidebarArtwork: true, + }); + invalidateCustomThemes(); expect(getCustomThemes()).toEqual([updatedTheme]); expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ id: "aurora", label: "Aurora Night", + sidebarArtwork: true, }); vi.unstubAllGlobals(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index dafc5dbf457b..2f6fb0434545 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; + /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */ + sidebarArtwork?: boolean; /** True when the palette was generated by the guided editor from its * canvas and accent; such themes reopen in guided mode. */ managed?: boolean; @@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{ appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; + sidebarArtwork?: boolean; managed?: boolean; }>; @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ const customThemeListeners = new Set<() => void>(); let customThemesSnapshot: ReadonlyArray | null = null; +const themePreviewListeners = new Set<() => void>(); +let themePreviewSidebarArtwork: boolean | null = null; + +export function getThemePreviewSidebarArtwork(): boolean | null { + return themePreviewSidebarArtwork; +} + +export function subscribeToThemePreview(listener: () => void): () => void { + themePreviewListeners.add(listener); + return () => themePreviewListeners.delete(listener); +} + +function setThemePreviewSidebarArtwork(next: boolean | null): void { + if (themePreviewSidebarArtwork === next) return; + themePreviewSidebarArtwork = next; + for (const listener of themePreviewListeners) listener(); +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { appearance: value.appearance, colors, ...(variants ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { appearance, colors: { ...fallback, ...overrides }, ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), + ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), ...(theme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; @@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview"; * can be judged against the real interface instead of a miniature. Callers * restore the stored theme (refreshTheme) when the draft goes away. */ -export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { +export function applyThemeColorPreview( + colors: ThemeColors, + appearance: ThemeAppearance, + sidebarArtwork = false, +): void { if (typeof document === "undefined") return; const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(sidebarArtwork); root.dataset.themeId = THEME_PREVIEW_ID; root.classList.toggle("dark", appearance === "dark"); for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { @@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(null); const palette = getThemeDefinition(theme); if (palette) { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a8fee32100e5..d0605241ab8f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -79,37 +79,47 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings environment identification", () => { + it("defaults to artwork and accepts each presentation mode", () => { + expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); + + for (const mode of ["artwork", "pill", "none"] as const) { + expect( + decodeClientSettingsPatch({ environmentIdentificationMode: mode }) + .environmentIdentificationMode, + ).toBe(mode); + } + }); + + it("rejects unsupported presentation modes", () => { + expect(() => decodeClientSettings({ environmentIdentificationMode: "badge" })).toThrow(); + expect(() => decodeClientSettingsPatch({ environmentIdentificationMode: "badge" })).toThrow(); + }); +}); + describe("ClientSettings sidebar", () => { - it("defaults recent work on and the v2 beta off with a three-day auto-settle threshold", () => { + it("defaults to the current sidebar with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); + expect(settings.legacySidebarEnabled).toBe(false); expect(settings.sidebarRecentThreadsEnabled).toBe(true); - expect(settings.sidebarV2Enabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); - it("treats settings written before the beta had a per-channel default as unconfigured", () => { - // The stored blob always carries `sidebarV2Enabled`, so only the companion - // flag can distinguish "user opted out" from "never touched it". - expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false); - expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false); - }); - - it("preserves an explicit beta choice", () => { - const settings = decodeClientSettings({ + it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { + const decoded = decodeClientSettings({ sidebarV2Enabled: false, sidebarV2ConfiguredByUser: true, }); - expect(settings.sidebarV2Enabled).toBe(false); - expect(settings.sidebarV2ConfiguredByUser).toBe(true); + expect(decoded.legacySidebarEnabled).toBe(false); + expect(decoded).not.toHaveProperty("sidebarV2Enabled"); + expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); - it("carries an explicit beta opt-out through the patch the beta toggle writes", () => { - const patch = decodeClientSettingsPatch({ - sidebarV2Enabled: false, - sidebarV2ConfiguredByUser: true, - }); - expect(patch.sidebarV2Enabled).toBe(false); - expect(patch.sidebarV2ConfiguredByUser).toBe(true); + it("preserves an explicit legacy sidebar opt-in", () => { + expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true); + expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe( + true, + ); }); it("allows the recent work queue to be disabled", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6d039c16e2e6..28158a48a45d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -182,6 +182,11 @@ export const ClientSettingsSchema = Schema.Struct({ // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Legacy sidebar (the original per-project tree). Deliberately a fresh key + // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the + // old keys, so everyone, including prior beta opt-outs, resets to the new + // default sidebar. + legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -211,13 +216,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarRecentThreadsEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), - sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta. - // Client settings persist as a whole blob, so every user who has ever touched - // any setting already has `sidebarV2Enabled: false` stored — without this bit - // there is no way to tell that apart from "left alone", and a channel-derived - // default could never reach them. Mirrors `updateChannelConfiguredByUser`. - sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -581,7 +579,12 @@ export const BackgroundActivitySettings = Schema.Struct({ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Legacy token-by-token assistant output. Deliberately a fresh key (was + // `enableAssistantStreaming`): decoding drops the old key, so everyone, + // including prior opt-ins, resets to the buffered default. + enableLegacyTokenStreaming: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New @@ -756,7 +759,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings - enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ @@ -847,6 +850,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), ), planModeEnabled: Schema.optionalKey(Schema.Boolean), + legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( @@ -857,8 +861,6 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarHideProviderIcons: Schema.optionalKey(Schema.Boolean), sidebarRecentThreadsEnabled: Schema.optionalKey(Schema.Boolean), - sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), - sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), });