From eac4909740a481ddebef30f26cf248a829987dfa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:32:46 -0700 Subject: [PATCH 1/6] Sort sidebar projects and threads by recency - Add configurable project and thread sidebar sort orders - Keep manual project reordering when enabled - Animate sidebar list updates and cover new sort logic with tests --- apps/web/package.json | 1 + apps/web/src/appSettings.test.ts | 14 + apps/web/src/appSettings.ts | 12 + apps/web/src/components/Sidebar.logic.test.ts | 245 +++++ apps/web/src/components/Sidebar.logic.ts | 96 ++ apps/web/src/components/Sidebar.tsx | 905 ++++++++++-------- apps/web/src/store.test.ts | 14 + apps/web/src/store.ts | 3 + apps/web/src/types.ts | 3 + bun.lock | 3 + 10 files changed, 907 insertions(+), 389 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 69decd3594..2b1759da3d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 16b0a840f8..74d5b29df2 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, getCustomModelOptionsByProvider, @@ -111,6 +113,16 @@ describe("timestamp format defaults", () => { }); }); +describe("sidebar sort defaults", () => { + it("defaults project sorting to updated_at", () => { + expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); + }); + + it("defaults thread sorting to updated_at", () => { + expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); + }); +}); + describe("provider-specific custom models", () => { it("includes provider-specific custom slugs in non-codex model lists", () => { const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); @@ -245,6 +257,8 @@ describe("AppSettingsSchema", () => { defaultThreadEnvMode: "local", confirmThreadDelete: false, enableAssistantStreaming: false, + sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, timestampFormat: DEFAULT_TIMESTAMP_FORMAT, customCodexModels: [], customClaudeModels: [], diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index cb7b5fd9ca..be9c376989 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,12 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); +export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; +export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; +export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); +export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; +export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -58,6 +64,12 @@ export const AppSettingsSchema = Schema.Struct({ confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( + withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), + ), + sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( + withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), + ), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 19d6f30609..b4222db19a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from "vitest"; import { + getProjectSortTimestamp, hasUnseenCompletion, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + sortThreadsForSidebar, } from "./Sidebar.logic"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type Project, + type Thread, +} from "../types"; function makeLatestTurn(overrides?: { completedAt?: string | null; @@ -281,3 +291,238 @@ describe("resolveProjectStatusIndicator", () => { ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" }); }); }); + +function makeProject(overrides: Partial = {}): Project { + return { + id: ProjectId.makeUnsafe("project-1"), + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.4", + expanded: true, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + scripts: [], + ...overrides, + }; +} + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5.4", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("sortThreadsForSidebar", () => { + it("sorts threads by the latest user message in recency mode", () => { + const sorted = sortThreadsForSidebar( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "user", + text: "older", + createdAt: "2026-03-09T10:01:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:01:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [ + { + id: "message-2" as never, + role: "user", + text: "newer", + createdAt: "2026-03-09T10:06:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:06:00.000Z", + }, + ], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + + it("falls back to thread timestamps when there is no user message", () => { + const sorted = sortThreadsForSidebar( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "assistant", + text: "assistant only", + createdAt: "2026-03-09T10:02:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:02:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + + it("can sort threads by createdAt when configured", () => { + const sorted = sortThreadsForSidebar( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + ], + "created_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ]); + }); +}); + +describe("sortProjectsForSidebar", () => { + it("sorts projects by the most recent user message across their threads", () => { + const projects = [ + makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project" }), + makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project" }), + ]; + const threads = [ + makeThread({ + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:20:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "user", + text: "older project user message", + createdAt: "2026-03-09T10:01:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:01:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + projectId: ProjectId.makeUnsafe("project-2"), + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [ + { + id: "message-2" as never, + role: "user", + text: "newer project user message", + createdAt: "2026-03-09T10:05:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:05:00.000Z", + }, + ], + }), + ]; + + const sorted = sortProjectsForSidebar(projects, threads, "updated_at"); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + }); + + it("falls back to project timestamps when a project has no threads", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Older project", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Newer project", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + }); + + it("preserves manual project ordering", () => { + const projects = [ + makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }), + makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "First" }), + ]; + + const sorted = sortProjectsForSidebar(projects, [], "manual"); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + }); + + it("falls back to project timestamps when a project has no threads", () => { + const timestamp = getProjectSortTimestamp( + makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), + [], + "updated_at", + ); + + expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef338dab67..a558fce79f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -8,6 +9,13 @@ import { export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export type SidebarNewThreadEnvMode = "local" | "worktree"; +type SidebarProject = { + id: string; + name: string; + createdAt?: string | undefined; + updatedAt?: string | undefined; +}; +type SidebarThreadSortInput = Pick; export interface ThreadStatusPill { label: @@ -178,3 +186,91 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } + +function toSortableTimestamp(iso: string | undefined): number { + if (!iso) return Number.NEGATIVE_INFINITY; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY; +} + +function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { + let latestUserMessageTimestamp = Number.NEGATIVE_INFINITY; + + for (const message of thread.messages) { + if (message.role !== "user") continue; + latestUserMessageTimestamp = Math.max( + latestUserMessageTimestamp, + toSortableTimestamp(message.createdAt), + ); + } + + if (latestUserMessageTimestamp !== Number.NEGATIVE_INFINITY) { + return latestUserMessageTimestamp; + } + + return toSortableTimestamp(thread.updatedAt ?? thread.createdAt); +} + +export function getThreadSortTimestamp( + thread: SidebarThreadSortInput, + sortOrder: SidebarThreadSortOrder | Exclude, +): number { + if (sortOrder === "created_at") { + return toSortableTimestamp(thread.createdAt); + } + return getLatestUserMessageTimestamp(thread); +} + +export function sortThreadsForSidebar< + T extends Pick, +>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { + return [...threads].toSorted((left, right) => { + const byTimestamp = + getThreadSortTimestamp(right, sortOrder) - getThreadSortTimestamp(left, sortOrder); + if (byTimestamp !== 0) return byTimestamp; + return right.id.localeCompare(left.id); + }); +} + +export function getProjectSortTimestamp( + project: SidebarProject, + projectThreads: readonly SidebarThreadSortInput[], + sortOrder: Exclude, +): number { + if (projectThreads.length > 0) { + return projectThreads.reduce( + (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), + Number.NEGATIVE_INFINITY, + ); + } + + if (sortOrder === "created_at") { + return toSortableTimestamp(project.createdAt); + } + return toSortableTimestamp(project.updatedAt ?? project.createdAt); +} + +export function sortProjectsForSidebar( + projects: readonly TProject[], + threads: readonly TThread[], + sortOrder: SidebarProjectSortOrder, +): TProject[] { + if (sortOrder === "manual") { + return [...projects]; + } + + const threadsByProjectId = new Map(); + for (const thread of threads) { + const existing = threadsByProjectId.get(thread.projectId) ?? []; + existing.push(thread); + threadsByProjectId.set(thread.projectId, existing); + } + + return [...projects].toSorted((left, right) => { + const byTimestamp = + getProjectSortTimestamp(right, threadsByProjectId.get(right.id) ?? [], sortOrder) - + getProjectSortTimestamp(left, threadsByProjectId.get(left.id) ?? [], sortOrder); + if (byTimestamp !== 0) return byTimestamp; + return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + }); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e1d126b06c..15fe5445ce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { ArrowLeftIcon, + ArrowUpDownIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, @@ -10,6 +11,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { autoAnimate } from "@formkit/auto-animate"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { DndContext, @@ -36,7 +38,11 @@ import { } from "@t3tools/contracts"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; -import { useAppSettings } from "../appSettings"; +import { + type SidebarProjectSortOrder, + type SidebarThreadSortOrder, + useAppSettings, +} from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -64,6 +70,7 @@ import { import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; +import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -89,11 +96,26 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + sortThreadsForSidebar, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; +const SIDEBAR_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created", + manual: "Manual", +}; +const SIDEBAR_THREAD_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created", +}; +const SIDEBAR_LIST_ANIMATION_OPTIONS = { + duration: 180, + easing: "ease-out", +} as const; function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); @@ -223,17 +245,97 @@ function ProjectFavicon({ cwd }: { cwd: string }) { ); } -type SortableProjectHandleProps = Pick, "attributes" | "listeners">; +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + onProjectSortOrderChange, + onThreadSortOrderChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; +}) { + return ( + + + + } + > + + + Sort projects + + + +
+ Sort projects +
+ { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
+ +
+ Sort threads +
+ { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
+
+
+ ); +} function SortableProjectItem({ projectId, + disabled = false, children, }: { projectId: ProjectId; + disabled?: boolean; children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = - useSortable({ id: projectId }); + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); return (
  • - {children({ attributes, listeners })} + {children({ attributes, listeners, setActivatorNodeRef })}
  • ); } @@ -272,7 +374,7 @@ export default function Sidebar() { ); const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); - const { settings: appSettings } = useAppSettings(); + const { settings: appSettings, updateSettings } = useAppSettings(); const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, @@ -385,13 +487,10 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (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]; + const latestThread = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === projectId), + appSettings.sidebarThreadSortOrder, + )[0]; if (!latestThread) return; void navigate({ @@ -399,7 +498,7 @@ export default function Sidebar() { params: { threadId: latestThread.id }, }); }, - [navigate, threads], + [appSettings.sidebarThreadSortOrder, navigate, threads], ); const addProjectFromPath = useCallback( @@ -931,6 +1030,10 @@ export default function Sidebar() { const handleProjectDragEnd = useCallback( (event: DragEndEvent) => { + if (appSettings.sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; @@ -939,22 +1042,352 @@ export default function Sidebar() { if (!activeProject || !overProject) return; reorderProjects(activeProject.id, overProject.id); }, - [projects, reorderProjects], + [appSettings.sidebarProjectSortOrder, projects, reorderProjects], ); - const handleProjectDragStart = useCallback((_event: DragStartEvent) => { - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, []); + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (appSettings.sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [appSettings.sidebarProjectSortOrder], + ); const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { dragInProgressRef.current = false; }, []); + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + const handleProjectTitlePointerDownCapture = useCallback(() => { suppressProjectClickAfterDragRef.current = false; }, []); + const sortedProjects = useMemo( + () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder), + [appSettings.sidebarProjectSortOrder, projects, threads], + ); + const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + + function renderProjectItem( + project: (typeof sortedProjects)[number], + dragHandleProps: SortableProjectHandleProps | null, + ) { + const projectThreads = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === project.id), + appSettings.sidebarThreadSortOrder, + ); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ), + ); + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const visibleThreads = + hasHiddenThreads && !isThreadListExpanded + ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) + : projectThreads; + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + + return ( + +
    + handleProjectTitleClick(event, project.id)} + onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + {!project.expanded && projectStatus ? ( + + + + } + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + }); + }} + > + + + } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
    + + + + {visibleThreads.map((thread) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }); + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, + ); + + return ( + + } + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + onClick={(event) => { + handleThreadClick(event, thread.id, orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
    + {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && ( + + + {threadStatus.label} + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
    +
    + {terminalStatus && ( + + + + )} + + {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + +
    +
    +
    + ); + })} + + {hasHiddenThreads && !isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + expandThreadListForProject(project.id); + }} + > + Show more + + + )} + {hasHiddenThreads && isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )} +
    +
    +
    + ); + } + const handleProjectTitleClick = useCallback( (event: React.MouseEvent, projectId: ProjectId) => { if (dragInProgressRef.current) { @@ -1063,12 +1496,9 @@ export default function Sidebar() { : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; - const newThreadShortcutLabel = useMemo( - () => - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"), - [keybindings], - ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1232,28 +1662,40 @@ export default function Sidebar() { Projects - - + { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }} + onThreadSortOrderChange={(sortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }} + /> + + + } + > + - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + + {shouldShowProjectPathEntry && ( @@ -1321,352 +1763,37 @@ export default function Sidebar() { )} - - - project.id)} - strategy={verticalListSortingStrategy} - > - {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); - }); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ), - ); - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - - return ( + {isManualProjectSorting ? ( + + + project.id)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( - {(dragHandleProps) => ( - -
    - handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - {!project.expanded && projectStatus ? ( - - - - } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id, { - envMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - }); - }} - > - - - } - /> - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    - - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: - derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: - derivePendingUserInputs(thread.activities).length > 0, - }); - const prStatus = prStatusIndicator( - prByThreadId.get(thread.id) ?? null, - ); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - - } - size="sm" - isActive={isActive} - className={resolveThreadRowClassName({ - isActive, - isSelected, - })} - onClick={(event) => { - handleThreadClick( - event, - thread.id, - orderedProjectThreadIds, - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - if ( - selectedThreadIds.size > 0 && - selectedThreadIds.has(thread.id) - ) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
    - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - - {prStatus.tooltip} - - - )} - {threadStatus && ( - - - - {threadStatus.label} - - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} -
    -
    - {terminalStatus && ( - - - - )} - - {formatRelativeTime(thread.createdAt)} - -
    -
    -
    - ); - })} - - {hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} -
    -
    -
    - )} + {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}
    - ); - })} -
    + ))} +
    +
    +
    + ) : ( + + {sortedProjects.map((project) => ( + + {renderProjectItem(project, null)} + + ))} - + )} {projects.length === 0 && !shouldShowProjectPathEntry && (
    diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index f1919ec724..f2d5a5be4c 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -226,6 +226,20 @@ describe("store read model sync", () => { expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); }); + it("preserves project and thread updatedAt timestamps from the read model", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + updatedAt: "2026-02-27T00:05:00.000Z", + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); + expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); + }); + it("preserves the current project order when syncing incoming read model updates", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 8269b30a65..9784aa972b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -145,6 +145,8 @@ function mapProjectsFromReadModel( (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), + createdAt: project.createdAt, + updatedAt: project.updatedAt, scripts: project.scripts.map((script) => ({ ...script })), } satisfies Project; }); @@ -304,6 +306,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 32a7fe02b7..03e6665c0c 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,6 +82,8 @@ export interface Project { cwd: string; model: string; expanded: boolean; + createdAt?: string | undefined; + updatedAt?: string | undefined; scripts: ProjectScript[]; } @@ -98,6 +100,7 @@ export interface Thread { proposedPlans: ProposedPlan[]; error: string | null; createdAt: string; + updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; lastVisitedAt?: string | undefined; branch: string | null; diff --git a/bun.lock b/bun.lock index 4e1959c157..e4db9faca1 100644 --- a/bun.lock +++ b/bun.lock @@ -80,6 +80,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", @@ -358,6 +359,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], From d9ef0ca2efc4f871a13fd04afa92f823aba97531 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:39:53 -0700 Subject: [PATCH 2/6] Optimize sidebar sorting and favicon reuse - Avoid cloning thread arrays before sorting - Cache loaded project favicons across sidebar renders - Update sidebar sort labels for clearer wording --- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 23 ++++++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a558fce79f..4216061787 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -224,7 +224,7 @@ export function getThreadSortTimestamp( export function sortThreadsForSidebar< T extends Pick, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return [...threads].toSorted((left, right) => { + return threads.toSorted((left, right) => { const byTimestamp = getThreadSortTimestamp(right, sortOrder) - getThreadSortTimestamp(left, sortOrder); if (byTimestamp !== 0) return byTimestamp; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 15fe5445ce..50ae01091a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -105,17 +105,18 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", - created_at: "Created", + created_at: "Created at", manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", - created_at: "Created", + created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; +const loadedProjectFaviconSrcs = new Set(); function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); @@ -226,9 +227,10 @@ function getServerHttpOrigin(): string { const serverHttpOrigin = getServerHttpOrigin(); function ProjectFavicon({ cwd }: { cwd: string }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); - const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; + const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => + loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + ); if (status === "error") { return ; @@ -239,7 +241,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) { src={src} alt="" className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`} - onLoad={() => setStatus("loaded")} + onLoad={() => { + loadedProjectFaviconSrcs.add(src); + setStatus("loaded"); + }} onError={() => setStatus("error")} /> ); @@ -275,7 +280,7 @@ function ProjectSortMenu({ -
    +
    Sort projects
    {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( - + {label} ), @@ -294,7 +299,7 @@ function ProjectSortMenu({ -
    +
    Sort threads
    ).map(([value, label]) => ( - + {label} ))} From 04a4545bbbba7198b2588c0fcb8a12ddb2c9aea7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:57:22 -0700 Subject: [PATCH 3/6] Show active thread in collapsed sidebar previews - Keep the current thread visible in folded project thread lists - Extract shared visible-thread selection logic and cover it with tests --- apps/web/src/components/Sidebar.logic.test.ts | 50 +++ apps/web/src/components/Sidebar.logic.ts | 43 +++ apps/web/src/components/Sidebar.tsx | 330 +++++++++--------- 3 files changed, 263 insertions(+), 160 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b4222db19a..874b14ba40 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, resolveProjectStatusIndicator, @@ -292,6 +293,55 @@ describe("resolveProjectStatusIndicator", () => { }); }); +describe("getVisibleThreadsForProject", () => { + it("includes the active thread even when it falls below the folded preview", () => { + const threads = Array.from({ length: 8 }, (_, index) => + makeThread({ + id: ThreadId.makeUnsafe(`thread-${index + 1}`), + title: `Thread ${index + 1}`, + }), + ); + + const result = getVisibleThreadsForProject({ + threads, + activeThreadId: ThreadId.makeUnsafe("thread-8"), + isThreadListExpanded: false, + previewLimit: 6, + }); + + expect(result.hasHiddenThreads).toBe(true); + expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-4"), + ThreadId.makeUnsafe("thread-5"), + ThreadId.makeUnsafe("thread-6"), + ThreadId.makeUnsafe("thread-8"), + ]); + }); + + it("returns all threads when the list is expanded", () => { + const threads = Array.from({ length: 8 }, (_, index) => + makeThread({ + id: ThreadId.makeUnsafe(`thread-${index + 1}`), + }), + ); + + const result = getVisibleThreadsForProject({ + threads, + activeThreadId: ThreadId.makeUnsafe("thread-8"), + isThreadListExpanded: true, + previewLimit: 6, + }); + + expect(result.hasHiddenThreads).toBe(true); + expect(result.visibleThreads.map((thread) => thread.id)).toEqual( + threads.map((thread) => thread.id), + ); + }); +}); + function makeProject(overrides: Partial = {}): Project { return { id: ProjectId.makeUnsafe("project-1"), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4216061787..7891987471 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -187,6 +187,49 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } +export function getVisibleThreadsForProject(input: { + threads: readonly Thread[]; + activeThreadId: Thread["id"] | undefined; + isThreadListExpanded: boolean; + previewLimit: number; +}): { + hasHiddenThreads: boolean; + visibleThreads: Thread[]; +} { + const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; + const hasHiddenThreads = threads.length > previewLimit; + + if (!hasHiddenThreads || isThreadListExpanded) { + return { + hasHiddenThreads, + visibleThreads: [...threads], + }; + } + + const previewThreads = threads.slice(0, previewLimit); + if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { + return { + hasHiddenThreads: true, + visibleThreads: previewThreads, + }; + } + + const activeThread = threads.find((thread) => thread.id === activeThreadId); + if (!activeThread) { + return { + hasHiddenThreads: true, + visibleThreads: previewThreads, + }; + } + + const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); + + return { + hasHiddenThreads: true, + visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), + }; +} + function toSortableTimestamp(iso: string | undefined): number { if (!iso) return Number.NEGATIVE_INFINITY; const ms = Date.parse(iso); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 50ae01091a..a8a58a13b2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + getVisibleThreadsForProject, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -1110,16 +1111,175 @@ export default function Sidebar() { }), ), ); + const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; + const pinnedCollapsedThread = + !project.expanded && activeThreadId + ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + : null; + const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadId, + isThreadListExpanded, + previewLimit: THREAD_PREVIEW_LIMIT, + }); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; + const renderThreadRow = (thread: (typeof projectThreads)[number]) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }); + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, + ); + + return ( + + } + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + onClick={(event) => { + handleThreadClick(event, thread.id, orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
    + {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && ( + + + {threadStatus.label} + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
    +
    + {terminalStatus && ( + + + + )} + + {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + +
    +
    +
    + ); + }; return ( - +
    - + - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }); - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, - ); - - return ( - - } - size="sm" - isActive={isActive} - className={resolveThreadRowClassName({ - isActive, - isSelected, - })} - onClick={(event) => { - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
    - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( - - - {threadStatus.label} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {thread.title} - )} -
    -
    - {terminalStatus && ( - - - - )} - - {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} - -
    -
    -
    - ); - })} + {renderedThreads.map((thread) => renderThreadRow(thread))} - {hasHiddenThreads && !isThreadListExpanded && ( + {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( } @@ -1372,7 +1382,7 @@ export default function Sidebar() { )} - {hasHiddenThreads && isThreadListExpanded && ( + {project.expanded && hasHiddenThreads && isThreadListExpanded && ( } From 1f96d6da926e205ba78c2068d5b41e5e17f5b793 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:59:50 -0700 Subject: [PATCH 4/6] Handle missing sort timestamps in sidebar ordering - Treat missing or invalid timestamps as absent instead of zero - Fall back to name and id ordering when projects have no sortable dates - Add coverage for timestamp-less project sorting --- apps/web/src/components/Sidebar.logic.test.ts | 26 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 43 ++++++++++++------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 874b14ba40..cb5866eb3c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -552,6 +552,32 @@ describe("sortProjectsForSidebar", () => { ]); }); + it("falls back to name and id ordering when projects have no sortable timestamps", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Beta", + createdAt: undefined, + updatedAt: undefined, + }), + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Alpha", + createdAt: undefined, + updatedAt: undefined, + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + it("preserves manual project ordering", () => { const projects = [ makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7891987471..f25a116446 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -230,36 +230,38 @@ export function getVisibleThreadsForProject(input: { }; } -function toSortableTimestamp(iso: string | undefined): number { - if (!iso) return Number.NEGATIVE_INFINITY; +function toSortableTimestamp(iso: string | undefined): number | null { + if (!iso) return null; const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY; + return Number.isFinite(ms) ? ms : null; } function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - let latestUserMessageTimestamp = Number.NEGATIVE_INFINITY; + let latestUserMessageTimestamp: number | null = null; for (const message of thread.messages) { if (message.role !== "user") continue; - latestUserMessageTimestamp = Math.max( - latestUserMessageTimestamp, - toSortableTimestamp(message.createdAt), - ); + const messageTimestamp = toSortableTimestamp(message.createdAt); + if (messageTimestamp === null) continue; + latestUserMessageTimestamp = + latestUserMessageTimestamp === null + ? messageTimestamp + : Math.max(latestUserMessageTimestamp, messageTimestamp); } - if (latestUserMessageTimestamp !== Number.NEGATIVE_INFINITY) { + if (latestUserMessageTimestamp !== null) { return latestUserMessageTimestamp; } - return toSortableTimestamp(thread.updatedAt ?? thread.createdAt); + return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; } -export function getThreadSortTimestamp( +function getThreadSortTimestamp( thread: SidebarThreadSortInput, sortOrder: SidebarThreadSortOrder | Exclude, ): number { if (sortOrder === "created_at") { - return toSortableTimestamp(thread.createdAt); + return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; } return getLatestUserMessageTimestamp(thread); } @@ -288,9 +290,9 @@ export function getProjectSortTimestamp( } if (sortOrder === "created_at") { - return toSortableTimestamp(project.createdAt); + return toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY; } - return toSortableTimestamp(project.updatedAt ?? project.createdAt); + return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; } export function sortProjectsForSidebar( @@ -310,9 +312,18 @@ export function sortProjectsForSidebar { + const rightTimestamp = getProjectSortTimestamp( + right, + threadsByProjectId.get(right.id) ?? [], + sortOrder, + ); + const leftTimestamp = getProjectSortTimestamp( + left, + threadsByProjectId.get(left.id) ?? [], + sortOrder, + ); const byTimestamp = - getProjectSortTimestamp(right, threadsByProjectId.get(right.id) ?? [], sortOrder) - - getProjectSortTimestamp(left, threadsByProjectId.get(left.id) ?? [], sortOrder); + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); }); From 554d063077b201837c8558b5597716de492eb039 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 12:08:00 -0700 Subject: [PATCH 5/6] Clarify project timestamp fallback test - Rename the sort timestamp test to better describe the no-threads fallback - No behavior change --- apps/web/src/components/Sidebar.logic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cb5866eb3c..62764c442a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -592,7 +592,7 @@ describe("sortProjectsForSidebar", () => { ]); }); - it("falls back to project timestamps when a project has no threads", () => { + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), [], From a3bb36cd61721c6813e2d9a91b4cf5c6e5031e12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 12:11:04 -0700 Subject: [PATCH 6/6] Stabilize sidebar thread sorting fallback - Preserve deterministic id ordering when timestamps are missing - Add coverage for updated_at sort fallback --- apps/web/src/components/Sidebar.logic.test.ts | 25 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 62764c442a..6925b5391c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -458,6 +458,31 @@ describe("sortThreadsForSidebar", () => { ]); }); + it("falls back to id ordering when threads have no sortable timestamps", () => { + const sorted = sortThreadsForSidebar( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + it("can sort threads by createdAt when configured", () => { const sorted = sortThreadsForSidebar( [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f25a116446..9ce12d3b8c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -270,8 +270,10 @@ export function sortThreadsForSidebar< T extends Pick, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { + const rightTimestamp = getThreadSortTimestamp(right, sortOrder); + const leftTimestamp = getThreadSortTimestamp(left, sortOrder); const byTimestamp = - getThreadSortTimestamp(right, sortOrder) - getThreadSortTimestamp(left, sortOrder); + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; return right.id.localeCompare(left.id); });