From fcd6d062600f0e6e364b5cea5447b49b202ab888 Mon Sep 17 00:00:00 2001 From: "abdulbari131103@gmail.com" Date: Sat, 14 Mar 2026 19:10:14 +0000 Subject: [PATCH] feat(sidebar): show update time instead of creation time - Add updatedAt to Thread type and pass through from store sync - Display thread.updatedAt (fallback to createdAt) in sidebar - Sort threads by updatedAt for most-recently-active-first order - Extract compareThreadsByLastActivity to Sidebar.logic with tests --- apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 55 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 21 +++++++ apps/web/src/components/Sidebar.tsx | 18 ++---- apps/web/src/store.test.ts | 1 + apps/web/src/store.ts | 1 + apps/web/src/types.ts | 1 + apps/web/src/worktreeCleanup.test.ts | 1 + 8 files changed, 87 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..de30810ad 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -28,6 +28,7 @@ export function buildLocalDraftThread( messages: [], error, createdAt: draftThread.createdAt, + updatedAt: draftThread.createdAt, latestTurn: null, lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a8f84d564..1543c31c7 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -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; @@ -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 }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f08ed212a..a1b417cdb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -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 & { + 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" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..079ce0f1d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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, @@ -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({ @@ -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 = @@ -1551,7 +1543,9 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.createdAt)} + {formatRelativeTime( + thread.updatedAt ?? thread.createdAt, + )} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..3cced8812 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -26,6 +26,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..aacd89240 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -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, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c071fb3f6..f24d1a8d2 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -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; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 516df6046..1e04eac4a 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -20,6 +20,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", latestTurn: null, branch: null, worktreePath: null,