authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 13:50:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 22:50:19+02:00
logddd5a46f3942df302f8a13310cdfa47838b0ca81
treeddc761405f680eda89400977fb291b29459aec80
parent88c69ffff6ca3a025eec6a2ea597d4048e851266
signature Signed by PGP key B5690EEEBB952194

fix(web): sidebar v2 polish — jump hints, working duration, in-flight fade, settled sort (#4274)

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}
216221
...@@ -783,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => {...@@ -783,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => {
783 });788 });
784});789});
785790
791describe("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
859describe("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
912describe("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
786describe("resolveThreadStatusPill", () => {926describe("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}
504504
505/** String twin of firstValidTimestampMs for callers that need the ISO string
506 (display labels, tick anchors) rather than epoch ms. */
507export 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 NEVER517// v2 sort: static creation order, newest thread on top. Activity NEVER
506// reorders the list — a row holds its position from open until settled, so518// reorders the list — a row holds its position from open until settled, so
507// the screen only moves at lifecycle transitions. Status (including pending519// 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}
518530
531type 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. */
541export 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.
564export 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. */
580export 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
590export 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
519export function resolveThreadStatusPill(input: {598export 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";
91import type { SidebarThreadSummary } from "../types";91import type { SidebarThreadSummary } from "../types";
92import { cn } from "~/lib/utils";92import { cn } from "~/lib/utils";
93import {93import {
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";
104import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";107import { 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}
151154
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.
158function 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.
169function 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.
181function 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
152function SidebarV2ThreadTooltip({193function 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 the345 // 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 : shouldRecede527 : 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 );
482535
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 isUnread557 isUnread
505 ? "text-foreground"558 ? "text-foreground"
506 : status !== "ready"559 : shouldRecede
507 ? "text-foreground/95"560 ? "text-muted-foreground/80"
508 : shouldRecede561 : 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 <span720 <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,