| 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 | 20 | resolveThreadRowClassName, |
| 21 | 21 | resolveSidebarV2Status, |
| 22 | 22 | resolveThreadStatusPill, |
| 23 | resolveWorkingStartedAt, | |
| 24 | formatWorkingDurationLabel, | |
| 23 | 25 | shouldNavigateAfterProjectRemoval, |
| 24 | 26 | shouldClearThreadSelectionOnMouseDown, |
| 25 | 27 | sortLogicalProjectsForSidebar, |
| 28 | sortSettledThreadsForSidebarV2, | |
| 26 | 29 | sortThreadsForSidebarV2, |
| 27 | 30 | sortProjectsForSidebar, |
| 28 | 31 | sortScopedProjectsForSidebar, |
| ... | ... | @@ -209,8 +212,10 @@ function makeLatestTurn(overrides?: { |
| 209 | 212 | state: "completed", |
| 210 | 213 | assistantMessageId: null, |
| 211 | 214 | requestedAt: "2026-03-09T10:00:00.000Z", |
| 212 | startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", | |
| 213 | completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", | |
| 215 | startedAt: | |
| 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 | 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 | 926 | describe("resolveThreadStatusPill", () => { |
| 787 | 927 | const baseThread = { |
| 788 | 928 | hasActionableProposedPlan: false, |
apps/web/src/components/Sidebar.logic.ts+79| ... | ... | @@ -502,6 +502,18 @@ export function firstValidTimestampMs( |
| 502 | 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 | 517 | // v2 sort: static creation order, newest thread on top. Activity NEVER |
| 506 | 518 | // reorders the list — a row holds its position from open until settled, so |
| 507 | 519 | // the screen only moves at lifecycle transitions. Status (including pending |
| ... | ... | @@ -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 | 598 | export function resolveThreadStatusPill(input: { |
| 520 | 599 | thread: ThreadStatusInput; |
| 521 | 600 | }): ThreadStatusPill | null { |
apps/web/src/components/SidebarV2.tsx+81-21| ... | ... | @@ -91,14 +91,17 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 91 | 91 | import type { SidebarThreadSummary } from "../types"; |
| 92 | 92 | import { cn } from "~/lib/utils"; |
| 93 | 93 | import { |
| 94 | firstValidTimestampMs, | |
| 94 | formatWorkingDurationLabel, | |
| 95 | 95 | hasUnseenCompletion, |
| 96 | 96 | isTrailingDoubleClick, |
| 97 | 97 | orderItemsByPreferredIds, |
| 98 | 98 | resolveAdjacentThreadId, |
| 99 | resolveSettledTimestamp, | |
| 99 | 100 | resolveSidebarV2Status, |
| 101 | resolveWorkingStartedAt, | |
| 100 | 102 | shouldNavigateAfterProjectRemoval, |
| 101 | 103 | sortLogicalProjectsForSidebar, |
| 104 | sortSettledThreadsForSidebarV2, | |
| 102 | 105 | sortThreadsForSidebarV2, |
| 103 | 106 | } from "./Sidebar.logic"; |
| 104 | 107 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| ... | ... | @@ -149,6 +152,44 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { |
| 149 | 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 | 193 | function SidebarV2ThreadTooltip({ |
| 153 | 194 | thread, |
| 154 | 195 | projectTitle, |
| ... | ... | @@ -292,7 +333,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 292 | 333 | // flag must not light up every historical thread as unread. |
| 293 | 334 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); |
| 294 | 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 | 345 | // Status hues follow the system-wide convention set by sidebar v1 and the |
| 297 | 346 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) |
| 298 | 347 | // so a thread reads the same color everywhere it surfaces. |
| ... | ... | @@ -478,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 478 | 527 | : shouldRecede |
| 479 | 528 | ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" |
| 480 | 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 | 536 | const title = isRenaming ? ( |
| ... | ... | @@ -503,10 +556,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 503 | 556 | "truncate", |
| 504 | 557 | isUnread |
| 505 | 558 | ? "text-foreground" |
| 506 | : status !== "ready" | |
| 507 | ? "text-foreground/95" | |
| 508 | : shouldRecede | |
| 509 | ? "text-muted-foreground/80" | |
| 559 | : shouldRecede | |
| 560 | ? "text-muted-foreground/80" | |
| 561 | : status === "failed" | |
| 562 | ? "text-foreground/95" | |
| 510 | 563 | : "text-foreground/90", |
| 511 | 564 | ) |
| 512 | 565 | : cn( |
| ... | ... | @@ -587,10 +640,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 587 | 640 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 588 | 641 | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> |
| 589 | 642 | <span className="text-xs"> |
| 590 | {props.jumpLabel ?? | |
| 591 | compactSidebarTimeLabel( | |
| 592 | formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), | |
| 593 | )} | |
| 643 | {variantAction === "unsettle" | |
| 644 | ? settledTimeLabel(thread) | |
| 645 | : threadTimeLabel(thread)} | |
| 594 | 646 | </span> |
| 595 | 647 | </span> |
| 596 | 648 | {!props.settlementSupported ? null : variantAction === "unsettle" ? ( |
| ... | ... | @@ -613,6 +665,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 613 | 665 | </button> |
| 614 | 666 | )} |
| 615 | 667 | </span> |
| 668 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} | |
| 616 | 669 | </TooltipTrigger> |
| 617 | 670 | {detailsTooltip} |
| 618 | 671 | </Tooltip> |
| ... | ... | @@ -650,7 +703,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 650 | 703 | className="size-4 shrink-0" |
| 651 | 704 | /> |
| 652 | 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 | 712 | {props.projectTitle} |
| 655 | 713 | </span> |
| 656 | 714 | ) : ( |
| ... | ... | @@ -658,11 +716,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 658 | 716 | )} |
| 659 | 717 | <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs"> |
| 660 | 718 | <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0"> |
| 661 | {props.jumpLabel ? ( | |
| 662 | props.jumpLabel | |
| 663 | ) : topStatus ? ( | |
| 719 | {topStatus ? ( | |
| 664 | 720 | <span |
| 665 | role="status" | |
| 666 | 721 | className={cn( |
| 667 | 722 | "inline-flex items-center gap-1 font-medium", |
| 668 | 723 | topStatus.className, |
| ... | ... | @@ -673,7 +728,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 673 | 728 | ) : topStatus.icon === "done" ? ( |
| 674 | 729 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 675 | 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 | 740 | </span> |
| 678 | 741 | ) : ( |
| 679 | 742 | threadTimeLabel(thread) |
| ... | ... | @@ -727,6 +790,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 727 | 790 | </span> |
| 728 | 791 | </div> |
| 729 | 792 | </div> |
| 793 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} | |
| 730 | 794 | </TooltipTrigger> |
| 731 | 795 | {detailsTooltip} |
| 732 | 796 | </Tooltip> |
| ... | ... | @@ -1137,11 +1201,7 @@ export default function SidebarV2() { |
| 1137 | 1201 | } |
| 1138 | 1202 | return { |
| 1139 | 1203 | activeThreads: sortThreadsForSidebarV2(active), |
| 1140 | settledThreads: settled.toSorted( | |
| 1141 | (left, right) => | |
| 1142 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - | |
| 1143 | firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), | |
| 1144 | ), | |
| 1204 | settledThreads: sortSettledThreadsForSidebarV2(settled), | |
| 1145 | 1205 | }; |
| 1146 | 1206 | }, [ |
| 1147 | 1207 | autoSettleAfterDays, |