| author | |
| committer | |
| log | ddd5a46f3942df302f8a13310cdfa47838b0ca81 |
| tree | ddc761405f680eda89400977fb291b29459aec80 |
| parent | 88c69ffff6ca3a025eec6a2ea597d4048e851266 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>3 files changed, 302 insertions(+), 23 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+142-2| ... | @@ -20,9 +20,12 @@ import { | ... | @@ -20,9 +20,12 @@ import { |
| 20 | resolveThreadRowClassName, | 20 | resolveThreadRowClassName, |
| 21 | resolveSidebarV2Status, | 21 | resolveSidebarV2Status, |
| 22 | resolveThreadStatusPill, | 22 | resolveThreadStatusPill, |
| 23 | resolveWorkingStartedAt, | ||
| 24 | formatWorkingDurationLabel, | ||
| 23 | shouldNavigateAfterProjectRemoval, | 25 | shouldNavigateAfterProjectRemoval, |
| 24 | shouldClearThreadSelectionOnMouseDown, | 26 | shouldClearThreadSelectionOnMouseDown, |
| 25 | sortLogicalProjectsForSidebar, | 27 | sortLogicalProjectsForSidebar, |
| 28 | sortSettledThreadsForSidebarV2, | ||
| 26 | sortThreadsForSidebarV2, | 29 | sortThreadsForSidebarV2, |
| 27 | sortProjectsForSidebar, | 30 | sortProjectsForSidebar, |
| 28 | sortScopedProjectsForSidebar, | 31 | sortScopedProjectsForSidebar, |
| ... | @@ -209,8 +212,10 @@ function makeLatestTurn(overrides?: { | ... | @@ -209,8 +212,10 @@ function makeLatestTurn(overrides?: { |
| 209 | state: "completed", | 212 | state: "completed", |
| 210 | assistantMessageId: null, | 213 | assistantMessageId: null, |
| 211 | requestedAt: "2026-03-09T10:00:00.000Z", | 214 | requestedAt: "2026-03-09T10:00:00.000Z", |
| 212 | startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", | 215 | startedAt: |
| 213 | completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", | 216 | overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z", |
| 217 | completedAt: | ||
| 218 | overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z", | ||
| 214 | }; | 219 | }; |
| 215 | } | 220 | } |
| 216 | 221 | ||
| ... | @@ -783,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => { | ... | @@ -783,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => { |
| 783 | }); | 788 | }); |
| 784 | }); | 789 | }); |
| 785 | 790 | ||
| 791 | describe("sortSettledThreadsForSidebarV2", () => { | ||
| 792 | const settled = (input: { | ||
| 793 | id: string; | ||
| 794 | settledAt?: string | null; | ||
| 795 | latestUserMessageAt?: string | null; | ||
| 796 | latestTurn?: OrchestrationLatestTurn | null; | ||
| 797 | updatedAt?: string; | ||
| 798 | }) => ({ | ||
| 799 | id: input.id, | ||
| 800 | settledAt: input.settledAt ?? null, | ||
| 801 | latestUserMessageAt: input.latestUserMessageAt ?? null, | ||
| 802 | latestTurn: input.latestTurn ?? null, | ||
| 803 | updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", | ||
| 804 | }); | ||
| 805 | |||
| 806 | it("orders by settle time, most recently settled first", () => { | ||
| 807 | const sorted = sortSettledThreadsForSidebarV2([ | ||
| 808 | settled({ | ||
| 809 | id: "settled-first", | ||
| 810 | settledAt: "2026-03-09T10:00:00.000Z", | ||
| 811 | // Created/active later than the other thread: settle time must win. | ||
| 812 | latestUserMessageAt: "2026-03-09T09:59:00.000Z", | ||
| 813 | }), | ||
| 814 | settled({ | ||
| 815 | id: "settled-last", | ||
| 816 | settledAt: "2026-03-09T12:00:00.000Z", | ||
| 817 | latestUserMessageAt: "2026-03-09T08:00:00.000Z", | ||
| 818 | }), | ||
| 819 | ]); | ||
| 820 | |||
| 821 | expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); | ||
| 822 | }); | ||
| 823 | |||
| 824 | it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { | ||
| 825 | const sorted = sortSettledThreadsForSidebarV2([ | ||
| 826 | settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), | ||
| 827 | settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), | ||
| 828 | settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), | ||
| 829 | ]); | ||
| 830 | |||
| 831 | expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); | ||
| 832 | }); | ||
| 833 | |||
| 834 | it("counts a turn completion as activity for auto-settled threads", () => { | ||
| 835 | // The message came in before the other thread's, but its turn finished | ||
| 836 | // after: completion time is the real "work ended" moment. | ||
| 837 | const sorted = sortSettledThreadsForSidebarV2([ | ||
| 838 | settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), | ||
| 839 | settled({ | ||
| 840 | id: "completed-later", | ||
| 841 | latestUserMessageAt: "2026-03-09T10:00:00.000Z", | ||
| 842 | latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), | ||
| 843 | }), | ||
| 844 | ]); | ||
| 845 | |||
| 846 | expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); | ||
| 847 | }); | ||
| 848 | |||
| 849 | it("breaks timestamp ties by id so the order is stable", () => { | ||
| 850 | const sorted = sortSettledThreadsForSidebarV2([ | ||
| 851 | settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), | ||
| 852 | settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), | ||
| 853 | ]); | ||
| 854 | |||
| 855 | expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); | ||
| 856 | }); | ||
| 857 | }); | ||
| 858 | |||
| 859 | describe("resolveWorkingStartedAt", () => { | ||
| 860 | const session = { | ||
| 861 | threadId: ThreadId.make("thread-1"), | ||
| 862 | status: "running" as const, | ||
| 863 | providerName: "Codex", | ||
| 864 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 865 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 866 | activeTurnId: "turn-1" as never, | ||
| 867 | lastError: null, | ||
| 868 | updatedAt: "2026-03-09T10:02:00.000Z", | ||
| 869 | }; | ||
| 870 | |||
| 871 | it("uses the running turn's start time", () => { | ||
| 872 | expect( | ||
| 873 | resolveWorkingStartedAt({ | ||
| 874 | latestTurn: makeLatestTurn({ completedAt: null }), | ||
| 875 | session, | ||
| 876 | }), | ||
| 877 | ).toBe("2026-03-09T10:00:00.000Z"); | ||
| 878 | }); | ||
| 879 | |||
| 880 | it("uses the request time while a turn awaits adoption", () => { | ||
| 881 | expect( | ||
| 882 | resolveWorkingStartedAt({ | ||
| 883 | latestTurn: makeLatestTurn({ startedAt: null, completedAt: null }), | ||
| 884 | session, | ||
| 885 | }), | ||
| 886 | ).toBe("2026-03-09T10:00:00.000Z"); | ||
| 887 | }); | ||
| 888 | |||
| 889 | it("falls back to the session transition when the latest turn already completed", () => { | ||
| 890 | expect( | ||
| 891 | resolveWorkingStartedAt({ | ||
| 892 | latestTurn: makeLatestTurn(), | ||
| 893 | session, | ||
| 894 | }), | ||
| 895 | ).toBe("2026-03-09T10:02:00.000Z"); | ||
| 896 | }); | ||
| 897 | |||
| 898 | it("skips a malformed startedAt instead of returning it", () => { | ||
| 899 | expect( | ||
| 900 | resolveWorkingStartedAt({ | ||
| 901 | latestTurn: makeLatestTurn({ startedAt: "not-a-date", completedAt: null }), | ||
| 902 | session, | ||
| 903 | }), | ||
| 904 | ).toBe("2026-03-09T10:00:00.000Z"); | ||
| 905 | }); | ||
| 906 | |||
| 907 | it("returns null with neither a running turn nor a session", () => { | ||
| 908 | expect(resolveWorkingStartedAt({ latestTurn: null, session: null })).toBeNull(); | ||
| 909 | }); | ||
| 910 | }); | ||
| 911 | |||
| 912 | describe("formatWorkingDurationLabel", () => { | ||
| 913 | it("formats seconds, minutes, and hours", () => { | ||
| 914 | expect(formatWorkingDurationLabel(0)).toBe("0s"); | ||
| 915 | expect(formatWorkingDurationLabel(42_000)).toBe("42s"); | ||
| 916 | expect(formatWorkingDurationLabel(5 * 60_000)).toBe("5m"); | ||
| 917 | expect(formatWorkingDurationLabel(90 * 60_000)).toBe("1h 30m"); | ||
| 918 | }); | ||
| 919 | |||
| 920 | it("clamps negative and non-finite elapsed values to zero", () => { | ||
| 921 | expect(formatWorkingDurationLabel(-5_000)).toBe("0s"); | ||
| 922 | expect(formatWorkingDurationLabel(Number.NaN)).toBe("0s"); | ||
| 923 | }); | ||
| 924 | }); | ||
| 925 | |||
| 786 | describe("resolveThreadStatusPill", () => { | 926 | describe("resolveThreadStatusPill", () => { |
| 787 | const baseThread = { | 927 | const baseThread = { |
| 788 | hasActionableProposedPlan: false, | 928 | hasActionableProposedPlan: false, |
apps/web/src/components/Sidebar.logic.ts+79| ... | @@ -502,6 +502,18 @@ export function firstValidTimestampMs( | ... | @@ -502,6 +502,18 @@ export function firstValidTimestampMs( |
| 502 | return 0; | 502 | return 0; |
| 503 | } | 503 | } |
| 504 | 504 | ||
| 505 | /** String twin of firstValidTimestampMs for callers that need the ISO string | ||
| 506 | (display labels, tick anchors) rather than epoch ms. */ | ||
| 507 | export function firstValidTimestamp( | ||
| 508 | ...candidates: ReadonlyArray<string | null | undefined> | ||
| 509 | ): string | null { | ||
| 510 | for (const candidate of candidates) { | ||
| 511 | if (candidate == null) continue; | ||
| 512 | if (!Number.isNaN(Date.parse(candidate))) return candidate; | ||
| 513 | } | ||
| 514 | return null; | ||
| 515 | } | ||
| 516 | |||
| 505 | // v2 sort: static creation order, newest thread on top. Activity NEVER | 517 | // v2 sort: static creation order, newest thread on top. Activity NEVER |
| 506 | // reorders the list — a row holds its position from open until settled, so | 518 | // reorders the list — a row holds its position from open until settled, so |
| 507 | // the screen only moves at lifecycle transitions. Status (including pending | 519 | // the screen only moves at lifecycle transitions. Status (including pending |
| ... | @@ -516,6 +528,73 @@ export function sortThreadsForSidebarV2< | ... | @@ -516,6 +528,73 @@ export function sortThreadsForSidebarV2< |
| 516 | ); | 528 | ); |
| 517 | } | 529 | } |
| 518 | 530 | ||
| 531 | type SettledTimestampInput = Pick< | ||
| 532 | SidebarThreadSummary, | ||
| 533 | "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" | ||
| 534 | >; | ||
| 535 | |||
| 536 | /** The timestamp a settled row sorts and labels by: settledAt when stamped | ||
| 537 | (explicit settles), otherwise last activity — the same candidates | ||
| 538 | threadLastActivityAt feeds the auto-settle window (user message plus all | ||
| 539 | latestTurn stamps), so a thread whose last activity was a turn completion | ||
| 540 | doesn't sort by an older message time. updatedAt is the final net. */ | ||
| 541 | export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { | ||
| 542 | const settledAt = firstValidTimestamp(thread.settledAt); | ||
| 543 | if (settledAt !== null) return settledAt; | ||
| 544 | let latest: string | null = null; | ||
| 545 | let latestMs = Number.NEGATIVE_INFINITY; | ||
| 546 | for (const candidate of [ | ||
| 547 | thread.latestUserMessageAt, | ||
| 548 | thread.latestTurn?.requestedAt, | ||
| 549 | thread.latestTurn?.startedAt, | ||
| 550 | thread.latestTurn?.completedAt, | ||
| 551 | ]) { | ||
| 552 | if (candidate == null) continue; | ||
| 553 | const parsed = Date.parse(candidate); | ||
| 554 | if (!Number.isNaN(parsed) && parsed > latestMs) { | ||
| 555 | latest = candidate; | ||
| 556 | latestMs = parsed; | ||
| 557 | } | ||
| 558 | } | ||
| 559 | return latest ?? firstValidTimestamp(thread.updatedAt); | ||
| 560 | } | ||
| 561 | |||
| 562 | // Settled rows are history, so they order by when the work ENDED, not when | ||
| 563 | // the thread was created or last touched. | ||
| 564 | export function sortSettledThreadsForSidebarV2< | ||
| 565 | T extends SettledTimestampInput & { readonly id: string }, | ||
| 566 | >(threads: readonly T[]): T[] { | ||
| 567 | const timestampMs = (thread: T) => { | ||
| 568 | const timestamp = resolveSettledTimestamp(thread); | ||
| 569 | return timestamp === null ? 0 : Date.parse(timestamp); | ||
| 570 | }; | ||
| 571 | return [...threads].toSorted( | ||
| 572 | (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), | ||
| 573 | ); | ||
| 574 | } | ||
| 575 | |||
| 576 | /** The timestamp a working thread's elapsed label counts from: the running | ||
| 577 | turn's start (request time until adoption), falling back to the session's | ||
| 578 | last transition when the turn projection lags behind. Malformed | ||
| 579 | timestamps fall through to the next candidate, not just missing ones. */ | ||
| 580 | export function resolveWorkingStartedAt( | ||
| 581 | thread: Pick<SidebarThreadSummary, "latestTurn" | "session">, | ||
| 582 | ): string | null { | ||
| 583 | const turn = thread.latestTurn; | ||
| 584 | if (turn && turn.completedAt === null) { | ||
| 585 | return firstValidTimestamp(turn.startedAt, turn.requestedAt, thread.session?.updatedAt); | ||
| 586 | } | ||
| 587 | return firstValidTimestamp(thread.session?.updatedAt); | ||
| 588 | } | ||
| 589 | |||
| 590 | export function formatWorkingDurationLabel(elapsedMs: number): string { | ||
| 591 | const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0; | ||
| 592 | if (seconds < 60) return `${seconds}s`; | ||
| 593 | const minutes = Math.floor(seconds / 60); | ||
| 594 | if (minutes < 60) return `${minutes}m`; | ||
| 595 | return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; | ||
| 596 | } | ||
| 597 | |||
| 519 | export function resolveThreadStatusPill(input: { | 598 | export function resolveThreadStatusPill(input: { |
| 520 | thread: ThreadStatusInput; | 599 | thread: ThreadStatusInput; |
| 521 | }): ThreadStatusPill | null { | 600 | }): ThreadStatusPill | null { |
apps/web/src/components/SidebarV2.tsx+81-21| ... | @@ -91,14 +91,17 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; | ... | @@ -91,14 +91,17 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 91 | import type { SidebarThreadSummary } from "../types"; | 91 | import type { SidebarThreadSummary } from "../types"; |
| 92 | import { cn } from "~/lib/utils"; | 92 | import { cn } from "~/lib/utils"; |
| 93 | import { | 93 | import { |
| 94 | firstValidTimestampMs, | 94 | formatWorkingDurationLabel, |
| 95 | hasUnseenCompletion, | 95 | hasUnseenCompletion, |
| 96 | isTrailingDoubleClick, | 96 | isTrailingDoubleClick, |
| 97 | orderItemsByPreferredIds, | 97 | orderItemsByPreferredIds, |
| 98 | resolveAdjacentThreadId, | 98 | resolveAdjacentThreadId, |
| 99 | resolveSettledTimestamp, | ||
| 99 | resolveSidebarV2Status, | 100 | resolveSidebarV2Status, |
| 101 | resolveWorkingStartedAt, | ||
| 100 | shouldNavigateAfterProjectRemoval, | 102 | shouldNavigateAfterProjectRemoval, |
| 101 | sortLogicalProjectsForSidebar, | 103 | sortLogicalProjectsForSidebar, |
| 104 | sortSettledThreadsForSidebarV2, | ||
| 102 | sortThreadsForSidebarV2, | 105 | sortThreadsForSidebarV2, |
| 103 | } from "./Sidebar.logic"; | 106 | } from "./Sidebar.logic"; |
| 104 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | 107 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| ... | @@ -149,6 +152,44 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { | ... | @@ -149,6 +152,44 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { |
| 149 | return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); | 152 | return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); |
| 150 | } | 153 | } |
| 151 | 154 | ||
| 155 | // Settled rows read "how long ago did this wrap up", matching their sort | ||
| 156 | // key: both go through resolveSettledTimestamp so label and order can't | ||
| 157 | // disagree. | ||
| 158 | function settledTimeLabel(thread: SidebarThreadSummary): string { | ||
| 159 | const timestamp = resolveSettledTimestamp(thread); | ||
| 160 | return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); | ||
| 161 | } | ||
| 162 | |||
| 163 | // Floats at the row's right edge, vertically centered, while the jump | ||
| 164 | // modifier is held. An overlay pill instead of an inline slot: the hint | ||
| 165 | // must neither displace the status/time label (holding ⌘ used to blank | ||
| 166 | // out "Working") nor shift any layout when it appears. pointer-events-none | ||
| 167 | // so it never swallows clicks meant for the settle/un-settle buttons it | ||
| 168 | // can overlap. | ||
| 169 | function JumpHintBadge(props: { label: string }) { | ||
| 170 | return ( | ||
| 171 | <span | ||
| 172 | aria-hidden | ||
| 173 | className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | ||
| 174 | > | ||
| 175 | {props.label} | ||
| 176 | </span> | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | // Self-ticking so only this span re-renders each second, not the whole row. | ||
| 181 | function WorkingDuration(props: { startedAt: string | null }) { | ||
| 182 | const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; | ||
| 183 | const [, setTick] = useState(0); | ||
| 184 | useEffect(() => { | ||
| 185 | if (Number.isNaN(startedMs)) return; | ||
| 186 | const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); | ||
| 187 | return () => window.clearInterval(id); | ||
| 188 | }, [startedMs]); | ||
| 189 | if (Number.isNaN(startedMs)) return null; | ||
| 190 | return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>; | ||
| 191 | } | ||
| 192 | |||
| 152 | function SidebarV2ThreadTooltip({ | 193 | function SidebarV2ThreadTooltip({ |
| 153 | thread, | 194 | thread, |
| 154 | projectTitle, | 195 | projectTitle, |
| ... | @@ -292,7 +333,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -292,7 +333,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 292 | // flag must not light up every historical thread as unread. | 333 | // flag must not light up every historical thread as unread. |
| 293 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); | 334 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); |
| 294 | const status = resolveSidebarV2Status(thread); | 335 | const status = resolveSidebarV2Status(thread); |
| 295 | const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; | 336 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 337 | // there is nothing for the user to do yet, so prominence is reserved for | ||
| 338 | // rows that need a human — done (unread), read-but-unsettled, and failed. | ||
| 339 | // The status label keeps its hue, so waiting rows stay findable. In-flight | ||
| 340 | // rows recede the same as read-ready ones (inbox-zero: working threads | ||
| 341 | // aren't your problem yet) — only the colored status label stands out. | ||
| 342 | const isInFlight = status === "working" || status === "approval" || status === "input"; | ||
| 343 | const shouldRecede = | ||
| 344 | (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; | ||
| 296 | // Status hues follow the system-wide convention set by sidebar v1 and the | 345 | // Status hues follow the system-wide convention set by sidebar v1 and the |
| 297 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) | 346 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) |
| 298 | // so a thread reads the same color everywhere it surfaces. | 347 | // so a thread reads the same color everywhere it surfaces. |
| ... | @@ -478,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -478,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 478 | : shouldRecede | 527 | : shouldRecede |
| 479 | ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | 528 | ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" |
| 480 | : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", | 529 | : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", |
| 530 | isInFlight && | ||
| 531 | !props.isActive && | ||
| 532 | !isSelected && | ||
| 533 | "opacity-70 transition-opacity hover:opacity-100", | ||
| 481 | ); | 534 | ); |
| 482 | 535 | ||
| 483 | const title = isRenaming ? ( | 536 | const title = isRenaming ? ( |
| ... | @@ -503,10 +556,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -503,10 +556,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 503 | "truncate", | 556 | "truncate", |
| 504 | isUnread | 557 | isUnread |
| 505 | ? "text-foreground" | 558 | ? "text-foreground" |
| 506 | : status !== "ready" | 559 | : shouldRecede |
| 507 | ? "text-foreground/95" | 560 | ? "text-muted-foreground/80" |
| 508 | : shouldRecede | 561 | : status === "failed" |
| 509 | ? "text-muted-foreground/80" | 562 | ? "text-foreground/95" |
| 510 | : "text-foreground/90", | 563 | : "text-foreground/90", |
| 511 | ) | 564 | ) |
| 512 | : cn( | 565 | : cn( |
| ... | @@ -587,10 +640,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -587,10 +640,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 587 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> | 640 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 588 | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> | 641 | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> |
| 589 | <span className="text-xs"> | 642 | <span className="text-xs"> |
| 590 | {props.jumpLabel ?? | 643 | {variantAction === "unsettle" |
| 591 | compactSidebarTimeLabel( | 644 | ? settledTimeLabel(thread) |
| 592 | formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), | 645 | : threadTimeLabel(thread)} |
| 593 | )} | ||
| 594 | </span> | 646 | </span> |
| 595 | </span> | 647 | </span> |
| 596 | {!props.settlementSupported ? null : variantAction === "unsettle" ? ( | 648 | {!props.settlementSupported ? null : variantAction === "unsettle" ? ( |
| ... | @@ -613,6 +665,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -613,6 +665,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 613 | </button> | 665 | </button> |
| 614 | )} | 666 | )} |
| 615 | </span> | 667 | </span> |
| 668 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} | ||
| 616 | </TooltipTrigger> | 669 | </TooltipTrigger> |
| 617 | {detailsTooltip} | 670 | {detailsTooltip} |
| 618 | </Tooltip> | 671 | </Tooltip> |
| ... | @@ -650,7 +703,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -650,7 +703,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 650 | className="size-4 shrink-0" | 703 | className="size-4 shrink-0" |
| 651 | /> | 704 | /> |
| 652 | {props.projectTitle ? ( | 705 | {props.projectTitle ? ( |
| 653 | <span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground/85"> | 706 | <span |
| 707 | className={cn( | ||
| 708 | "min-w-0 flex-1 truncate text-xs text-muted-foreground/85", | ||
| 709 | shouldRecede ? "font-normal" : "font-medium", | ||
| 710 | )} | ||
| 711 | > | ||
| 654 | {props.projectTitle} | 712 | {props.projectTitle} |
| 655 | </span> | 713 | </span> |
| 656 | ) : ( | 714 | ) : ( |
| ... | @@ -658,11 +716,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -658,11 +716,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 658 | )} | 716 | )} |
| 659 | <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs"> | 717 | <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs"> |
| 660 | <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0"> | 718 | <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0"> |
| 661 | {props.jumpLabel ? ( | 719 | {topStatus ? ( |
| 662 | props.jumpLabel | ||
| 663 | ) : topStatus ? ( | ||
| 664 | <span | 720 | <span |
| 665 | role="status" | ||
| 666 | className={cn( | 721 | className={cn( |
| 667 | "inline-flex items-center gap-1 font-medium", | 722 | "inline-flex items-center gap-1 font-medium", |
| 668 | topStatus.className, | 723 | topStatus.className, |
| ... | @@ -673,7 +728,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -673,7 +728,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 673 | ) : topStatus.icon === "done" ? ( | 728 | ) : topStatus.icon === "done" ? ( |
| 674 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> | 729 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 675 | ) : null} | 730 | ) : null} |
| 676 | {topStatus.label} | 731 | {/* The label alone is the live region: a role="status" |
| 732 | wrapper around the ticking duration would make | ||
| 733 | screen readers announce every second. */} | ||
| 734 | <span role="status">{topStatus.label}</span> | ||
| 735 | {status === "working" ? ( | ||
| 736 | <span aria-hidden> | ||
| 737 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> | ||
| 738 | </span> | ||
| 739 | ) : null} | ||
| 677 | </span> | 740 | </span> |
| 678 | ) : ( | 741 | ) : ( |
| 679 | threadTimeLabel(thread) | 742 | threadTimeLabel(thread) |
| ... | @@ -727,6 +790,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -727,6 +790,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 727 | </span> | 790 | </span> |
| 728 | </div> | 791 | </div> |
| 729 | </div> | 792 | </div> |
| 793 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} | ||
| 730 | </TooltipTrigger> | 794 | </TooltipTrigger> |
| 731 | {detailsTooltip} | 795 | {detailsTooltip} |
| 732 | </Tooltip> | 796 | </Tooltip> |
| ... | @@ -1137,11 +1201,7 @@ export default function SidebarV2() { | ... | @@ -1137,11 +1201,7 @@ export default function SidebarV2() { |
| 1137 | } | 1201 | } |
| 1138 | return { | 1202 | return { |
| 1139 | activeThreads: sortThreadsForSidebarV2(active), | 1203 | activeThreads: sortThreadsForSidebarV2(active), |
| 1140 | settledThreads: settled.toSorted( | 1204 | settledThreads: sortSettledThreadsForSidebarV2(settled), |
| 1141 | (left, right) => | ||
| 1142 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - | ||
| 1143 | firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), | ||
| 1144 | ), | ||
| 1145 | }; | 1205 | }; |
| 1146 | }, [ | 1206 | }, [ |
| 1147 | autoSettleAfterDays, | 1207 | autoSettleAfterDays, |