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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function buildLocalDraftThread(
messages: [],
error,
createdAt: draftThread.createdAt,
updatedAt: draftThread.createdAt,
latestTurn: null,
lastVisitedAt: draftThread.createdAt,
branch: draftThread.branch,
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";

import {
compareThreadsByLastActivity,
hasUnseenCompletion,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
} from "./Sidebar.logic";
import { ThreadId } from "@t3tools/contracts";

function makeThreadId(id: string): ThreadId {
return ThreadId.makeUnsafe(id);
}

function makeLatestTurn(overrides?: {
completedAt?: string | null;
Expand Down Expand Up @@ -176,6 +182,55 @@ describe("resolveThreadStatusPill", () => {
});
});

describe("compareThreadsByLastActivity", () => {
it("sorts by updatedAt when present, newest first", () => {
const older = {
id: makeThreadId("thread-old"),
createdAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
};
const newer = {
id: makeThreadId("thread-new"),
createdAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-03-01T14:00:00.000Z",
};
expect(compareThreadsByLastActivity(older, newer)).toBeGreaterThan(0);
expect(compareThreadsByLastActivity(newer, older)).toBeLessThan(0);
});

it("falls back to createdAt when updatedAt is absent", () => {
const older = { id: makeThreadId("thread-a"), createdAt: "2026-03-01T10:00:00.000Z" };
const newer = { id: makeThreadId("thread-b"), createdAt: "2026-03-01T12:00:00.000Z" };
expect(compareThreadsByLastActivity(older, newer)).toBeGreaterThan(0);
expect(compareThreadsByLastActivity(newer, older)).toBeLessThan(0);
});

it("uses id as tiebreaker when timestamps are equal", () => {
const a = {
id: makeThreadId("thread-aaa"),
createdAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-03-01T10:00:00.000Z",
};
const b = {
id: makeThreadId("thread-zzz"),
createdAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-03-01T10:00:00.000Z",
};
expect(compareThreadsByLastActivity(a, b)).toBeGreaterThan(0);
expect(compareThreadsByLastActivity(b, a)).toBeLessThan(0);
});

it("produces stable sort order for array", () => {
const threads = [
{ id: makeThreadId("thread-1"), createdAt: "2026-03-01T10:00:00.000Z", updatedAt: "2026-03-01T14:00:00.000Z" },
{ id: makeThreadId("thread-2"), createdAt: "2026-03-01T10:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z" },
{ id: makeThreadId("thread-3"), createdAt: "2026-03-01T10:00:00.000Z", updatedAt: "2026-03-01T16:00:00.000Z" },
];
const sorted = [...threads].toSorted(compareThreadsByLastActivity);
expect(sorted.map((t) => t.id)).toEqual(["thread-3", "thread-1", "thread-2"]);
});
});

describe("resolveThreadRowClassName", () => {
it("uses the darker selected palette when a thread is both selected and active", () => {
const className = resolveThreadRowClassName({ isActive: true, isSelected: true });
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
export type SidebarNewThreadEnvMode = "local" | "worktree";

export type ThreadSortInput = Pick<Thread, "id"> & {
createdAt: string;
updatedAt?: string;
};

/**
* Comparator for sorting threads by last activity (updatedAt, then createdAt), newest first.
* Uses thread id as tiebreaker for deterministic order.
*/
export function compareThreadsByLastActivity(
a: ThreadSortInput,
b: ThreadSortInput,
): number {

const byDate =
new Date(b.updatedAt ?? b.createdAt).getTime() -
new Date(a.updatedAt ?? a.createdAt).getTime();
if (byDate !== 0) return byDate;
return b.id.localeCompare(a.id);
}

export interface ThreadStatusPill {
label:
| "Working"
Expand Down
18 changes: 6 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import {
compareThreadsByLastActivity,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
resolveThreadStatusPill,
Expand Down Expand Up @@ -385,11 +386,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(compareThreadsByLastActivity)[0];
if (!latestThread) return;

void navigate({
Expand Down Expand Up @@ -1295,12 +1292,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(compareThreadsByLastActivity);
const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
const visibleThreads =
Expand Down Expand Up @@ -1551,7 +1543,9 @@ export default function Sidebar() {
: "text-muted-foreground/40"
}`}
>
{formatRelativeTime(thread.createdAt)}
{formatRelativeTime(
thread.updatedAt ?? thread.createdAt,
)}
</span>
</div>
</SidebarMenuSubButton>
Expand Down
1 change: 1 addition & 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
1 change: 1 addition & 0 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,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;
latestTurn: OrchestrationLatestTurn | null;
lastVisitedAt?: string | undefined;
branch: string | null;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/worktreeCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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