Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
resolveThreadRowClassName,
resolveSidebarV2Status,
resolveThreadStatusPill,
resolveWorkingStartedAt,
formatWorkingDurationLabel,
shouldClearThreadSelectionOnMouseDown,
sortSettledThreadsForSidebarV2,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
Expand Down Expand Up @@ -156,8 +159,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",
};
}

Expand Down Expand Up @@ -730,6 +735,124 @@ describe("sortThreadsForSidebarV2", () => {
});
});

describe("sortSettledThreadsForSidebarV2", () => {
const settled = (input: {
id: string;
settledAt?: string | null;
latestUserMessageAt?: string | null;
updatedAt?: string;
}) => ({
id: input.id,
settledAt: input.settledAt ?? null,
latestUserMessageAt: input.latestUserMessageAt ?? 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("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,
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,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 | undefined>
): 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
Expand All @@ -482,6 +494,48 @@ export function sortThreadsForSidebarV2<
);
}

// Settled rows are history, so they order by when the work ENDED, not when

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/Sidebar.logic.ts:497

sortSettledThreadsForSidebarV2 falls back to latestUserMessageAt then updatedAt for auto-settled threads, but it skips latestTurn.requestedAt, startedAt, and completedAt — which threadLastActivityAt includes. A long-running auto-settled thread sorts by the earlier user-message time rather than by when the turn actually completed, so it appears older than it should and ends up out of order. Include the latest-turn activity timestamps in the fallback chain.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/Sidebar.logic.ts around line 497:

`sortSettledThreadsForSidebarV2` falls back to `latestUserMessageAt` then `updatedAt` for auto-settled threads, but it skips `latestTurn.requestedAt`, `startedAt`, and `completedAt` — which `threadLastActivityAt` includes. A long-running auto-settled thread sorts by the earlier user-message time rather than by when the turn actually completed, so it appears older than it should and ends up out of order. Include the latest-turn activity timestamps in the fallback chain.

// the thread was created or last touched. Explicit settles stamp settledAt;
// auto-settled threads (inactivity, merged/closed PR) carry no stamp and
// fall back to last activity so they still interleave sensibly.
export function sortSettledThreadsForSidebarV2<
T extends {
readonly id: string;
readonly updatedAt: string;
readonly settledAt: string | null;
readonly latestUserMessageAt: string | null;
},
>(threads: readonly T[]): T[] {
return [...threads].toSorted(
(left, right) =>
firstValidTimestampMs(right.settledAt, right.latestUserMessageAt, right.updatedAt) -
firstValidTimestampMs(left.settledAt, left.latestUserMessageAt, left.updatedAt) ||
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<SidebarThreadSummary, "latestTurn" | "session">,
): 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);
}
Comment thread
cursor[bot] marked this conversation as resolved.

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 {
Expand Down
Loading
Loading