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
96 changes: 95 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";

import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic";
import {
compareThreadsByRecentActivity,
hasUnseenCompletion,
resolveThreadActivityAt,
resolveThreadStatusPill,
} from "./Sidebar.logic";

function makeLatestTurn(overrides?: {
completedAt?: string | null;
Expand Down Expand Up @@ -30,6 +35,95 @@ describe("hasUnseenCompletion", () => {
});
});

describe("resolveThreadActivityAt", () => {
it("prefers updatedAt over createdAt when present", () => {
expect(
resolveThreadActivityAt({
id: "thread-1" as never,
createdAt: "2026-03-09T10:00:00.000Z",
updatedAt: "2026-03-09T10:05:00.000Z",
}),
).toBe("2026-03-09T10:05:00.000Z");
});

it("falls back to createdAt when updatedAt is missing", () => {
expect(
resolveThreadActivityAt({
id: "thread-1" as never,
createdAt: "2026-03-09T10:00:00.000Z",
}),
).toBe("2026-03-09T10:00:00.000Z");
});

it("falls back to createdAt when updatedAt is invalid", () => {
expect(
resolveThreadActivityAt({
id: "thread-1" as never,
createdAt: "2026-03-09T10:00:00.000Z",
updatedAt: "not-a-date",
}),
).toBe("2026-03-09T10:00:00.000Z");
});
});

describe("compareThreadsByRecentActivity", () => {
it("sorts threads by most recent activity first", () => {
const threads = [
{
id: "thread-1" as never,
createdAt: "2026-03-09T10:00:00.000Z",
updatedAt: "2026-03-09T10:01:00.000Z",
},
{
id: "thread-2" as never,
createdAt: "2026-03-09T09:00:00.000Z",
updatedAt: "2026-03-09T10:05:00.000Z",
},
];

expect(threads.toSorted(compareThreadsByRecentActivity).map((thread) => thread.id)).toEqual([
"thread-2",
"thread-1",
]);
});

it("sorts a valid activity timestamp ahead of an invalid one", () => {
const threads = [
{
id: "thread-1" as never,
createdAt: "not-a-date",
},
{
id: "thread-2" as never,
createdAt: "2026-03-09T10:05:00.000Z",
},
];

expect(threads.toSorted(compareThreadsByRecentActivity).map((thread) => thread.id)).toEqual([
"thread-2",
"thread-1",
]);
});

it("sorts two invalid timestamps by id as a stable fallback", () => {
const threads = [
{
id: "thread-1" as never,
createdAt: "not-a-date",
},
{
id: "thread-2" as never,
createdAt: "still-not-a-date",
},
];

expect(threads.toSorted(compareThreadsByRecentActivity).map((thread) => thread.id)).toEqual([
"thread-2",
"thread-1",
]);
});
});

describe("resolveThreadStatusPill", () => {
const baseThread = {
interactionMode: "plan" as const,
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,48 @@ type ThreadStatusInput = Pick<
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
>;

type ThreadActivityInput = Pick<Thread, "createdAt" | "id"> & {
updatedAt?: string | undefined;
};

function parseIsoDate(value: string | undefined): number {
if (!value) return Number.NaN;
return Date.parse(value);
}

export function resolveThreadActivityAt(thread: ThreadActivityInput): string {
const { updatedAt } = thread;
if (updatedAt) {
const updatedAtMs = parseIsoDate(updatedAt);
if (!Number.isNaN(updatedAtMs)) {
return updatedAt;
}
}
return thread.createdAt;
}

export function compareThreadsByRecentActivity(
left: ThreadActivityInput,
right: ThreadActivityInput,
): number {
const rightActivityAt = parseIsoDate(resolveThreadActivityAt(right));
const leftActivityAt = parseIsoDate(resolveThreadActivityAt(left));
if (
!Number.isNaN(rightActivityAt) &&
!Number.isNaN(leftActivityAt) &&
rightActivityAt !== leftActivityAt
) {
return rightActivityAt - leftActivityAt;
}
if (!Number.isNaN(rightActivityAt) && Number.isNaN(leftActivityAt)) {
return 1;
}
if (Number.isNaN(rightActivityAt) && !Number.isNaN(leftActivityAt)) {
return -1;
}
return right.id.localeCompare(left.id);
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
Expand Down
20 changes: 8 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ import {
} from "./ui/sidebar";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import { resolveThreadStatusPill } from "./Sidebar.logic";
import {
compareThreadsByRecentActivity,
resolveThreadActivityAt,
resolveThreadStatusPill,
} from "./Sidebar.logic";

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const THREAD_PREVIEW_LIMIT = 6;
Expand Down Expand Up @@ -422,11 +426,7 @@ export default function Sidebar() {
(projectId: ProjectId) => {
const latestThread = threads
.filter((thread) => thread.projectId === projectId)
.toSorted((a, b) => {
const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (byDate !== 0) return byDate;
return b.id.localeCompare(a.id);
})[0];
.toSorted(compareThreadsByRecentActivity)[0];
if (!latestThread) return;

void navigate({
Expand Down Expand Up @@ -1134,11 +1134,7 @@ export default function Sidebar() {
{projects.map((project) => {
const projectThreads = threads
.filter((thread) => thread.projectId === project.id)
.toSorted((a, b) => {
const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (byDate !== 0) return byDate;
return b.id.localeCompare(a.id);
});
.toSorted(compareThreadsByRecentActivity);
const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
const visibleThreads =
Expand Down Expand Up @@ -1348,7 +1344,7 @@ export default function Sidebar() {
isActive ? "text-foreground/65" : "text-muted-foreground/40"
}`}
>
{formatRelativeTime(thread.createdAt)}
{formatRelativeTime(resolveThreadActivityAt(thread))}
</span>
</div>
</SidebarMenuSubButton>
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
proposedPlans: [],
error: null,
createdAt: "2026-02-13T00:00:00.000Z",
updatedAt: "2026-02-13T00:00:00.000Z",
latestTurn: null,
branch: null,
worktreePath: null,
Expand Down Expand Up @@ -147,4 +148,17 @@ describe("store read model sync", () => {

expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
});

it("preserves thread updatedAt from the orchestration read model", () => {
const initialState = makeState(makeThread());
const readModel = makeReadModel(
makeReadModelThread({
updatedAt: "2026-02-27T00:05:00.000Z",
}),
);

const next = syncServerReadModel(initialState, readModel);

expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
});
});
1 change: 1 addition & 0 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
})),
error: thread.session?.lastError ?? null,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
latestTurn: thread.latestTurn,
lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
branch: thread.branch,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface Thread {
proposedPlans: ProposedPlan[];
error: string | null;
createdAt: string;
updatedAt?: string | undefined;
latestTurn: OrchestrationLatestTurn | null;
lastVisitedAt?: string | undefined;
branch: string | null;
Expand Down
Loading