Skip to content
Merged
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
144 changes: 142 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import {
resolveThreadRowClassName,
resolveSidebarV2Status,
resolveThreadStatusPill,
resolveWorkingStartedAt,
formatWorkingDurationLabel,
shouldNavigateAfterProjectRemoval,
shouldClearThreadSelectionOnMouseDown,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebarV2,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
Expand Down Expand Up @@ -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",
};
}

Expand Down Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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 @@ -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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// 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<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