diff --git a/apps/web/package.json b/apps/web/package.json index a3699235..18f24162 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,6 @@ "@effect/atom-react": "catalog:", "@fontsource-variable/cascadia-mono": "^5.3.0", "@fontsource-variable/schibsted-grotesk": "^5.3.0", - "@formkit/auto-animate": "^0.10.0", "@legendapp/list": "^3.3.3", "@lexical/react": "^0.47.0", "@pierre/diffs": "catalog:", diff --git a/apps/web/src/browserPanelStore.test.ts b/apps/web/src/browserPanelStore.test.ts index 24f6eae6..de300e9e 100644 --- a/apps/web/src/browserPanelStore.test.ts +++ b/apps/web/src/browserPanelStore.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { makeBrowserTab, nextActiveTabId, steppedZoom, type BrowserTab } from "./browserPanelStore"; +import { + makeBrowserTab, + nextActiveTabId, + steppedZoom, + waitForPreviewWebview, + type BrowserTab, +} from "./browserPanelStore"; function tabs(count: number): BrowserTab[] { return Array.from({ length: count }, () => makeBrowserTab()); @@ -49,3 +55,48 @@ describe("steppedZoom", () => { expect(steppedZoom(1.05, -1)).toBe(1); }); }); + +describe("waitForPreviewWebview", () => { + it("returns the page as soon as one registers, without waiting out the cap", async () => { + let element: string | null = null; + const listeners = new Set<() => void>(); + + const waited = waitForPreviewWebview({ + resolve: () => element, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + timeoutMs: 5_000, + }); + + // The panel mounts its element a tick after the agent asked for it. + element = "webview"; + for (const listener of listeners) listener(); + + expect(await waited).toBe("webview"); + // The subscription is not left behind once it has served its purpose. + expect(listeners.size).toBe(0); + }); + + it("gives up at the cap so a panel that never mounts still answers", async () => { + const timers: Array<() => void> = []; + + const waited = waitForPreviewWebview({ + resolve: () => null, + subscribe: () => () => undefined, + timeoutMs: 5_000, + setTimeoutFn: ((callback: () => void) => { + timers.push(callback); + return 1 as unknown as ReturnType; + }) as unknown as typeof globalThis.setTimeout, + clearTimeoutFn: (() => undefined) as unknown as typeof globalThis.clearTimeout, + }); + + timers[0]?.(); + + // Null rather than a rejection: the caller reports "no page" the way it + // always has instead of the agent seeing a thrown error. + expect(await waited).toBeNull(); + }); +}); diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts index 49aefda3..41ecf8cc 100644 --- a/apps/web/src/browserPanelStore.ts +++ b/apps/web/src/browserPanelStore.ts @@ -157,8 +157,143 @@ export function nextActiveTabId( return (remaining[closedIndex] ?? remaining[remaining.length - 1])!.id; } +/** + * The live `` elements, by thread and tab. + * + * Deliberately not in the store's state: these are DOM nodes, they change on + * every tab mount, and nothing renders from them -- the automation host is the + * only caller, and it wants the current element rather than a re-render. Kept + * here rather than in the panel because the host now outlives the panel. + */ +export interface PreviewWebviewHandle { + getWebContentsId: () => number; + loadURL: (url: string) => Promise; + getBoundingClientRect: () => DOMRect; +} + +const webviewRegistry = new Map(); +const webviewListeners = new Set<() => void>(); + +function webviewKey(threadRef: ScopedThreadRef, tabId: string): string { + return `${scopedThreadKey(threadRef)}::${tabId}`; +} + +export function registerPreviewWebview( + threadRef: ScopedThreadRef, + tabId: string, + element: PreviewWebviewHandle | null, +): void { + const key = webviewKey(threadRef, tabId); + if (element === null) { + webviewRegistry.delete(key); + } else { + webviewRegistry.set(key, element); + } + for (const listener of webviewListeners) { + listener(); + } +} + +export function getPreviewWebview( + threadRef: ScopedThreadRef, + tabId: string, +): PreviewWebviewHandle | null { + return webviewRegistry.get(webviewKey(threadRef, tabId)) ?? null; +} + +export function subscribePreviewWebviews(listener: () => void): () => void { + webviewListeners.add(listener); + return () => { + webviewListeners.delete(listener); + }; +} + +/** Test seam: the registry outlives any one component, so tests clear it. */ +export function resetPreviewWebviewsForTests(): void { + webviewRegistry.clear(); + webviewListeners.clear(); +} + +/** + * Waits for a page to exist, for as long as it is worth waiting. + * + * Opening the panel is not instant: the element mounts, attaches, and only then + * has a webContents to drive. The agent's call arrives before all of that, so + * it waits here rather than being told there is no browser -- but it waits + * against a cap well under the broker's own timeout, so a panel that never + * comes up answers the agent instead of hanging it. + */ +export async function waitForPreviewWebview(input: { + readonly resolve: () => T | null; + readonly subscribe: (listener: () => void) => () => void; + readonly timeoutMs: number; + readonly setTimeoutFn?: typeof globalThis.setTimeout; + readonly clearTimeoutFn?: typeof globalThis.clearTimeout; +}): Promise { + const immediate = input.resolve(); + if (immediate !== null) { + return immediate; + } + const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; + const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; + + return await new Promise((resolvePromise) => { + let settled = false; + const finish = (value: T | null) => { + if (settled) return; + settled = true; + clearTimeoutFn(timer); + unsubscribe(); + resolvePromise(value); + }; + const unsubscribe = input.subscribe(() => { + const candidate = input.resolve(); + if (candidate !== null) { + finish(candidate); + } + }); + const timer = setTimeoutFn(() => finish(null), input.timeoutMs); + // Racing the subscription: registration may have landed between the first + // check and the listener going on. + const late = input.resolve(); + if (late !== null) { + finish(late); + } + }); +} + +/** How long an agent's call waits for the panel it just opened to have a page. */ +export const PREVIEW_WEBVIEW_WAIT_MS = 5_000; + +/** + * What the agent is doing in a thread's browser. + * + * In the store rather than in the panel because the host that produces it now + * outlives the panel that draws it: an agent can act on a page while the panel + * is closed, and when it opens the panel has to already know where the pointer + * went and which tab is the agent's. + */ +export interface ThreadBrowserAgentState { + /** The tab the agent pinned itself to; null until it acts. */ + tabId: string | null; + point: { x: number; y: number; from?: { x: number; y: number }; sequence: number } | null; + activity: { + phase: "running" | "done"; + verb: string; + detail: string | null; + sequence: number; + } | null; +} + +export const EMPTY_AGENT_STATE: ThreadBrowserAgentState = Object.freeze({ + tabId: null, + point: null, + activity: null, +}); + interface BrowserPanelStoreState { browserStateByThreadKey: Record; + agentStateByThreadKey: Record; splitChatFraction: number; /** Hides the chat so the page gets the whole centre; the split is remembered. */ expanded: boolean; @@ -188,6 +323,15 @@ interface BrowserPanelStoreState { setTabZoom: (threadRef: ScopedThreadRef, tabId: string, zoomFactor: number) => void; toggleDeviceToolbar: () => void; setAppearance: (appearance: BrowserAppearance) => void; + setAgentTab: (threadRef: ScopedThreadRef, tabId: string | null) => void; + setAgentPoint: ( + threadRef: ScopedThreadRef, + point: { x: number; y: number; from?: { x: number; y: number } } | null, + ) => void; + setAgentActivity: ( + threadRef: ScopedThreadRef, + activity: ThreadBrowserAgentState["activity"], + ) => void; setSplitChatFraction: (fraction: number) => void; toggleExpanded: () => void; } @@ -204,6 +348,16 @@ function updateThread( }; } +function updateAgentState( + state: BrowserPanelStoreState, + threadRef: ScopedThreadRef, + update: (current: ThreadBrowserAgentState) => ThreadBrowserAgentState, +): Pick { + const key = scopedThreadKey(threadRef); + const current = state.agentStateByThreadKey[key] ?? EMPTY_AGENT_STATE; + return { agentStateByThreadKey: { ...state.agentStateByThreadKey, [key]: update(current) } }; +} + function updateTab( current: ThreadBrowserState, tabId: string, @@ -219,6 +373,7 @@ export const useBrowserPanelStore = create()( persist( (set) => ({ browserStateByThreadKey: {}, + agentStateByThreadKey: {}, splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION, expanded: false, deviceToolbarOpen: false, @@ -281,6 +436,18 @@ export const useBrowserPanelStore = create()( ), toggleDeviceToolbar: () => set((state) => ({ deviceToolbarOpen: !state.deviceToolbarOpen })), setAppearance: (appearance) => set(() => ({ appearance })), + setAgentTab: (threadRef, tabId) => + set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, tabId }))), + setAgentPoint: (threadRef, point) => + set((state) => + updateAgentState(state, threadRef, (current) => ({ + ...current, + point: + point === null ? null : { ...point, sequence: (current.point?.sequence ?? 0) + 1 }, + })), + ), + setAgentActivity: (threadRef, activity) => + set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, activity }))), setSplitChatFraction: (fraction) => set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })), toggleExpanded: () => set((state) => ({ expanded: !state.expanded })), @@ -316,6 +483,16 @@ export function selectThreadBrowserState( return stored === undefined || stored.tabs.length === 0 ? DEFAULT_THREAD_STATE : stored; } +export function selectThreadAgentState( + agentStateByThreadKey: Record, + threadRef: ScopedThreadRef | null, +): ThreadBrowserAgentState { + if (threadRef === null) { + return EMPTY_AGENT_STATE; + } + return agentStateByThreadKey[scopedThreadKey(threadRef)] ?? EMPTY_AGENT_STATE; +} + export function selectActiveTab(state: ThreadBrowserState): BrowserTab | null { return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0] ?? null; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ce8a3b54..eacdd410 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -25,9 +25,6 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_SIDEBAR_DEFAULT_WIDTH = `${THREAD_SIDEBAR_MIN_WIDTH}px`; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "88px"; -// Collapsed width of the deck rail. Wide enough for a 32px hit target plus the -// breathing room that keeps status dots off the window edge. -const THREAD_SIDEBAR_RAIL_WIDTH = "2.75rem"; function SidebarControl() { return ( @@ -50,7 +47,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const appSettings = useSettings(); const sidebarStyle = { "--sidebar-width": THREAD_SIDEBAR_DEFAULT_WIDTH, - "--sidebar-width-icon": THREAD_SIDEBAR_RAIL_WIDTH, ...(isElectron && typeof navigator !== "undefined" && isMacPlatform(navigator.platform) ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), @@ -139,7 +135,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - {/* Only the expanded pane resizes; a handle on the fixed-width rail - would advertise a drag that does nothing. */} - + {/* The drag handle on the pane's edge; it resizes while open. */} + {children} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f547dadb..1ffc0d09 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,6 +8,8 @@ import { type EnvironmentApi, type MessageId, type OrchestrationEvent, + type PreviewAutomationRequest, + type PreviewAutomationResponse, type OrchestrationReadModel, type ProjectId, ProviderDriverKind, @@ -45,6 +47,13 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { CLIENT_SETTINGS_STORAGE_KEY } from "../clientPersistenceStorage"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + makeBrowserTab, + registerPreviewWebview, + resetPreviewWebviewsForTests, + useBrowserPanelStore, +} from "../browserPanelStore"; +import { PreviewAutomationMount } from "./browser/PreviewAutomationMount"; import { __resetEnvironmentApiOverridesForTests, __setEnvironmentApiOverrideForTests, @@ -102,7 +111,10 @@ const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, }, ); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; +// The fixture clock is "a few minutes ago", not a fixed date: the sidebar's +// lifecycle is relative (threads file themselves under Done once idle), so a +// pinned calendar date would age into the Done tail as real time moved on. +const NOW_ISO = new Date(Date.now() - 10 * 60_000).toISOString(); const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; @@ -221,12 +233,13 @@ function createMockEnvironmentApi(input: { browse: EnvironmentApi["filesystem"]["browse"]; dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; getRevertPlan?: EnvironmentApi["orchestration"]["getRevertPlan"]; + previewAutomation?: EnvironmentApi["previewAutomation"]; }): EnvironmentApi { return { terminal: {} as EnvironmentApi["terminal"], projects: {} as EnvironmentApi["projects"], attachments: {} as EnvironmentApi["attachments"], - previewAutomation: {} as EnvironmentApi["previewAutomation"], + previewAutomation: input.previewAutomation ?? ({} as EnvironmentApi["previewAutomation"]), filesystem: { browse: input.browse, }, @@ -2097,6 +2110,8 @@ describe("ChatView timeline estimator parity (full app)", () => { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + doneThreadOverrides: {}, + inboxProjectScopeKey: null, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ @@ -2192,12 +2207,13 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), "Unable to find phone thread row.", ); - const archiveButton = await waitForElement( - () => - document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), - "Unable to find archive button.", + const pinButton = await waitForElement( + () => document.querySelector(`[data-testid="thread-pin-${THREAD_ID}"]`), + "Unable to find pin button.", ); - const compactActions = archiveButton.parentElement; + // Archive belongs to the Done tail now; a live row offers pin and done. + expect(document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`)).toBeNull(); + const compactActions = pinButton.parentElement; const timestampWrapper = await waitForElement( () => threadRow.querySelector(`[data-testid="thread-meta-${THREAD_ID}"]`), "Unable to find thread timestamp.", @@ -2373,30 +2389,59 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("hides the active thread row when collapsing a one-thread project", async () => { + function withThreadBranch( + snapshot: ReturnType, + branch: string, + ): ReturnType { + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, branch } : thread, + ), + }; + } + + it("narrows the live list from the scope menu and drops the implied project label", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-one-thread-collapse-target" as MessageId, - targetText: "one thread collapse target", - }), + snapshot: withThreadBranch( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-scope-target" as MessageId, + targetText: "scope target", + }), + "feature/scope-test", + ), }); try { - await expect.element(page.getByTestId(`thread-row-${THREAD_ID}`)).toBeInTheDocument(); + const threadRow = await waitForElement( + () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), + "Unable to find thread row.", + ); + // Unscoped: the row names its project and the branch the thread pinned. + expect(threadRow.textContent).toContain("Project"); + expect(threadRow.textContent).toContain("feature/scope-test"); - const projectButton = await waitForButtonByText("Project"); - expect(projectButton.getAttribute("aria-expanded")).toBe("true"); - projectButton.click(); + await page.getByTestId("inbox-scope-trigger").click(); + await page.getByTestId(`inbox-scope-${PROJECT_LOGICAL_KEY}`).click(); await vi.waitFor( () => { - expect(projectButton.getAttribute("aria-expanded")).toBe("false"); - expect(document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`)).toBeNull(); + expect(useUiStateStore.getState().inboxProjectScopeKey).toBe(PROJECT_LOGICAL_KEY); + const scopedRow = document.querySelector( + `[data-testid="thread-row-${THREAD_ID}"]`, + ); + expect(scopedRow).not.toBeNull(); + // Scoped: the project is implied, but the row keeps its second line. + expect(scopedRow?.textContent).not.toContain("Project"); + expect( + scopedRow?.querySelector(`[data-testid="thread-detail-${THREAD_ID}"]`), + ).not.toBeNull(); }, { timeout: 4_000, interval: 16 }, ); } finally { + useUiStateStore.getState().setInboxProjectScope(null); await mounted.cleanup(); } }); @@ -5037,9 +5082,6 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("renders compact thread actions in the shared thread-row hover group", async () => { - // Deliberately unpinned. A pinned thread is on deck, and a thread on deck - // is not drawn again in the tree -- so the tree row this is about only - // exists for an ordinary thread. const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -5053,28 +5095,31 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), "Unable to find thread row.", ); - const archiveButton = await waitForElement( - () => - document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), - "Unable to find archive button.", + const doneButton = await waitForElement( + () => document.querySelector(`[data-testid="thread-done-${THREAD_ID}"]`), + "Unable to find done button.", ); const pinButton = await waitForElement( () => document.querySelector(`[data-testid="thread-pin-${THREAD_ID}"]`), "Unable to find pin button.", ); - const compactActions = archiveButton.parentElement; + const compactActions = pinButton.parentElement; expect( compactActions, "Thread actions should render inside a shared visibility wrapper.", ).not.toBeNull(); - const threadItem = archiveButton.closest("li"); - expect(threadItem?.className).toContain("group/menu-sub-item"); - expect(compactActions?.className).toContain("group-hover/menu-sub-item:opacity-100"); - expect(compactActions?.className).toContain("group-focus-within/menu-sub-item:opacity-100"); + const threadItem = pinButton.closest("li"); + expect(threadItem?.className).toContain("group/thread-row"); + expect(compactActions?.className).toContain("group-hover/thread-row:opacity-100"); + expect(compactActions?.className).toContain("group-focus-within/thread-row:opacity-100"); expect(pinButton.getAttribute("aria-label")).toBe(`Pin ${THREAD_TITLE}`); expect(pinButton.getAttribute("aria-pressed")).toBe("false"); + expect(doneButton.getAttribute("aria-label")).toBe(`Wrap up ${THREAD_TITLE}`); + // Icons only: the words for these two live in their tooltips. + expect(doneButton.textContent).toBe(""); + expect(pinButton.textContent).toBe(""); expect( - pinButton.compareDocumentPosition(archiveButton) & Node.DOCUMENT_POSITION_FOLLOWING, + doneButton.compareDocumentPosition(pinButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); // Hidden until the row is hovered or focused, which is the whole point of // the shared wrapper. @@ -5084,43 +5129,408 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("puts a pinned thread on the deck instead of in the tree, and holds live work there", async () => { - // This replaces two tests that asserted pin and archive on the tree row of a - // running thread. That row no longer exists: pinned and live threads are on - // deck, and a thread on deck is drawn once, there. What matters now is that - // it moved rather than vanished, and that a run in progress cannot be waved - // away with the dismiss button. + it("lists every open thread once, floats pins to the top, and refuses Done on running work", async () => { + const pinnedThreadId = "thread-pinned-inbox" as ThreadId; + const runningSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-running-pin-test" as MessageId, + targetText: "running pin target", + sessionStatus: "running", + sessionActiveTurnId: "turn-running-pin-test" as TurnId, + }); + const withPinnedThread = addThreadToSnapshot(runningSnapshot, pinnedThreadId); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-running-pin-test" as MessageId, - targetText: "running pin target", - threadPinnedAt: NOW_ISO, - sessionStatus: "running", - sessionActiveTurnId: "turn-running-pin-test" as TurnId, - }), + snapshot: { + ...withPinnedThread, + threads: withPinnedThread.threads.map((thread) => + thread.id === pinnedThreadId ? { ...thread, pinnedAt: NOW_ISO } : thread, + ), + }, }); try { - await waitForElement( - () => document.querySelector(`[data-testid="on-deck-row-${THREAD_ID}"]`), - "Unable to find the thread's deck row.", + const runningRow = await waitForElement( + () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), + "Unable to find the running thread's row.", + ); + const pinnedRow = await waitForElement( + () => document.querySelector(`[data-testid="thread-row-${pinnedThreadId}"]`), + "Unable to find the pinned thread's row.", ); expect( - document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), - "A thread on deck should not also be drawn in the project tree.", - ).toBeNull(); + pinnedRow.compareDocumentPosition(runningRow) & Node.DOCUMENT_POSITION_FOLLOWING, + "A pinned thread sorts above the rest of the live list.", + ).toBeTruthy(); expect( - document.querySelector(`[data-testid="on-deck-dismiss-${THREAD_ID}"]`), - "A running thread should not offer dismiss.", + document.querySelectorAll(`[data-testid="thread-row-${THREAD_ID}"]`).length, + "Each thread is drawn exactly once.", + ).toBe(1); + expect( + document.querySelector(`[data-testid="thread-done-${THREAD_ID}"]`), + "A running thread cannot be marked done.", ).toBeNull(); + expect( + document.querySelector(`[data-testid="thread-done-${pinnedThreadId}"]`), + "A settled thread offers Done.", + ).not.toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps General Chats as its own destination above the scope row", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-general-chats-nav" as MessageId, + targetText: "general chats nav target", + }), + }); + + try { + const chatsRow = page.getByTestId("sidebar-general-chats"); + await expect.element(chatsRow).toBeInTheDocument(); + await chatsRow.click(); + + await waitForURL( + mounted.router, + (pathname) => pathname === "/chats", + "General Chats should open the chats page.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens a closed browser panel for an arriving automation request", async () => { + // The agent asks for the page while the panel is shut. It used to be told + // there was no browser; now the request is what opens one. + const tab = makeBrowserTab(); + useBrowserPanelStore.setState({ + browserStateByThreadKey: { + [THREAD_KEY]: { open: false, tabs: [tab], activeTabId: tab.id }, + }, + agentStateByThreadKey: {}, + }); + + let deliver: ((request: PreviewAutomationRequest) => void) | null = null; + const responses: PreviewAutomationResponse[] = []; + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: (() => + Promise.reject(new Error("not used"))) as EnvironmentApi["filesystem"]["browse"], + dispatchCommand: (() => + Promise.reject( + new Error("not used"), + )) as EnvironmentApi["orchestration"]["dispatchCommand"], + previewAutomation: { + connect: (_input: unknown, listener: (request: PreviewAutomationRequest) => void) => { + deliver = listener; + return () => { + deliver = null; + }; + }, + respond: (response: PreviewAutomationResponse) => { + responses.push(response); + return Promise.resolve(); + }, + } as unknown as EnvironmentApi["previewAutomation"], + }), + ); + window.desktopBridge = { + previewStatus: () => + Promise.resolve({ url: "http://localhost:5173/", title: "Preview", loading: false }), + } as unknown as NonNullable; + + const screen = await render(); + + try { + await vi.waitFor( + () => { + expect(deliver, "the host should register with the broker").not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + + deliver!({ + requestId: "req-auto-open", + operation: "status", + input: {}, + } as unknown as PreviewAutomationRequest); + + // The panel opens on its own... + await vi.waitFor( + () => { + expect(useBrowserPanelStore.getState().browserStateByThreadKey[THREAD_KEY]?.open).toBe( + true, + ); + }, + { timeout: 4_000, interval: 16 }, + ); + + // ...and once its page is up, the operation the agent asked for lands. + registerPreviewWebview(THREAD_REF, tab.id, { + getWebContentsId: () => 42, + loadURL: () => Promise.resolve(), + getBoundingClientRect: () => ({ width: 900, height: 600 }) as DOMRect, + }); + + await vi.waitFor( + () => { + expect(responses).toHaveLength(1); + expect(responses[0]?.error, "the request should not report a missing browser").toBe( + undefined, + ); + expect(responses[0]?.result).toMatchObject({ url: "http://localhost:5173/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + screen.unmount(); + resetPreviewWebviewsForTests(); + useBrowserPanelStore.setState({ browserStateByThreadKey: {}, agentStateByThreadKey: {} }); + Reflect.deleteProperty(window, "desktopBridge"); + __resetEnvironmentApiOverridesForTests(); + } + }); + + it("pins the search chrome above the scrolling list", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-search" as MessageId, + targetText: "sticky search target", + }), + }); + + try { + const trigger = await waitForElement( + () => document.querySelector('[data-testid="command-palette-trigger"]'), + "Unable to find the search trigger.", + ); + const row = await waitForElement( + () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), + "Unable to find a thread row.", + ); + const viewport = document.querySelector('[data-slot="scroll-area-viewport"]'); + + expect(viewport, "the sidebar list should scroll in its own viewport").not.toBeNull(); + // The list scrolls inside the viewport; the search row is not in it, so + // it cannot scroll away. + expect(viewport!.contains(row)).toBe(true); + expect(viewport!.contains(trigger)).toBe(false); + expect(trigger.getBoundingClientRect().bottom).toBeLessThanOrEqual( + viewport!.getBoundingClientRect().top + 1, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps a working row's status whole and drops the branch to make room", async () => { + const longBranch = "feature/a-very-long-branch-name-that-would-never-fit-in-the-sidebar"; + const base = createSnapshotForTargetUser({ + targetMessageId: "msg-user-working-collision" as MessageId, + targetText: "working collision target", + sessionStatus: "running", + sessionActiveTurnId: "turn-working-collision" as TurnId, + }); + const branched = withThreadBranch(base, longBranch); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...branched, + projects: base.projects.map((project) => ({ + ...project, + title: "A project with a deliberately long display name", + })), + // A running session has a turn under way; that turn is what the + // elapsed counter counts from. + threads: branched.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + latestTurn: { + turnId: "turn-working-collision" as TurnId, + state: "running" as const, + requestedAt: NOW_ISO, + startedAt: NOW_ISO, + completedAt: null, + assistantMessageId: null, + }, + } + : thread, + ), + }, + }); + + try { + const row = await waitForElement( + () => document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`), + "Unable to find the working thread's row.", + ); + const meta = await waitForElement( + () => document.querySelector(`[data-testid="thread-meta-${THREAD_ID}"]`), + "Unable to find the row's status slot.", + ); + + await vi.waitFor( + () => { + // A duration, never the relative formatter's "just now". + expect(meta.textContent).toMatch(/^working · \d+[smh]/); + }, + { timeout: 8_000, interval: 16 }, + ); + + // The status slot is never the thing that gets cut. + expect(meta.scrollWidth).toBeLessThanOrEqual(meta.clientWidth + 1); + expect(meta.getBoundingClientRect().right).toBeLessThanOrEqual( + row.getBoundingClientRect().right, + ); + // The branch yields first, and yields completely. + expect(row.textContent).not.toContain("feature/"); } finally { await mounted.cleanup(); } }); - it("exposes the full thread title on the sidebar row tooltip", async () => { + it("reveals the folded tail in increments and folds it back", async () => { + const extraThreadIds = Array.from( + { length: 13 }, + (_, index) => `thread-quiet-${index}` as ThreadId, + ); + const snapshot = extraThreadIds.reduce( + (current, threadId) => addThreadToSnapshot(current, threadId), + createSnapshotForTargetUser({ + targetMessageId: "msg-user-live-fold" as MessageId, + targetText: "live fold target", + }), + ); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot }); + + const expectRows = async (count: number, revealLabel: string | null) => { + await vi.waitFor( + () => { + expect(document.querySelectorAll('[data-testid^="thread-row-"]').length).toBe(count); + const reveal = document.querySelector('[data-testid="inbox-live-show-more"]'); + if (revealLabel === null) { + expect(reveal).toBeNull(); + } else { + expect(reveal?.textContent).toContain(revealLabel); + } + }, + { timeout: 8_000, interval: 16 }, + ); + }; + + try { + // Fourteen quiet threads, six unfolded, revealed five at a time. + await expectRows(6, "Show 5 more"); + await page.getByTestId("inbox-live-show-more").click(); + await expectRows(11, "Show 3 more"); + await page.getByTestId("inbox-live-show-more").click(); + // Nothing left to reveal, so only the fold-back and search icons remain. + await expectRows(14, null); + await expect.element(page.getByTestId("inbox-live-search")).toBeInTheDocument(); + + await page.getByTestId("inbox-live-show-fewer").click(); + await expectRows(6, "Show 5 more"); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the thread you are reading on the shelf when Done collapses", async () => { + const otherDoneThreadId = "thread-done-collapse" as ThreadId; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: addThreadToSnapshot( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-done-collapse" as MessageId, + targetText: "done collapse target", + }), + otherDoneThreadId, + ), + }); + + try { + await expect.element(page.getByTestId(`thread-row-${THREAD_ID}`)).toBeInTheDocument(); + const otherThreadKey = scopedThreadKey( + scopeThreadRef(LOCAL_ENVIRONMENT_ID, otherDoneThreadId), + ); + const doneAt = new Date().toISOString(); + useUiStateStore.getState().markThreadDone(THREAD_KEY, doneAt); + useUiStateStore.getState().markThreadDone(otherThreadKey, doneAt); + + await vi.waitFor( + () => { + expect(document.querySelector(`[data-testid="done-row-${THREAD_ID}"]`)).not.toBeNull(); + expect( + document.querySelector(`[data-testid="done-row-${otherDoneThreadId}"]`), + ).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page.getByTestId("inbox-done-toggle").click(); + + await vi.waitFor( + () => { + // Collapsing a section must not close the thread open inside it. + expect(document.querySelector(`[data-testid="done-row-${THREAD_ID}"]`)).not.toBeNull(); + expect( + document.querySelector(`[data-testid="done-row-${otherDoneThreadId}"]`), + ).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("moves a thread into the Done tail and back with Reopen", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-done-lifecycle" as MessageId, + targetText: "done lifecycle target", + }), + }); + + try { + const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + await expect.element(threadRow).toBeInTheDocument(); + await threadRow.hover(); + await page.getByTestId(`thread-done-${THREAD_ID}`).click(); + + await vi.waitFor( + () => { + expect(document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`)).toBeNull(); + expect(document.querySelector(`[data-testid="done-row-${THREAD_ID}"]`)).not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + + const doneRow = page.getByTestId(`done-row-${THREAD_ID}`); + await doneRow.hover(); + await page.getByTestId(`done-reopen-${THREAD_ID}`).click(); + + await vi.waitFor( + () => { + expect(document.querySelector(`[data-testid="done-row-${THREAD_ID}"]`)).toBeNull(); + expect(document.querySelector(`[data-testid="thread-row-${THREAD_ID}"]`)).not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the full thread title in the row hover card, and nothing else", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -5137,9 +5547,17 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain(THREAD_TITLE); + const hoverCard = document.querySelector( + '[data-testid="thread-hover-card"]', + ); + expect(hoverCard).not.toBeNull(); + expect(hoverCard?.textContent).toContain(THREAD_TITLE); + // The hover card is the only popup: a truncated title must not raise + // a second one on top of it. + expect(document.querySelectorAll('[data-testid="thread-hover-card"]').length).toBe(1); + // And no tooltip stacked on top: the card moved off the tooltip + // primitive precisely so the two can never compete. + expect(document.querySelectorAll('[data-slot="tooltip-popup"]').length).toBe(0); }, { timeout: 8_000, interval: 16 }, ); @@ -5166,10 +5584,14 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + // live -> done -> archived: archive is only offered once a thread has + // been filed away. + await expect.element(page.getByTestId(`thread-row-${THREAD_ID}`)).toBeInTheDocument(); + useUiStateStore.getState().markThreadDone(THREAD_KEY, new Date().toISOString()); - await expect.element(threadRow).toBeInTheDocument(); - await threadRow.hover(); + const doneRow = page.getByTestId(`done-row-${THREAD_ID}`); + await expect.element(doneRow).toBeInTheDocument(); + await doneRow.hover(); const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); await expect.element(archiveButton).toBeInTheDocument(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b8c8618c..6f9bb51e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,8 +112,6 @@ import { type TurnDiffSummary, type ChatAttachment, } from "../types"; -import { useAutoCollapseSidebar } from "../hooks/useAutoCollapseSidebar"; -import { useSidebar } from "./ui/sidebar"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -260,6 +258,7 @@ import { import { useLocalStorage } from "~/hooks/useLocalStorage"; import { selectThreadBrowserState, useBrowserPanelStore } from "../browserPanelStore"; import { BrowserPanel } from "./browser/BrowserPanel"; +import { PreviewAutomationMount } from "./browser/PreviewAutomationMount"; import { BrowserSplitHandle } from "./browser/BrowserSplitHandle"; import { useComposerHandleContext } from "../composerHandleContext"; import { @@ -2545,15 +2544,6 @@ export default function ChatView(props: ChatViewProps) { const browserAvailable = !isGeneralChatThread; const browserOpen = browserAvailable && browserPanelState.open; - // With source control and the browser both open the chat column is what pays - // for it, so the sidebar folds down to its rail -- once, and never again if - // you expand it back. - const { setOpen: setSidebarOpen, state: sidebarState } = useSidebar(); - useAutoCollapseSidebar({ - squeezed: browserOpen && rightPanelEngaged, - expanded: sidebarState === "expanded", - setExpanded: setSidebarOpen, - }); const handleToggleBrowser = useCallback(() => { if (routeThreadRef !== null) { toggleBrowserOpen(routeThreadRef); @@ -6499,6 +6489,12 @@ export default function ChatView(props: ChatViewProps) { ) : null} {/* end chat column */} + {/* The agent's end of the browser is mounted with the thread, not with + the panel: a closed panel is a closed panel, not the absence of a + browser, and a request for the browser opens it. */} + {browserAvailable && routeThreadRef !== null ? ( + + ) : null} {browserOpen && routeThreadRef !== null ? ( <> {browserExpanded ? null : ( diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx index bfa8780f..e730009c 100644 --- a/apps/web/src/components/ChatsDestinationView.tsx +++ b/apps/web/src/components/ChatsDestinationView.tsx @@ -17,14 +17,40 @@ import { } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; import { formatRelativeTimeLabel } from "../timestampFormat"; -import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; +import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; +import { PROVIDER_OPTIONS } from "../session-logic"; import { resolveThreadStatusPill } from "./Sidebar.logic"; -import { ThreadHoverCard } from "./sidebar/ThreadHoverCard"; +import { ThreadHoverCard, ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; +import type { SidebarThreadSummary } from "../types"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +type ChatGroupId = "today" | "week" | "earlier"; + +const CHAT_GROUP_LABELS: Record = { + today: "Today", + week: "This week", + earlier: "Earlier", +}; + +function chatActivityAt(thread: SidebarThreadSummary): string { + return thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; +} + +/** Recency buckets, read off the same clock as the row's relative time. */ +function resolveChatGroup(activityAt: string, nowMs: number): ChatGroupId { + const at = Date.parse(activityAt); + if (Number.isNaN(at)) return "earlier"; + const startOfToday = new Date(nowMs).setHours(0, 0, 0, 0); + if (at >= startOfToday) return "today"; + if (at >= startOfToday - 6 * DAY_MS) return "week"; + return "earlier"; +} /** * The Chats destination: general chats are threads with no project, so they get - * a place of their own rather than a project-shaped group in the sidebar tree. + * a place of their own rather than a project-shaped group in the sidebar. */ export function ChatsDestinationView() { const navigate = useNavigate(); @@ -60,81 +86,135 @@ export function ChatsDestinationView() { ); }, [generalChatsProject, threadSortOrder, threads]); + const groups = useMemo(() => { + const nowMs = Date.now(); + const byGroup = new Map(); + for (const thread of chats) { + const group = resolveChatGroup(chatActivityAt(thread), nowMs); + const existing = byGroup.get(group); + if (existing) { + existing.push(thread); + } else { + byGroup.set(group, [thread]); + } + } + // Empty buckets are simply absent: a label with nothing under it is a + // heading for a list that does not exist. + return (["today", "week", "earlier"] as const).flatMap((group) => { + const groupChats = byGroup.get(group); + return groupChats ? [{ id: group, chats: groupChats }] : []; + }); + }, [chats]); + const startChat = () => { if (generalChatsProjectRef) { void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef); } }; - return ( -
-
-

General chats

- -
-

- Conversations that aren't tied to a project. -

+ + New chat + + ); - {chats.length === 0 ? ( -

- No chats yet. + return ( + +

+
+

General chats

+ {newChatButton} +
+

+ Conversations that aren't tied to a project.

- ) : ( - - {/* The rows repeat the page title's shape closely enough that a rule - alone read as more list. A label gives the list a head of its own. */} -
- {chats.length} {chats.length === 1 ? "chat" : "chats"} + + {chats.length === 0 ? ( +
+
+

No general chats yet

+

+ Start one for anything that doesn't belong to a project. +

+
+ {newChatButton}
-
- {chats.map((thread) => ( - - - + ) : ( + + {groups.map((group) => ( +
+

+ {CHAT_GROUP_LABELS[group.id]} +

+
+ {group.chats.map((thread) => ( + { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(thread.environmentId, thread.id), + ), + }); + }} + /> + ))} +
+
))} -
- - )} -
+
+ )} +
+ + ); +} + +function ChatRow({ thread, onOpen }: { thread: SidebarThreadSummary; onOpen: () => void }) { + const provider = thread.session?.provider ?? null; + const ProviderIcon = provider ? (PROVIDER_ICON_BY_PROVIDER[provider] ?? null) : null; + // The listing carries no message text and this page adds no fetching of its + // own, so the provider is what the second line can honestly say. + const providerLabel = provider + ? (PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider) + : null; + + return ( + + + ); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 63aff431..80e4aca8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,31 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { ProviderDriverKind } from "@threadlines/contracts"; import { - buildOnDeckSyncInput, - buildProjectHoverSummary, - countThreadsNeedingUser, createThreadJumpHintVisibilityController, - excludeOnDeckThreads, getSidebarThreadIdsToPrewarm, - isOnDeckDismissible, - getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, - getSidebarThreadWindow, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, orderItemsByPreferredIds, - resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, THREAD_STATUS_DOT_CLASSES, - resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; +import { + buildProjectScopeOptions, + isThreadDone, + sortInboxThreads, + windowInboxThreads, +} from "./Sidebar.logic"; import { EnvironmentId, OrchestrationLatestTurn, @@ -38,7 +35,6 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, - type SidebarThreadSummary, type Thread, } from "../types"; @@ -62,13 +58,8 @@ describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ - hasActionableProposedPlan: false, - hasPendingApprovals: false, - hasPendingUserInput: false, - interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", - session: null, }), ).toBe(true); }); @@ -407,46 +398,6 @@ describe("resolveAdjacentThreadId", () => { }); }); -describe("getVisibleSidebarThreadIds", () => { - it("returns only the rendered visible thread order across projects", () => { - expect( - getVisibleSidebarThreadIds([ - { - renderedThreadIds: [ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ], - }, - { - renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], - }, - ]), - ).toEqual([ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ThreadId.make("thread-8"), - ThreadId.make("thread-6"), - ]); - }); - - it("skips threads from collapsed projects whose thread panels are not shown", () => { - expect( - getVisibleSidebarThreadIds([ - { - shouldShowThreadPanel: false, - renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], - }, - { - shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], - }, - ]), - ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); - }); -}); - describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( @@ -762,212 +713,6 @@ describe("resolveThreadStatusPill", () => { }); }); -describe("resolveThreadRowClassName", () => { - it("uses the darker selected palette when a thread is both selected and active", () => { - const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); - expect(className).toContain("bg-primary/22"); - expect(className).toContain("hover:bg-primary/26"); - expect(className).toContain("dark:bg-primary/30"); - expect(className).not.toContain("bg-accent/85"); - }); - - it("uses selected hover colors for selected threads", () => { - const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); - expect(className).toContain("bg-primary/15"); - expect(className).toContain("hover:bg-primary/19"); - expect(className).toContain("dark:bg-primary/22"); - expect(className).not.toContain("hover:bg-accent"); - }); - - it("keeps the accent palette for active-only threads", () => { - const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); - expect(className).toContain("bg-accent/85"); - expect(className).toContain("hover:bg-accent"); - }); -}); - -describe("resolveProjectStatusIndicator", () => { - it("returns null when no threads have a notable status", () => { - expect(resolveProjectStatusIndicator([null, null])).toBeNull(); - }); - - it("surfaces the highest-priority actionable state across project threads", () => { - expect( - resolveProjectStatusIndicator([ - { - label: "Completed", - colorClass: "text-emerald-600", - dotClass: THREAD_STATUS_DOT_CLASSES.emerald, - pulse: false, - }, - { - label: "Pending Approval", - colorClass: "text-amber-600", - dotClass: THREAD_STATUS_DOT_CLASSES.amber, - pulse: false, - }, - { - label: "Working", - colorClass: "text-primary-readable", - dotClass: THREAD_STATUS_DOT_CLASSES.blue, - pulse: true, - }, - ]), - ).toMatchObject({ label: "Pending Approval", dotClass: THREAD_STATUS_DOT_CLASSES.amber }); - }); - - it("prefers plan-ready over completed when no stronger action is needed", () => { - expect( - resolveProjectStatusIndicator([ - { - label: "Completed", - colorClass: "text-emerald-600", - dotClass: THREAD_STATUS_DOT_CLASSES.emerald, - pulse: false, - }, - { - label: "Plan Ready", - colorClass: "text-violet-600", - dotClass: THREAD_STATUS_DOT_CLASSES.violet, - pulse: false, - }, - ]), - ).toMatchObject({ label: "Plan Ready", dotClass: THREAD_STATUS_DOT_CLASSES.violet }); - }); -}); - -const makeKeys = (count: number) => Array.from({ length: count }, (_, index) => `t-${index + 1}`); -const getThreadKey = (key: string) => key; - -describe("getSidebarThreadWindow", () => { - const previewLimit = 6; - - it("includes the active thread even when it falls below the folded preview", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(8), - getThreadKey, - activeThreadKey: "t-8", - previewLimit, - revealedCount: 0, - }); - - expect(window.visibleThreads).toEqual(["t-1", "t-2", "t-3", "t-4", "t-5", "t-6", "t-8"]); - expect(window.hiddenThreads).toEqual(["t-7"]); - expect(window.isRevealed).toBe(false); - }); - - it("caps the next reveal to the configured preview count", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(previewLimit + previewLimit + 2), - getThreadKey, - activeThreadKey: null, - previewLimit, - revealedCount: 0, - }); - - expect(window.nextRevealCount).toBe(previewLimit); - expect(window.searchHandoffThreadCount).toBeNull(); - }); - - it("offers a single preview-sized step when the tail is larger than the preview", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(40), - getThreadKey, - activeThreadKey: null, - previewLimit, - revealedCount: 0, - }); - - expect(window.visibleThreads).toHaveLength(previewLimit); - expect(window.nextRevealCount).toBe(previewLimit); - expect(window.searchHandoffThreadCount).toBeNull(); - }); - - it("offers only the remaining tail when it is smaller than the preview count", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(previewLimit + 2), - getThreadKey, - activeThreadKey: null, - previewLimit, - revealedCount: 0, - }); - - expect(window.nextRevealCount).toBe(2); - expect(window.searchHandoffThreadCount).toBeNull(); - }); - - it("uses the configured preview count for the reveal step", () => { - const configuredPreviewLimit = 5; - const window = getSidebarThreadWindow({ - threads: makeKeys(40), - getThreadKey, - activeThreadKey: null, - previewLimit: configuredPreviewLimit, - revealedCount: 0, - }); - - expect(window.visibleThreads).toHaveLength(configuredPreviewLimit); - expect(window.nextRevealCount).toBe(configuredPreviewLimit); - }); - - it("keeps revealing in chunks once expanded while also offering search", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(40), - getThreadKey, - activeThreadKey: null, - previewLimit, - revealedCount: previewLimit, - }); - - expect(window.visibleThreads).toHaveLength(previewLimit + previewLimit); - expect(window.nextRevealCount).toBe(previewLimit); - expect(window.searchHandoffThreadCount).toBe(40); - expect(window.isRevealed).toBe(true); - }); - - it("keeps Show less without further rows when everything is revealed", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(10), - getThreadKey, - activeThreadKey: null, - previewLimit, - revealedCount: 4, - }); - - expect(window.visibleThreads).toHaveLength(10); - expect(window.hiddenThreads).toEqual([]); - expect(window.nextRevealCount).toBe(0); - expect(window.searchHandoffThreadCount).toBeNull(); - expect(window.isRevealed).toBe(true); - }); - - it("does not duplicate an active thread already inside the window", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(8), - getThreadKey, - activeThreadKey: "t-2", - previewLimit, - revealedCount: 0, - }); - - expect(window.visibleThreads).toEqual(["t-1", "t-2", "t-3", "t-4", "t-5", "t-6"]); - expect(window.hiddenThreads).toEqual(["t-7", "t-8"]); - }); - - it("ignores an active thread that belongs to another project", () => { - const window = getSidebarThreadWindow({ - threads: makeKeys(8), - getThreadKey, - activeThreadKey: "other-project-thread", - previewLimit, - revealedCount: 0, - }); - - expect(window.visibleThreads).toEqual(["t-1", "t-2", "t-3", "t-4", "t-5", "t-6"]); - expect(window.hiddenThreads).toEqual(["t-7", "t-8"]); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { @@ -1245,138 +990,177 @@ describe("sortProjectsForSidebar", () => { }); }); -describe("on deck classification", () => { - const pill = (label: import("./Sidebar.logic").ThreadStatusPill["label"]) => ({ - label, - colorClass: "", - dotClass: "", - pulse: false, +describe("inbox done lifecycle", () => { + const NOW = "2026-07-28T12:00:00.000Z"; + const base = { + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestUserMessageAt: null, + latestTurn: null, + createdAt: "2026-07-27T12:00:00.000Z", + updatedAt: undefined, + }; + const doneMark = { state: "done", at: NOW } as const; + + it("refuses to hide work that is blocked on the user", () => { + // Hiding a pending approval defeats the approval: the agent waits forever + // on an answer the user can no longer see the question for. + expect(isThreadDone({ ...base, hasPendingApprovals: true }, doneMark, { now: NOW })).toBe( + false, + ); + expect(isThreadDone({ ...base, hasPendingUserInput: true }, doneMark, { now: NOW })).toBe( + false, + ); }); - it("buildOnDeckSyncInput separates live work from unseen completions", () => { - expect( - buildOnDeckSyncInput({ threadKey: "t-1", pinnedAt: null, status: pill("Working") }), - ).toEqual({ key: "t-1", pinned: false, live: true, unseen: false }); - expect( - buildOnDeckSyncInput({ threadKey: "t-2", pinnedAt: null, status: pill("Completed") }), - ).toEqual({ key: "t-2", pinned: false, live: false, unseen: true }); + it("refuses to hide a running thread, even explicitly marked", () => { + const running = { + ...base, + session: { status: "running" } as never, + }; + expect(isThreadDone(running, doneMark, { now: NOW })).toBe(false); + }); + + it("treats a just-sent message as pending work until a turn adopts it", () => { + // Between send and adoption there is no turn and no running session -- + // the gap where marking Done would hide a message about to run. + const queued = { + ...base, + latestUserMessageAt: "2026-07-28T11:59:30.000Z", + }; + expect(isThreadDone(queued, doneMark, { now: NOW })).toBe(false); + // Past the grace window the same shape is a failed start, not pending + // work; holding it undoneable forever would strand the thread. expect( - buildOnDeckSyncInput({ - threadKey: "t-3", - pinnedAt: "2026-03-09T10:00:00.000Z", - status: null, + isThreadDone({ ...base, latestUserMessageAt: "2026-07-28T11:00:00.000Z" }, doneMark, { + now: NOW, }), - ).toEqual({ key: "t-3", pinned: true, live: false, unseen: false }); + ).toBe(true); }); - it("isOnDeckDismissible allows dismissing settled rows only", () => { - expect(isOnDeckDismissible(null)).toBe(true); - expect(isOnDeckDismissible(pill("Completed"))).toBe(true); - expect(isOnDeckDismissible(pill("Working"))).toBe(false); - expect(isOnDeckDismissible(pill("Pending Approval"))).toBe(false); + it("lets a failed thread be marked done", () => { + // "I saw it, I'm done with it" must be expressible for failures, or the + // Done list can never hold anything that went wrong. + const failed = { ...base, session: { status: "error" } as never }; + expect(isThreadDone(failed, doneMark, { now: NOW })).toBe(true); }); - it("excludeOnDeckThreads drops deck rows from the tree and keeps the rest in order", () => { - const threads = [{ key: "t-1" }, { key: "t-2" }, { key: "t-3" }]; - const getThreadKey = (thread: { key: string }) => thread.key; + it("keeps a thread live without an explicit done mark", () => { + expect(isThreadDone(base, null, { now: NOW })).toBe(false); + expect(isThreadDone(base, { state: "active", at: NOW }, { now: NOW })).toBe(false); + }); + it("ignores an override older than the thread's last activity", () => { + // New work outranks an old word, in both directions: the done thread that + // starts again returns by itself, the reopened one may re-file itself. + const activeAgain = { + ...base, + latestUserMessageAt: "2026-07-28T11:00:00.000Z", + }; expect( - excludeOnDeckThreads({ - threads, - onDeckThreadKeys: new Set(["t-2"]), - getThreadKey, - }), - ).toEqual([{ key: "t-1" }, { key: "t-3" }]); - - expect(excludeOnDeckThreads({ threads, onDeckThreadKeys: new Set(), getThreadKey })).toEqual( - threads, - ); + isThreadDone(activeAgain, { state: "done", at: "2026-07-28T10:00:00.000Z" }, { now: NOW }), + ).toBe(false); + }); + it("files an idle thread under Done on its own", () => { + const staleThread = { + ...base, + latestUserMessageAt: "2026-07-24T12:00:00.000Z", + }; + expect(isThreadDone(staleThread, null, { now: NOW, autoDoneAfterDays: 2 })).toBe(true); + // Fresh activity holds it live under the same rule. expect( - excludeOnDeckThreads({ - threads, - onDeckThreadKeys: new Set(["t-1", "t-2", "t-3"]), - getThreadKey, + isThreadDone({ ...base, latestUserMessageAt: "2026-07-28T09:00:00.000Z" }, null, { + now: NOW, + autoDoneAfterDays: 2, }), - ).toEqual([]); + ).toBe(false); }); - it("countThreadsNeedingUser counts only threads blocked on the user", () => { + it("never auto-files a completion the user has not seen", () => { + // Filing unread work is the sidebar reading your mail for you. Only an + // explicit Done may put an unseen completion in the tail. + const unseen = { + ...base, + latestTurn: { completedAt: "2026-07-24T12:00:00.000Z" } as never, + lastVisitedAt: undefined, + }; + expect(isThreadDone(unseen, null, { now: NOW, autoDoneAfterDays: 2 })).toBe(false); expect( - countThreadsNeedingUser([ - pill("Pending Approval"), - pill("Awaiting Input"), - // Live but unblocked, so the rail badge must ignore these. - pill("Working"), - pill("Starting"), - pill("Plan Ready"), - pill("Completed"), - null, - ]), - ).toBe(2); - expect(countThreadsNeedingUser([])).toBe(0); + isThreadDone(unseen, { state: "done", at: NOW }, { now: NOW, autoDoneAfterDays: 2 }), + ).toBe(true); }); }); -describe("buildProjectHoverSummary", () => { - const thread = (id: string, overrides: Partial = {}) => - ({ - id: ThreadId.make(id), - environmentId: localEnvironmentId, - projectId: ProjectId.make("project-badcode"), - title: id, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2026-07-20T00:00:00.000Z", - archivedAt: null, - pinnedAt: null, - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - ...overrides, - }) as SidebarThreadSummary; - - const summaryFor = (threads: readonly SidebarThreadSummary[]) => - buildProjectHoverSummary({ - name: "badcode", - cwd: "/repo/badcode", - environmentId: localEnvironmentId, - threads, - getLastVisitedAt: () => undefined, - }); - - it("counts only threads the provider is still live on", () => { - const summary = summaryFor([ - thread("waiting", { hasPendingApprovals: true }), - thread("idle"), - thread("also-idle"), - ]); +describe("sortInboxThreads", () => { + it("holds creation order and floats pins, nothing else", () => { + const rows = [ + { id: "old", createdAt: "2026-07-20T00:00:00.000Z", pinnedAt: null }, + { id: "pinned", createdAt: "2026-07-18T00:00:00.000Z", pinnedAt: "2026-07-27T00:00:00.000Z" }, + { id: "new", createdAt: "2026-07-27T00:00:00.000Z", pinnedAt: null }, + ]; - expect(summary.threadCount).toBe(3); - expect(summary.activeCount).toBe(1); - expect(summary.status?.label).toBe("Pending Approval"); + // Activity timestamps are deliberately absent from the input type: the + // list must be un-reorderable by anything but creation and pinning. + expect(sortInboxThreads(rows).map((row) => row.id)).toEqual(["pinned", "new", "old"]); }); +}); - it("reports the most recent activity across the project", () => { - const summary = summaryFor([ - thread("older", { latestUserMessageAt: "2026-07-21T00:00:00.000Z" }), - thread("newest", { latestUserMessageAt: "2026-07-24T00:00:00.000Z" }), - thread("middle", { latestUserMessageAt: "2026-07-22T00:00:00.000Z" }), - ]); +describe("buildProjectScopeOptions", () => { + it("orders by recent activity and carries needs-you counts", () => { + const options = buildProjectScopeOptions({ + projects: [ + { key: "a", label: "alpha" }, + { key: "b", label: "beta" }, + { key: "c", label: "gamma" }, + { key: "d", label: "delta" }, + ], + lastActivityMsByKey: new Map([ + ["a", 400], + ["b", 300], + ["c", 200], + ]), + needsYouCountByKey: new Map([["b", 2]]), + }); - expect(summary.lastActivityAt).toBe("2026-07-24T00:00:00.000Z"); + // "d" has never been touched, so it sorts last rather than dropping out. + expect(options.map((option) => option.key)).toEqual(["a", "b", "c", "d"]); + expect(options[1]?.needsYouCount).toBe(2); + expect(options[0]?.needsYouCount).toBe(0); }); +}); + +describe("windowInboxThreads", () => { + const rows = [ + { id: "a", attention: false }, + { id: "b", attention: false }, + { id: "c", attention: false }, + { id: "d", attention: true }, + { id: "e", attention: false }, + ]; + const hasAttention = (row: { attention: boolean }) => row.attention; + + it("folds quiet rows past the limit but never one that needs you", () => { + // Hiding a pending approval behind "show more" defeats the approval. + const { visible, hiddenCount } = windowInboxThreads({ + rows, + hasAttention, + limit: 2, + expanded: false, + }); - it("describes an empty project without inventing activity", () => { - const summary = summaryFor([]); + expect(visible.map((row) => row.id)).toEqual(["a", "b", "d"]); + expect(hiddenCount).toBe(2); + }); - expect(summary.threadCount).toBe(0); - expect(summary.activeCount).toBe(0); - expect(summary.status).toBeNull(); - expect(summary.lastActivityAt).toBeNull(); + it("shows everything when expanded or when the list fits", () => { + expect(windowInboxThreads({ rows, hasAttention, limit: 2, expanded: true }).hiddenCount).toBe( + 0, + ); + expect( + windowInboxThreads({ rows: rows.slice(0, 2), hasAttention, limit: 2, expanded: false }) + .visible.length, + ).toBe(2); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 744168d5..5a4f4401 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,11 +10,7 @@ import { toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; -import type { EnvironmentId } from "@threadlines/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import type { SidebarThreadSummary, Thread } from "../types"; -import type { OnDeckSyncInput } from "../uiStateStore"; -import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; @@ -40,28 +36,20 @@ export interface ThreadStatusPill { | "Pending Approval" | "Awaiting Input" | "Plan Ready" - | "Background"; + | "Background" + | "Failed"; colorClass: string; dotClass: string; pulse: boolean; } -const THREAD_STATUS_PRIORITY: Record = { - "Pending Approval": 5, - "Awaiting Input": 4, - Working: 3, - Starting: 3, - "Plan Ready": 2, - Background: 2, - Completed: 1, -}; - export const THREAD_STATUS_DOT_CLASSES = { amber: "bg-amber-500 dark:bg-amber-300/90", blue: "bg-primary-graph", cyan: "bg-cyan-500 dark:bg-cyan-300/90", emerald: "bg-emerald-500 dark:bg-emerald-300/90", violet: "bg-violet-500 dark:bg-violet-300/90", + red: "bg-red-500 dark:bg-red-400/90", } as const; type ThreadStatusInput = Pick< @@ -161,7 +149,9 @@ export function useThreadJumpHintVisibility(): { }; } -export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { +export function hasUnseenCompletion( + thread: Pick & { lastVisitedAt?: string | undefined }, +): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; @@ -258,17 +248,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, @@ -312,37 +291,6 @@ export function isContextMenuPointerDown(input: { return input.isMac && input.button === 0 && input.ctrlKey; } -export function resolveThreadRowClassName(input: { - isActive: boolean; - isSelected: boolean; -}): string { - const baseClassName = - "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-ring focus-visible:ring-inset"; - - if (input.isSelected && input.isActive) { - return cn( - baseClassName, - "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", - ); - } - - if (input.isSelected) { - return cn( - baseClassName, - "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", - ); - } - - if (input.isActive) { - return cn( - baseClassName, - "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", - ); - } - - return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); -} - export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { @@ -386,6 +334,17 @@ export function resolveThreadStatusPill(input: { }; } + if (thread.session?.status === "error") { + // Failed threads previously showed nothing -- indistinguishable from + // healthy idle ones, which is the worst place for a failure to hide. + return { + label: "Failed", + colorClass: "text-red-600 dark:text-red-400/90", + dotClass: THREAD_STATUS_DOT_CLASSES.red, + pulse: false, + }; + } + const hasPlanReadyPrompt = !thread.hasPendingUserInput && thread.interactionMode === "plan" && @@ -424,163 +383,35 @@ export function resolveThreadStatusPill(input: { return null; } -/** Statuses that mean the provider is working or waiting on the user right now. */ -const ON_DECK_LIVE_STATUSES: ReadonlySet = new Set([ - "Pending Approval", - "Awaiting Input", - "Working", - "Starting", - "Plan Ready", - "Background", -]); - -/** Maps a thread's status pill and pin state onto the deck's entry signals. */ -export function buildOnDeckSyncInput(input: { - threadKey: string; - pinnedAt: string | null; - status: ThreadStatusPill | null; -}): OnDeckSyncInput { - return { - key: input.threadKey, - pinned: input.pinnedAt !== null, - live: input.status !== null && ON_DECK_LIVE_STATUSES.has(input.status.label), - unseen: input.status?.label === "Completed", - }; -} - -/** Only settled rows offer the dismiss affordance; live work can't be waved away. */ -export function isOnDeckDismissible(status: ThreadStatusPill | null): boolean { - return status === null || !ON_DECK_LIVE_STATUSES.has(status.label); -} - -/** - * Drops threads that already have a deck row, so a thread on deck appears once - * in the sidebar rather than twice. Must be applied *before* the preview window - * is computed: a deck thread should not consume a preview slot and then vanish, - * which would leave "Show more" counts disagreeing with the rendered rows. - */ -export function excludeOnDeckThreads(input: { - threads: readonly TThread[]; - onDeckThreadKeys: ReadonlySet; - getThreadKey: (thread: TThread) => string; -}): TThread[] { - if (input.onDeckThreadKeys.size === 0) { - return [...input.threads]; - } - return input.threads.filter((thread) => !input.onDeckThreadKeys.has(input.getThreadKey(thread))); -} - /** * Statuses where the agent has stopped and cannot continue without the user. - * Narrower than {@link ON_DECK_LIVE_STATUSES}: a working thread is live but - * needs nothing, and a ready plan is an invitation rather than a block. + * Narrower than "busy": a working thread needs nothing, and a ready plan is an + * invitation rather than a block. */ const NEEDS_USER_STATUSES: ReadonlySet = new Set([ "Pending Approval", "Awaiting Input", + "Failed", ]); -/** True while the provider is working on the thread or waiting on the user. */ -export function isLiveThreadStatus(status: ThreadStatusPill | null): boolean { - return status !== null && ON_DECK_LIVE_STATUSES.has(status.label); -} - /** True when the thread is blocked waiting on the user. */ export function isNeedsUserStatus(status: ThreadStatusPill | null): boolean { return status !== null && NEEDS_USER_STATUSES.has(status.label); } /** - * How many threads are blocked on the user. The collapsed sidebar rail drops - * per-thread titles, so this aggregate is the only attention signal left. + * The single word a row spends on status. Only states that stop the agent and + * hand the thread back earn one; in-flight work reads as "working" with its + * elapsed time, and everything else rests with a timestamp instead. */ -export function countThreadsNeedingUser(statuses: ReadonlyArray): number { - let count = 0; - for (const status of statuses) { - if (isNeedsUserStatus(status)) count += 1; - } - return count; -} - -export function resolveProjectStatusIndicator( - statuses: ReadonlyArray, -): ThreadStatusPill | null { - let highestPriorityStatus: ThreadStatusPill | null = null; - - for (const status of statuses) { - if (status === null) continue; - if ( - highestPriorityStatus === null || - THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] - ) { - highestPriorityStatus = status; - } - } - - return highestPriorityStatus; -} +const INBOX_STATUS_WORDS: Partial> = { + "Pending Approval": "approval", + "Awaiting Input": "input", + Failed: "failed", +}; -export interface SidebarThreadWindow { - visibleThreads: T[]; - hiddenThreads: T[]; - /** How many threads the next "Show more" click reveals (0 = no row). */ - nextRevealCount: number; - /** Total thread count for the "Search all N threads" row (null = no row). */ - searchHandoffThreadCount: number | null; - /** True once the user revealed beyond the preview (shows "Show less"). */ - isRevealed: boolean; -} - -export function getSidebarThreadWindow(input: { - threads: readonly T[]; - getThreadKey: (thread: T) => string; - activeThreadKey: string | null; - previewLimit: number; - revealedCount: number; -}): SidebarThreadWindow { - const { activeThreadKey, getThreadKey, previewLimit, revealedCount, threads } = input; - const isRevealed = revealedCount > 0; - const windowSize = previewLimit + Math.max(0, revealedCount); - - if (threads.length <= windowSize) { - return { - visibleThreads: [...threads], - hiddenThreads: [], - nextRevealCount: 0, - searchHandoffThreadCount: null, - isRevealed, - }; - } - - const windowThreads = threads.slice(0, windowSize); - // The active thread always stays visible, even when it falls in the hidden - // tail, so the current-thread marker never disappears from the sidebar. - const visibleThreadKeys = new Set(windowThreads.map(getThreadKey)); - const includeActiveThread = - activeThreadKey !== null && - !visibleThreadKeys.has(activeThreadKey) && - threads.some((thread) => getThreadKey(thread) === activeThreadKey); - if (includeActiveThread) { - visibleThreadKeys.add(activeThreadKey); - } - - const visibleThreads = includeActiveThread - ? threads.filter((thread) => visibleThreadKeys.has(getThreadKey(thread))) - : windowThreads; - const hiddenThreads = threads.filter((thread) => !visibleThreadKeys.has(getThreadKey(thread))); - - // Reveal in preview-sized chunks until the tail is exhausted. Search remains - // available after the first expansion as a fast path for huge projects. - const revealStepSize = Math.max(0, previewLimit); - const nextRevealCount = Math.min(hiddenThreads.length, revealStepSize); - - return { - visibleThreads, - hiddenThreads, - nextRevealCount, - searchHandoffThreadCount: isRevealed && hiddenThreads.length > 0 ? threads.length : null, - isRevealed, - }; +export function inboxStatusWord(status: ThreadStatusPill | null): string | null { + return status === null ? null : (INBOX_STATUS_WORDS[status.label] ?? null); } export function getFallbackThreadIdAfterDelete< @@ -699,50 +530,252 @@ export function sortScopedProjectsByActivity< }); } -export interface ProjectHoverSummary { - name: string; - cwd: string; - environmentId: EnvironmentId; - status: ThreadStatusPill | null; - threadCount: number; - activeCount: number; - lastActivityAt: string | null; +// ── Inbox lifecycle ────────────────────────────────────────────────── +// +// The sidebar is an inbox: one live list, a Done tail. "Done" is a client-side +// overlay in v1 -- an override the user sets, resolved against the thread's +// actual state. The rules below owe their shape to studying how the settle +// lifecycle goes wrong: the invariant that matters is that no override may +// hide work that is moving or blocked on the user. + +/** + * A queued turn start counts as pending work for at most this long. + * + * Between sending a message and a session adopting it, the work is invisible + * to every status check: no turn, no running session. Without a bound, a + * thread whose start failed would be permanently un-doneable; without the + * guard, marking Done in that gap would hide a message that is about to run. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +type InboxLifecycleInput = Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" +>; + +/** + * A user message no turn has picked up yet: strictly newer than every + * timestamp on the latest turn, and within the adoption grace window. Bounded + * on both sides because message timestamps originate on whichever device sent + * them -- a clock ahead of this one would otherwise hold the queued state for + * the whole skew. + */ +export function hasQueuedTurnStart( + thread: InboxLifecycleInput, + options: { readonly now: string }, +): boolean { + if (thread.latestUserMessageAt == null) return false; + // A failed start is already visible as the Failed pill; holding the queued + // state too would make the thread un-doneable while it screams red. + if (thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = thread.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); } /** - * Builds a project's hover summary from its threads. Shared so the expanded row - * and the collapsed rail glyph describe a project identically. + * Whether Done is allowed right now. Work that is moving, blocked on the + * user, or queued cannot be waved away: hiding a pending approval defeats + * the approval, and hiding a running turn hides where its result will land. + * A failed thread CAN be marked done -- that is "I saw it, I'm done with it". */ -export function buildProjectHoverSummary(input: { - name: string; - cwd: string; - environmentId: EnvironmentId; - threads: readonly SidebarThreadSummary[]; - getLastVisitedAt: (threadKey: string) => string | null | undefined; -}): ProjectHoverSummary { - const getThreadKey = (thread: SidebarThreadSummary) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const statuses = input.threads.map((thread) => { - const lastVisitedAt = input.getLastVisitedAt(getThreadKey(thread)); - return resolveThreadStatusPill({ - thread: { - ...thread, - ...(lastVisitedAt !== undefined && lastVisitedAt !== null ? { lastVisitedAt } : {}), - }, - }); +export function canMarkThreadDone( + thread: InboxLifecycleInput, + options: { readonly now: string }, +): boolean { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + // The same in-flight resolution the status pill uses, so "can't be marked + // done" and "shows as working" can never disagree about what running means. + if (getThreadInFlightStatus(thread) !== null) return false; + if (hasQueuedTurnStart(thread, options)) return false; + return true; +} + +/** + * The user's explicit word on a thread's lifecycle, stamped when given. + * "active" exists so a reopened thread stays reopened once auto-done rules + * arrive; today it simply reads as not-done. + */ +export interface ThreadDoneOverride { + readonly state: "done" | "active"; + readonly at: string; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; + +/** + * How long a thread sits idle before it files itself under Done. + * + * Without this the live list is every thread ever opened: one-off questions + * from three weeks ago standing shoulder to shoulder with this morning's + * work, which is the sidebar this design exists to replace. Two days keeps + * yesterday's threads at hand and lets the weekend clear the desk. + */ +export const INBOX_AUTO_DONE_AFTER_DAYS = 2; + +/** + * Where a thread lives. Resolution order, each layer outranking the next: + * + * 1. Blockers. Moving or blocked-on-you work is live, whatever anyone said. + * 2. The user's override -- but only while it is FRESHER than the thread's + * last activity. New work outranks an old word in both directions: a done + * thread that starts again pulls itself back without being un-marked, and + * a reopened thread that goes quiet again is allowed to re-file itself. + * 3. Auto-done on idle, unless the thread holds a completion the user has + * not seen -- unread work is the inbox's reason to exist, and filing it + * unread would be the sidebar reading your mail for you. + */ +export function isThreadDone( + thread: InboxLifecycleInput & DoneSortInput & { readonly lastVisitedAt?: string | undefined }, + override: ThreadDoneOverride | null | undefined, + options: { readonly now: string; readonly autoDoneAfterDays?: number | null }, +): boolean { + if (!canMarkThreadDone(thread, options)) return false; + const lastActivityAt = resolveDoneTimestamp(thread, null); + const overrideIsStale = + override != null && + lastActivityAt !== null && + Date.parse(lastActivityAt) > Date.parse(override.at); + if (override != null && !overrideIsStale) { + return override.state === "done"; + } + if (options.autoDoneAfterDays == null) return false; + if (hasUnseenCompletion(thread)) return false; + if (lastActivityAt === null) return false; + return Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoDoneAfterDays * DAY_MS; +} + +/** + * The live list holds still. Creation order, newest first; activity never + * reorders it, so a row keeps its place from open until it is marked done + * and the thing you were reaching for never jumps. Pins are the one + * exception, and they move only when you pin -- your action, your motion. + */ +export function sortInboxThreads< + T extends { readonly id: string; readonly createdAt: string; readonly pinnedAt: string | null }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted((left, right) => { + if ((left.pinnedAt !== null) !== (right.pinnedAt !== null)) { + return left.pinnedAt !== null ? -1 : 1; + } + if (left.pinnedAt !== null && right.pinnedAt !== null) { + const byPin = + (toSortableTimestamp(right.pinnedAt) ?? 0) - (toSortableTimestamp(left.pinnedAt) ?? 0); + if (byPin !== 0) return byPin; + } + const byCreated = + (toSortableTimestamp(right.createdAt) ?? 0) - (toSortableTimestamp(left.createdAt) ?? 0); + return byCreated !== 0 ? byCreated : left.id.localeCompare(right.id); }); - const lastActivityAt = input.threads.reduce((latest, thread) => { - const at = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; - return latest === null || at > latest ? at : latest; - }, null); +} - return { - name: input.name, - cwd: input.cwd, - environmentId: input.environmentId, - status: resolveProjectStatusIndicator(statuses), - threadCount: input.threads.length, - activeCount: statuses.filter(isLiveThreadStatus).length, - lastActivityAt, +type DoneSortInput = Pick< + SidebarThreadSummary, + "latestUserMessageAt" | "latestTurn" | "updatedAt" | "createdAt" +>; + +/** + * Done rows are history, so they order by when the work ended: the explicit + * mark when there is one, else the thread's last activity. Label and order + * both come from here so they can never disagree. + */ +export function resolveDoneTimestamp( + thread: DoneSortInput, + override: ThreadDoneOverride | null | undefined, +): string | null { + if (override?.state === "done" && !Number.isNaN(Date.parse(override.at))) { + return override.at; + } + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const candidate of [ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed) && parsed > latestMs) { + latest = candidate; + latestMs = parsed; + } + } + return latest ?? thread.updatedAt ?? thread.createdAt; +} + +export function sortDoneThreads( + threads: readonly T[], + overrideFor: (thread: T) => ThreadDoneOverride | null | undefined, +): T[] { + const timestampMs = (thread: T) => { + const timestamp = resolveDoneTimestamp(thread, overrideFor(thread)); + return timestamp === null ? 0 : (toSortableTimestamp(timestamp) ?? 0); }; + return [...threads].toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); +} + +// ── Project scope ──────────────────────────────────────────────────── + +export interface ProjectScopeOption { + readonly key: string; + readonly label: string; + /** Threads in this project blocked on the user, shown beside its name. */ + readonly needsYouCount: number; +} + +/** + * The scope menu's projects, most-recently-active first. + * + * A menu has room for all of them, so ordering is the whole job: whatever you + * touched last sits under the cursor when the menu opens. + */ +export function buildProjectScopeOptions(input: { + readonly projects: ReadonlyArray<{ readonly key: string; readonly label: string }>; + readonly lastActivityMsByKey: ReadonlyMap; + readonly needsYouCountByKey: ReadonlyMap; +}): ProjectScopeOption[] { + return [...input.projects] + .toSorted( + (left, right) => + (input.lastActivityMsByKey.get(right.key) ?? 0) - + (input.lastActivityMsByKey.get(left.key) ?? 0) || left.label.localeCompare(right.label), + ) + .map((project) => ({ + key: project.key, + label: project.label, + needsYouCount: input.needsYouCountByKey.get(project.key) ?? 0, + })); +} + +/** + * Which live rows show while the list is folded. + * + * The live list keeps every thread, but only the first few quiet ones are + * worth screen space at rest -- a dev session mints a dozen threads a day and + * a wall of two-line rows buries the ones that matter. Rows with a status are + * exempt from the fold entirely: hiding a pending approval behind "show more" + * defeats the approval, and hiding running work hides where its result will + * land. Order is never changed, only membership. + */ +export function windowInboxThreads(input: { + readonly rows: readonly T[]; + readonly hasAttention: (row: T) => boolean; + readonly limit: number; + readonly expanded: boolean; +}): { readonly visible: T[]; readonly hiddenCount: number } { + if (input.expanded || input.rows.length <= input.limit) { + return { visible: [...input.rows], hiddenCount: 0 }; + } + const visible = input.rows.filter((row, index) => index < input.limit || input.hasAttention(row)); + return { visible, hiddenCount: input.rows.length - visible.length }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aa76c24b..67cec70e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,52 +1,18 @@ import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - ChevronUpIcon, - CloudIcon, - FolderPlusIcon, + ChevronDownIcon, + ChevronsUpIcon, + MessageCirclePlusIcon, MessagesSquareIcon, - PinIcon, - PinOffIcon, SearchIcon, SettingsIcon, SquarePenIcon, - TerminalIcon, } from "lucide-react"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - resolveThreadPr, - terminalStatusFromRunningIds, - ThreadRailNode, - ThreadStatusLabel, -} from "./ThreadStatusIndicators"; import { ThreadlinesGlyph } from "./Icons"; -import { LiveNode, SectionLabel } from "./ui/threadline"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; -import { - type ContextMenuItem, - ProjectId, + type ScopedProjectRef, type ScopedThreadRef, - type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, } from "@threadlines/contracts"; @@ -58,25 +24,15 @@ import { scopeThreadRef, } from "@threadlines/client-runtime"; import { Link, 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 "@threadlines/contracts/settings"; -import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { usePrimaryEnvironmentId } from "../environments/primary"; -import { resolveGeneralChatsProjectRef } from "../lib/generalChats"; import { isElectron } from "../env"; import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform, newCommandId } from "../lib/utils"; +import { toSortableTimestamp } from "../lib/threadSort"; import { selectProjectByRef, - selectGeneralChatsProjectAcrossEnvironments, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore, @@ -93,12 +49,11 @@ import { } from "../keybindings"; import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; -import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useRelativeTimeTick } from "../hooks/useRelativeTimeTick"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; - import { useThreadActions } from "../hooks/useThreadActions"; import { buildThreadRouteParams, @@ -106,37 +61,8 @@ import { resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { Kbd } from "./ui/kbd"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { - Menu, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - 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, @@ -146,9 +72,6 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, useSidebar, @@ -156,50 +79,36 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { - buildOnDeckSyncInput, - buildProjectHoverSummary, - excludeOnDeckThreads, + buildProjectScopeOptions, + canMarkThreadDone, getSidebarThreadIdsToPrewarm, - getSidebarThreadWindow, - isOnDeckDismissible, + isNeedsUserStatus, + INBOX_AUTO_DONE_AFTER_DAYS, + isThreadDone, resolveAdjacentThreadId, - isContextMenuPointerDown, - resolveProjectStatusIndicator, + resolveDoneTimestamp, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveThreadRowClassName, resolveThreadStatusPill, - orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, + sortDoneThreads, + sortInboxThreads, + windowInboxThreads, useThreadJumpHintVisibility, - ThreadStatusPill, + type ThreadStatusPill, } from "./Sidebar.logic"; -import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection"; -import { DeckRail, type DeckRailProject } from "./sidebar/DeckRail"; -import { ProjectHoverCard } from "./sidebar/ProjectHoverCard"; +import { InboxDoneRow, InboxThreadRow } from "./sidebar/InboxRows"; +import { ProjectScopeMenu } from "./sidebar/ProjectScopeMenu"; import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; -import { - DESTINATION_ICONS, - DestinationBand, - type SidebarDestination, -} from "./sidebar/DestinationBand"; -import { startNewGeneralChatThread, startNewThreadFromContext } from "../lib/chatThreadActions"; -import { sortThreads } from "../lib/threadSort"; +import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; +import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { SidebarVersionTag } from "./sidebar/SidebarVersionTag"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveElectronSidebarWordmarkLayout } from "../desktopChrome"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; +import { derivePhysicalProjectKey, selectProjectGroupingSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -208,57 +117,33 @@ import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; -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", -}; - -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.name; - } - - return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; -} +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; -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."; - } +const EMPTY_THREAD_JUMP_LABELS = new Map(); +/** How many quiet live rows rest unfolded; rows needing you are never folded. */ +const LIVE_PREVIEW_COUNT = 6; +/** Each click on the reveal row uncovers this many more. */ +const LIVE_REVEAL_STEP = 5; +/** The Done tail opens short and reveals in bigger steps: it is history. */ +const DONE_PREVIEW_COUNT = 10; +const DONE_REVEAL_STEP = 20; +// The queued-turn grace window is measured in minutes, so a coarse clock is +// enough to keep "can this be marked done" honest without re-rendering often. +const INBOX_CLOCK_INTERVAL_MS = 30_000; + +interface InboxEntry { + thread: SidebarThreadSummary; + threadKey: string; + status: ThreadStatusPill | null; + projectKey: string; + projectLabel: string | null; + isDone: boolean; + canMarkDone: boolean; + doneAt: string | null; } function buildThreadJumpLabelMap(input: { @@ -291,844 +176,226 @@ function buildThreadJumpLabelMap(input: { 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; - 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; - attemptTogglePinThread: (threadRef: ScopedThreadRef, shouldPin: boolean) => Promise; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} - -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { - orderedProjectThreadKeys, - isActive, - jumpLabel, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptTogglePinThread, - attemptArchiveThread, - openPrLink, - thread, - } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "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 threadProjectCwd = useStore( - useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, - [thread.environmentId, thread.projectId], - ), - ); - const gitCwd = resolveThreadWorkingCwd({ - projectCwd: threadProjectCwd ?? props.projectCwd, - worktreePath: thread.worktreePath, - effectiveCwd: thread.effectiveCwd, - }); - const gitStatus = useGitStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); - const isHighlighted = isActive || isSelected; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const archiveUnavailableReason = - thread.session?.status === "connecting" || thread.session?.orchestrationStatus === "starting" - ? "connecting" - : isThreadRunning - ? "running" - : null; - const isThreadArchiveDisabled = archiveUnavailableReason !== null; - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadArchiveDisabled; - const isPinned = thread.pinnedAt !== null; - // On phone layouts the pin/archive cluster is always visible (absolutely - // positioned at the row's right edge), so the timestamp stays visible and - // shifts left of the cluster instead of hiding; the hover/focus fade-out - // only applies at sm+ where the cluster overlays the timestamp's spot. - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : "pointer-events-none transition-opacity duration-150 max-sm:mr-15 sm:group-hover/menu-sub-item:opacity-0 sm:group-focus-within/menu-sub-item:opacity-0"; - 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 handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], +/** Section voice for the inbox: mono, quiet, with the count on the right. */ +/** + * Section voice for the inbox, and the list's only structural rule: with rows + * separated by spacing rather than borders, the hairline belongs to the + * boundary between sections. + */ +/** + * A section's name, then a hairline running out to the edge. + * + * The rule doubles as the divider between sections, so the boundary and the + * label are one gesture rather than two stacked ones. Sentence case: these are + * names for parts of a list, not shouted headings. + */ +/** + * A section's name, then a hairline running out to the edge, then the state + * chevron -- and the whole row is the control. A header that collapses only + * from its glyph makes the user aim at a 14px target for a row-sized idea. + * + * Sticky with an opaque surface so rows slide under it rather than through it + * while the list scrolls. + */ +function InboxSectionHeader({ + label, + count, + collapsed, + onToggleCollapsed, + testId, +}: { + label: string; + count: number; + collapsed: boolean; + onToggleCollapsed: () => void; + testId?: string; +}) { + return ( + ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } +} - if (hasSelection) { - clearSelection(); - } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }); - }, - [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]); - 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 handleTogglePinClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (event.detail > 0) { - event.currentTarget.blur(); - } - void attemptTogglePinThread(threadRef, !isPinned); - }, - [attemptTogglePinThread, isPinned, threadRef], +const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const electronWordmarkLayout = resolveElectronSidebarWordmarkLayout( + typeof navigator === "undefined" ? "" : navigator.platform, ); - const rowButtonRender = useMemo(() =>
, []); - - return ( - - {/* The rail node lives in the row (a sibling of the button, which clips its - own overflow) so it can sit centered on the project line: every thread - contributes one node, turning the rail into a continuous threadline. */} - - -
- {prStatus && ( - - - - - } - /> - {prStatus.tooltip} - - )} - {isPinned && ( - - - - - } - /> - Pinned - - )} - {renamingThreadKey === threadKey ? ( - - ) : ( - - - {thread.title} - - } - /> - - {thread.title} - - - )} -
-
- {terminalStatus && ( - + + + - - - )} -
- {isConfirmingArchive ? ( - - ) : ( -
- - - {isPinned ? ( - - ) : ( - - )} - - } - /> - {isPinned ? "Unpin" : "Pin"} - - {!isThreadArchiveDisabled && appSettingsConfirmThreadArchive ? ( - - ) : !isThreadArchiveDisabled ? ( - - - - - } - /> - Archive - - ) : ( - - )} -
- )} - - - {isRemoteThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - )} - - )} - +
- - + + +
); -}); -interface SidebarProjectThreadListProps { - projectKey: string; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - nextRevealCount: number; - searchHandoffThreadCount: number | null; - isThreadListRevealed: boolean; - onSearchAllThreads: () => void; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (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; - attemptTogglePinThread: (threadRef: ScopedThreadRef, shouldPin: boolean) => Promise; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string, additionalCount: number) => void; - collapseThreadListForProject: (projectKey: string) => void; -} + return isElectron ? ( + + {electronWordmarkLayout.spacerClassName ? ( + - - - - - { - if (!open) { - closeProjectRenameDialog(); - } - }} - > - - - Rename project - - {projectRenameTarget - ? `Update the title for ${projectRenameTarget.cwd}.` - : "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.cwd} 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 ( - - - - ); -}); - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; - -function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - projectGroupingMode, - threadPreviewCount, - onProjectSortOrderChange, - onThreadSortOrderChange, - onProjectGroupingModeChange, - onThreadPreviewCountChange, -}: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - projectGroupingMode: SidebarProjectGroupingMode; - threadPreviewCount: SidebarThreadPreviewCount; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => 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(); - }} - /> - - - -
-
- - -
- Group projects -
- { - if (value === "repository" || value === "repository_path" || value === "separate") { - onProjectGroupingModeChange(value); + const createThreadForProjectRef = useCallback( + (projectRef: ScopedProjectRef) => { + const currentRouteParams = + router.state.matches[router.state.matches.length - 1]?.params ?? {}; + const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); + const currentActiveThread = + currentRouteTarget?.kind === "server" + ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null) + : null; + const draftStore = useComposerDraftStore.getState(); + const currentActiveDraftThread = + currentRouteTarget?.kind === "server" + ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) + : currentRouteTarget?.kind === "draft" + ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) + : null; + const seedContext = resolveSidebarNewThreadSeedContext({ + projectId: projectRef.projectId, + defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: defaultThreadEnvMode }), + activeThread: + currentActiveThread && currentActiveThread.projectId === projectRef.projectId + ? { + projectId: currentActiveThread.projectId, + branch: currentActiveThread.branch, + worktreePath: currentActiveThread.worktreePath, } - }} - > - {( - Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< - [SidebarProjectGroupingMode, string] - > - ).map(([value, label]) => ( - - {label} - - ))} - -
-
-
- ); -} - -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 })} -
  • - ); -} - -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, - collapsed = false, -}: { - isElectron: boolean; - collapsed?: boolean; -}) { - const electronWordmarkLayout = resolveElectronSidebarWordmarkLayout( - typeof navigator === "undefined" ? "" : navigator.platform, - ); - const wordmark = ( -
    - - - - -
    - ); - - // The rail is too narrow for the wordmark, and its top-left is already the - // collapse trigger's fixed home — so the collapsed header is a bare spacer - // that keeps the titlebar height (and, on Electron, the drag region). - if (collapsed) { - return ( - - ); - } - - return isElectron ? ( - - {electronWordmarkLayout.spacerClassName ? ( -