diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 084cf0956be..65424b830a2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,9 +20,12 @@ import { resolveThreadRowClassName, resolveSidebarV2Status, resolveThreadStatusPill, + resolveWorkingStartedAt, + formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, + sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -209,8 +212,10 @@ function makeLatestTurn(overrides?: { state: "completed", assistantMessageId: null, requestedAt: "2026-03-09T10:00:00.000Z", - startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", - completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", + startedAt: + overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z", + completedAt: + overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z", }; } @@ -783,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("sortSettledThreadsForSidebarV2", () => { + const settled = (input: { + id: string; + settledAt?: string | null; + latestUserMessageAt?: string | null; + latestTurn?: OrchestrationLatestTurn | null; + updatedAt?: string; + }) => ({ + id: input.id, + settledAt: input.settledAt ?? null, + latestUserMessageAt: input.latestUserMessageAt ?? null, + latestTurn: input.latestTurn ?? null, + updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", + }); + + it("orders by settle time, most recently settled first", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ + id: "settled-first", + settledAt: "2026-03-09T10:00:00.000Z", + // Created/active later than the other thread: settle time must win. + latestUserMessageAt: "2026-03-09T09:59:00.000Z", + }), + settled({ + id: "settled-last", + settledAt: "2026-03-09T12:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); + }); + + it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); + }); + + it("counts a turn completion as activity for auto-settled threads", () => { + // The message came in before the other thread's, but its turn finished + // after: completion time is the real "work ended" moment. + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), + settled({ + id: "completed-later", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); + }); + + it("breaks timestamp ties by id so the order is stable", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + +describe("resolveWorkingStartedAt", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:02:00.000Z", + }; + + it("uses the running turn's start time", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("uses the request time while a turn awaits adoption", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ startedAt: null, completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("falls back to the session transition when the latest turn already completed", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn(), + session, + }), + ).toBe("2026-03-09T10:02:00.000Z"); + }); + + it("skips a malformed startedAt instead of returning it", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ startedAt: "not-a-date", completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("returns null with neither a running turn nor a session", () => { + expect(resolveWorkingStartedAt({ latestTurn: null, session: null })).toBeNull(); + }); +}); + +describe("formatWorkingDurationLabel", () => { + it("formats seconds, minutes, and hours", () => { + expect(formatWorkingDurationLabel(0)).toBe("0s"); + expect(formatWorkingDurationLabel(42_000)).toBe("42s"); + expect(formatWorkingDurationLabel(5 * 60_000)).toBe("5m"); + expect(formatWorkingDurationLabel(90 * 60_000)).toBe("1h 30m"); + }); + + it("clamps negative and non-finite elapsed values to zero", () => { + expect(formatWorkingDurationLabel(-5_000)).toBe("0s"); + expect(formatWorkingDurationLabel(Number.NaN)).toBe("0s"); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 04124feb58a..e7082ca8ada 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -502,6 +502,18 @@ export function firstValidTimestampMs( return 0; } +/** String twin of firstValidTimestampMs for callers that need the ISO string + (display labels, tick anchors) rather than epoch ms. */ +export function firstValidTimestamp( + ...candidates: ReadonlyArray +): string | null { + for (const candidate of candidates) { + if (candidate == null) continue; + if (!Number.isNaN(Date.parse(candidate))) return candidate; + } + return null; +} + // v2 sort: static creation order, newest thread on top. Activity NEVER // reorders the list — a row holds its position from open until settled, so // the screen only moves at lifecycle transitions. Status (including pending @@ -516,6 +528,73 @@ export function sortThreadsForSidebarV2< ); } +type SettledTimestampInput = Pick< + SidebarThreadSummary, + "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" +>; + +/** The timestamp a settled row sorts and labels by: settledAt when stamped + (explicit settles), otherwise last activity — the same candidates + threadLastActivityAt feeds the auto-settle window (user message plus all + latestTurn stamps), so a thread whose last activity was a turn completion + doesn't sort by an older message time. updatedAt is the final net. */ +export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { + const settledAt = firstValidTimestamp(thread.settledAt); + if (settledAt !== null) return settledAt; + 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 ?? firstValidTimestamp(thread.updatedAt); +} + +// Settled rows are history, so they order by when the work ENDED, not when +// the thread was created or last touched. +export function sortSettledThreadsForSidebarV2< + T extends SettledTimestampInput & { readonly id: string }, +>(threads: readonly T[]): T[] { + const timestampMs = (thread: T) => { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? 0 : Date.parse(timestamp); + }; + return [...threads].toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); +} + +/** The timestamp a working thread's elapsed label counts from: the running + turn's start (request time until adoption), falling back to the session's + last transition when the turn projection lags behind. Malformed + timestamps fall through to the next candidate, not just missing ones. */ +export function resolveWorkingStartedAt( + thread: Pick, +): string | null { + const turn = thread.latestTurn; + if (turn && turn.completedAt === null) { + return firstValidTimestamp(turn.startedAt, turn.requestedAt, thread.session?.updatedAt); + } + return firstValidTimestamp(thread.session?.updatedAt); +} + +export function formatWorkingDurationLabel(elapsedMs: number): string { + const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1563c3d0104..361c3460bfd 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -91,14 +91,17 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { - firstValidTimestampMs, + formatWorkingDurationLabel, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, + resolveSettledTimestamp, resolveSidebarV2Status, + resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; @@ -149,6 +152,44 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } +// Settled rows read "how long ago did this wrap up", matching their sort +// key: both go through resolveSettledTimestamp so label and order can't +// disagree. +function settledTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} + +// Floats at the row's right edge, vertically centered, while the jump +// modifier is held. An overlay pill instead of an inline slot: the hint +// must neither displace the status/time label (holding ⌘ used to blank +// out "Working") nor shift any layout when it appears. pointer-events-none +// so it never swallows clicks meant for the settle/un-settle buttons it +// can overlap. +function JumpHintBadge(props: { label: string }) { + return ( + + {props.label} + + ); +} + +// Self-ticking so only this span re-renders each second, not the whole row. +function WorkingDuration(props: { startedAt: string | null }) { + const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; + const [, setTick] = useState(0); + useEffect(() => { + if (Number.isNaN(startedMs)) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); + return () => window.clearInterval(id); + }, [startedMs]); + if (Number.isNaN(startedMs)) return null; + return {formatWorkingDurationLabel(Date.now() - startedMs)}; +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -292,7 +333,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); - const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + // In-flight rows (working, or waiting on approval/input) fade as a whole: + // there is nothing for the user to do yet, so prominence is reserved for + // rows that need a human — done (unread), read-but-unsettled, and failed. + // The status label keeps its hue, so waiting rows stay findable. In-flight + // rows recede the same as read-ready ones (inbox-zero: working threads + // aren't your problem yet) — only the colored status label stands out. + const isInFlight = status === "working" || status === "approval" || status === "input"; + const shouldRecede = + (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -478,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + isInFlight && + !props.isActive && + !isSelected && + "opacity-70 transition-opacity hover:opacity-100", ); const title = isRenaming ? ( @@ -503,10 +556,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "truncate", isUnread ? "text-foreground" - : status !== "ready" - ? "text-foreground/95" - : shouldRecede - ? "text-muted-foreground/80" + : shouldRecede + ? "text-muted-foreground/80" + : status === "failed" + ? "text-foreground/95" : "text-foreground/90", ) : cn( @@ -587,10 +640,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { - {props.jumpLabel ?? - compactSidebarTimeLabel( - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), - )} + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} {!props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -613,6 +665,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} + {props.jumpLabel ? : null} {detailsTooltip} @@ -650,7 +703,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className="size-4 shrink-0" /> {props.projectTitle ? ( - + {props.projectTitle} ) : ( @@ -658,11 +716,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( + {topStatus ? ( ) : null} - {topStatus.label} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} ) : ( threadTimeLabel(thread) @@ -727,6 +790,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { + {props.jumpLabel ? : null} {detailsTooltip} @@ -1137,11 +1201,7 @@ export default function SidebarV2() { } return { activeThreads: sortThreadsForSidebarV2(active), - settledThreads: settled.toSorted( - (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), - ), + settledThreads: sortSettledThreadsForSidebarV2(settled), }; }, [ autoSettleAfterDays,