History

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

Julius Marminge committed 7 months ago

Commit eac49091 parent 529315b10 files · +907 −389Browse files

Unified · Split

10 files
  1. apps/web/package.json +1 −0
  2. apps/web/src/appSettings.test.ts +14 −0
  3. apps/web/src/appSettings.ts +12 −0
  4. apps/web/src/components/Sidebar.logic.test.ts +245 −0
  5. apps/web/src/components/Sidebar.logic.ts +96 −0
  6. apps/web/src/components/Sidebar.tsx +516 −389
  7. apps/web/src/store.test.ts +14 −0
  8. apps/web/src/store.ts +3 −0
  9. apps/web/src/types.ts +3 −0
  10. bun.lock +3 −0
apps/web/package.json+1−0
@@ -19,6 +19,7 @@ · 18 unmodified lines
1919 "@dnd-kit/modifiers": "^9.0.0",
2020 "@dnd-kit/sortable": "^10.0.0",
2121 "@dnd-kit/utilities": "^3.2.2",
22+ "@formkit/auto-animate": "^0.9.0",
2223 "@lexical/react": "^0.41.0",
2324 "@pierre/diffs": "^1.1.0-beta.16",
2425 "@t3tools/contracts": "workspace:*",
apps/web/src/appSettings.test.ts+14−0
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
33
44 import {
55 AppSettingsSchema,
6+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
7+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
68 DEFAULT_TIMESTAMP_FORMAT,
79 getAppModelOptions,
810 getCustomModelOptionsByProvider,
@@ -111,6 +113,16 @@ describe("timestamp format defaults", () => { · 102 unmodified lines
111113 });
112114 });
113115
116+describe("sidebar sort defaults", () => {
117+ it("defaults project sorting to updated_at", () => {
118+ expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at");
119+ });
120+
121+ it("defaults thread sorting to updated_at", () => {
122+ expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at");
123+ });
124+});
125+
114126 describe("provider-specific custom models", () => {
115127 it("includes provider-specific custom slugs in non-codex model lists", () => {
116128 const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]);
@@ -245,6 +257,8 @@ describe("AppSettingsSchema", () => { · 128 unmodified lines
245257 defaultThreadEnvMode: "local",
246258 confirmThreadDelete: false,
247259 enableAssistantStreaming: false,
260+ sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
261+ sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
248262 timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
249263 customCodexModels: [],
250264 customClaudeModels: [],
apps/web/src/appSettings.ts+12−0
@@ -21,6 +21,12 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; · 20 unmodified lines
2121 export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2222 export type TimestampFormat = typeof TimestampFormat.Type;
2323 export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
24+export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
25+export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
26+export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
27+export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
28+export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
29+export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
2430 type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
2531 export type ProviderCustomModelConfig = {
2632 provider: ProviderKind;
@@ -58,6 +64,12 @@ export const AppSettingsSchema = Schema.Struct({ · 31 unmodified lines
5864 confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
5965 diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
6066 enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
67+ sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
68+ withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),
69+ ),
70+ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
71+ withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER),
72+ ),
6173 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
6274 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
6375 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
apps/web/src/components/Sidebar.logic.test.ts+245−0
@@ -1,13 +1,23 @@
11 import { describe, expect, it } from "vitest";
22
33 import {
4+ getProjectSortTimestamp,
45 hasUnseenCompletion,
56 resolveProjectStatusIndicator,
67 resolveSidebarNewThreadEnvMode,
78 resolveThreadRowClassName,
89 resolveThreadStatusPill,
910 shouldClearThreadSelectionOnMouseDown,
11+ sortProjectsForSidebar,
12+ sortThreadsForSidebar,
1013 } from "./Sidebar.logic";
14+import { ProjectId, ThreadId } from "@t3tools/contracts";
15+import {
16+ DEFAULT_INTERACTION_MODE,
17+ DEFAULT_RUNTIME_MODE,
18+ type Project,
19+ type Thread,
20+} from "../types";
1121
1222 function makeLatestTurn(overrides?: {
1323 completedAt?: string | null;
@@ -281,3 +291,238 @@ describe("resolveProjectStatusIndicator", () => { · 267 unmodified lines
281291 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });
282292 });
283293 });
294+
295+function makeProject(overrides: Partial<Project> = {}): Project {
296+ return {
297+ id: ProjectId.makeUnsafe("project-1"),
298+ name: "Project",
299+ cwd: "/tmp/project",
300+ model: "gpt-5.4",
301+ expanded: true,
302+ createdAt: "2026-03-09T10:00:00.000Z",
303+ updatedAt: "2026-03-09T10:00:00.000Z",
304+ scripts: [],
305+ ...overrides,
306+ };
307+}
308+
309+function makeThread(overrides: Partial<Thread> = {}): Thread {
310+ return {
311+ id: ThreadId.makeUnsafe("thread-1"),
312+ codexThreadId: null,
313+ projectId: ProjectId.makeUnsafe("project-1"),
314+ title: "Thread",
315+ model: "gpt-5.4",
316+ runtimeMode: DEFAULT_RUNTIME_MODE,
317+ interactionMode: DEFAULT_INTERACTION_MODE,
318+ session: null,
319+ messages: [],
320+ proposedPlans: [],
321+ error: null,
322+ createdAt: "2026-03-09T10:00:00.000Z",
323+ updatedAt: "2026-03-09T10:00:00.000Z",
324+ latestTurn: null,
325+ branch: null,
326+ worktreePath: null,
327+ turnDiffSummaries: [],
328+ activities: [],
329+ ...overrides,
330+ };
331+}
332+
333+describe("sortThreadsForSidebar", () => {
334+ it("sorts threads by the latest user message in recency mode", () => {
335+ const sorted = sortThreadsForSidebar(
336+ [
337+ makeThread({
338+ id: ThreadId.makeUnsafe("thread-1"),
339+ createdAt: "2026-03-09T10:00:00.000Z",
340+ updatedAt: "2026-03-09T10:10:00.000Z",
341+ messages: [
342+ {
343+ id: "message-1" as never,
344+ role: "user",
345+ text: "older",
346+ createdAt: "2026-03-09T10:01:00.000Z",
347+ streaming: false,
348+ completedAt: "2026-03-09T10:01:00.000Z",
349+ },
350+ ],
351+ }),
352+ makeThread({
353+ id: ThreadId.makeUnsafe("thread-2"),
354+ createdAt: "2026-03-09T10:05:00.000Z",
355+ updatedAt: "2026-03-09T10:05:00.000Z",
356+ messages: [
357+ {
358+ id: "message-2" as never,
359+ role: "user",
360+ text: "newer",
361+ createdAt: "2026-03-09T10:06:00.000Z",
362+ streaming: false,
363+ completedAt: "2026-03-09T10:06:00.000Z",
364+ },
365+ ],
366+ }),
367+ ],
368+ "updated_at",
369+ );
370+
371+ expect(sorted.map((thread) => thread.id)).toEqual([
372+ ThreadId.makeUnsafe("thread-2"),
373+ ThreadId.makeUnsafe("thread-1"),
374+ ]);
375+ });
376+
377+ it("falls back to thread timestamps when there is no user message", () => {
378+ const sorted = sortThreadsForSidebar(
379+ [
380+ makeThread({
381+ id: ThreadId.makeUnsafe("thread-1"),
382+ createdAt: "2026-03-09T10:00:00.000Z",
383+ updatedAt: "2026-03-09T10:01:00.000Z",
384+ messages: [
385+ {
386+ id: "message-1" as never,
387+ role: "assistant",
388+ text: "assistant only",
389+ createdAt: "2026-03-09T10:02:00.000Z",
390+ streaming: false,
391+ completedAt: "2026-03-09T10:02:00.000Z",
392+ },
393+ ],
394+ }),
395+ makeThread({
396+ id: ThreadId.makeUnsafe("thread-2"),
397+ createdAt: "2026-03-09T10:05:00.000Z",
398+ updatedAt: "2026-03-09T10:05:00.000Z",
399+ messages: [],
400+ }),
401+ ],
402+ "updated_at",
403+ );
404+
405+ expect(sorted.map((thread) => thread.id)).toEqual([
406+ ThreadId.makeUnsafe("thread-2"),
407+ ThreadId.makeUnsafe("thread-1"),
408+ ]);
409+ });
410+
411+ it("can sort threads by createdAt when configured", () => {
412+ const sorted = sortThreadsForSidebar(
413+ [
414+ makeThread({
415+ id: ThreadId.makeUnsafe("thread-1"),
416+ createdAt: "2026-03-09T10:05:00.000Z",
417+ updatedAt: "2026-03-09T10:05:00.000Z",
418+ }),
419+ makeThread({
420+ id: ThreadId.makeUnsafe("thread-2"),
421+ createdAt: "2026-03-09T10:00:00.000Z",
422+ updatedAt: "2026-03-09T10:10:00.000Z",
423+ }),
424+ ],
425+ "created_at",
426+ );
427+
428+ expect(sorted.map((thread) => thread.id)).toEqual([
429+ ThreadId.makeUnsafe("thread-1"),
430+ ThreadId.makeUnsafe("thread-2"),
431+ ]);
432+ });
433+});
434+
435+describe("sortProjectsForSidebar", () => {
436+ it("sorts projects by the most recent user message across their threads", () => {
437+ const projects = [
438+ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project" }),
439+ makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project" }),
440+ ];
441+ const threads = [
442+ makeThread({
443+ projectId: ProjectId.makeUnsafe("project-1"),
444+ updatedAt: "2026-03-09T10:20:00.000Z",
445+ messages: [
446+ {
447+ id: "message-1" as never,
448+ role: "user",
449+ text: "older project user message",
450+ createdAt: "2026-03-09T10:01:00.000Z",
451+ streaming: false,
452+ completedAt: "2026-03-09T10:01:00.000Z",
453+ },
454+ ],
455+ }),
456+ makeThread({
457+ id: ThreadId.makeUnsafe("thread-2"),
458+ projectId: ProjectId.makeUnsafe("project-2"),
459+ updatedAt: "2026-03-09T10:05:00.000Z",
460+ messages: [
461+ {
462+ id: "message-2" as never,
463+ role: "user",
464+ text: "newer project user message",
465+ createdAt: "2026-03-09T10:05:00.000Z",
466+ streaming: false,
467+ completedAt: "2026-03-09T10:05:00.000Z",
468+ },
469+ ],
470+ }),
471+ ];
472+
473+ const sorted = sortProjectsForSidebar(projects, threads, "updated_at");
474+
475+ expect(sorted.map((project) => project.id)).toEqual([
476+ ProjectId.makeUnsafe("project-2"),
477+ ProjectId.makeUnsafe("project-1"),
478+ ]);
479+ });
480+
481+ it("falls back to project timestamps when a project has no threads", () => {
482+ const sorted = sortProjectsForSidebar(
483+ [
484+ makeProject({
485+ id: ProjectId.makeUnsafe("project-1"),
486+ name: "Older project",
487+ updatedAt: "2026-03-09T10:01:00.000Z",
488+ }),
489+ makeProject({
490+ id: ProjectId.makeUnsafe("project-2"),
491+ name: "Newer project",
492+ updatedAt: "2026-03-09T10:05:00.000Z",
493+ }),
494+ ],
495+ [],
496+ "updated_at",
497+ );
498+
499+ expect(sorted.map((project) => project.id)).toEqual([
500+ ProjectId.makeUnsafe("project-2"),
501+ ProjectId.makeUnsafe("project-1"),
502+ ]);
503+ });
504+
505+ it("preserves manual project ordering", () => {
506+ const projects = [
507+ makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }),
508+ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "First" }),
509+ ];
510+
511+ const sorted = sortProjectsForSidebar(projects, [], "manual");
512+
513+ expect(sorted.map((project) => project.id)).toEqual([
514+ ProjectId.makeUnsafe("project-2"),
515+ ProjectId.makeUnsafe("project-1"),
516+ ]);
517+ });
518+
519+ it("falls back to project timestamps when a project has no threads", () => {
520+ const timestamp = getProjectSortTimestamp(
521+ makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
522+ [],
523+ "updated_at",
524+ );
525+
526+ expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
527+ });
528+});
apps/web/src/components/Sidebar.logic.ts+96−0
@@ -1,3 +1,4 @@
1+import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings";
12 import type { Thread } from "../types";
23 import { cn } from "../lib/utils";
34 import {
@@ -8,6 +9,13 @@ import { · 4 unmodified lines
89
910 export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
1011 export type SidebarNewThreadEnvMode = "local" | "worktree";
12+type SidebarProject = {
13+ id: string;
14+ name: string;
15+ createdAt?: string | undefined;
16+ updatedAt?: string | undefined;
17+};
18+type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
1119
1220 export interface ThreadStatusPill {
1321 label:
@@ -178,3 +186,91 @@ export function resolveProjectStatusIndicator( · 164 unmodified lines
178186
179187 return highestPriorityStatus;
180188 }
189+
190+function toSortableTimestamp(iso: string | undefined): number {
191+ if (!iso) return Number.NEGATIVE_INFINITY;
192+ const ms = Date.parse(iso);
193+ return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
194+}
195+
196+function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {
197+ let latestUserMessageTimestamp = Number.NEGATIVE_INFINITY;
198+
199+ for (const message of thread.messages) {
200+ if (message.role !== "user") continue;
201+ latestUserMessageTimestamp = Math.max(
202+ latestUserMessageTimestamp,
203+ toSortableTimestamp(message.createdAt),
204+ );
205+ }
206+
207+ if (latestUserMessageTimestamp !== Number.NEGATIVE_INFINITY) {
208+ return latestUserMessageTimestamp;
209+ }
210+
211+ return toSortableTimestamp(thread.updatedAt ?? thread.createdAt);
212+}
213+
214+export function getThreadSortTimestamp(
215+ thread: SidebarThreadSortInput,
216+ sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
217+): number {
218+ if (sortOrder === "created_at") {
219+ return toSortableTimestamp(thread.createdAt);
220+ }
221+ return getLatestUserMessageTimestamp(thread);
222+}
223+
224+export function sortThreadsForSidebar<
225+ T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "messages">,
226+>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {
227+ return [...threads].toSorted((left, right) => {
228+ const byTimestamp =
229+ getThreadSortTimestamp(right, sortOrder) - getThreadSortTimestamp(left, sortOrder);
230+ if (byTimestamp !== 0) return byTimestamp;
231+ return right.id.localeCompare(left.id);
232+ });
233+}
234+
235+export function getProjectSortTimestamp(
236+ project: SidebarProject,
237+ projectThreads: readonly SidebarThreadSortInput[],
238+ sortOrder: Exclude<SidebarProjectSortOrder, "manual">,
239+): number {
240+ if (projectThreads.length > 0) {
241+ return projectThreads.reduce(
242+ (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
243+ Number.NEGATIVE_INFINITY,
244+ );
245+ }
246+
247+ if (sortOrder === "created_at") {
248+ return toSortableTimestamp(project.createdAt);
249+ }
250+ return toSortableTimestamp(project.updatedAt ?? project.createdAt);
251+}
252+
253+export function sortProjectsForSidebar<TProject extends SidebarProject, TThread extends Thread>(
254+ projects: readonly TProject[],
255+ threads: readonly TThread[],
256+ sortOrder: SidebarProjectSortOrder,
257+): TProject[] {
258+ if (sortOrder === "manual") {
259+ return [...projects];
260+ }
261+
262+ const threadsByProjectId = new Map<string, TThread[]>();
263+ for (const thread of threads) {
264+ const existing = threadsByProjectId.get(thread.projectId) ?? [];
265+ existing.push(thread);
266+ threadsByProjectId.set(thread.projectId, existing);
267+ }
268+
269+ return [...projects].toSorted((left, right) => {
270+ const byTimestamp =
271+ getProjectSortTimestamp(right, threadsByProjectId.get(right.id) ?? [], sortOrder) -
272+ getProjectSortTimestamp(left, threadsByProjectId.get(left.id) ?? [], sortOrder);
273+ if (byTimestamp !== 0) return byTimestamp;
274+ return left.name.localeCompare(right.name) || left.id.localeCompare(right.id);
275+ });
276+}
apps/web/src/components/Sidebar.tsx+516−389
@@ -1,5 +1,6 @@
11 import {
22 ArrowLeftIcon,
3+ ArrowUpDownIcon,
34 ChevronRightIcon,
45 FolderIcon,
56 GitPullRequestIcon,
@@ -10,6 +11,7 @@ import { · 4 unmodified lines
1011 TerminalIcon,
1112 TriangleAlertIcon,
1213 } from "lucide-react";
14+import { autoAnimate } from "@formkit/auto-animate";
1315 import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
1416 import {
1517 DndContext,
@@ -36,7 +38,11 @@ import { · 20 unmodified lines
3638 } from "@t3tools/contracts";
3739 import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3840 import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
39−import { useAppSettings } from "../appSettings";
41+import {
42+ type SidebarProjectSortOrder,
43+ type SidebarThreadSortOrder,
44+ useAppSettings,
45+} from "../appSettings";
4046 import { isElectron } from "../env";
4147 import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
4248 import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
@@ -64,6 +70,7 @@ import { · 21 unmodified lines
6470 import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
6571 import { Button } from "./ui/button";
6672 import { Collapsible, CollapsibleContent } from "./ui/collapsible";
73+import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
6774 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6875 import {
6976 SidebarContent,
@@ -89,11 +96,26 @@ import { · 19 unmodified lines
8996 resolveThreadRowClassName,
9097 resolveThreadStatusPill,
9198 shouldClearThreadSelectionOnMouseDown,
99+ sortProjectsForSidebar,
100+ sortThreadsForSidebar,
92101 } from "./Sidebar.logic";
93102 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
94103
95104 const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
96105 const THREAD_PREVIEW_LIMIT = 6;
106+const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
107+ updated_at: "Last user message",
108+ created_at: "Created",
109+ manual: "Manual",
110+};
111+const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
112+ updated_at: "Last user message",
113+ created_at: "Created",
114+};
115+const SIDEBAR_LIST_ANIMATION_OPTIONS = {
116+ duration: 180,
117+ easing: "ease-out",
118+} as const;
97119
98120 function formatRelativeTime(iso: string): string {
99121 const diff = Date.now() - new Date(iso).getTime();
@@ -223,17 +245,97 @@ function ProjectFavicon({ cwd }: { cwd: string }) { · 123 unmodified lines
223245 );
224246 }
225247
226−type SortableProjectHandleProps = Pick<ReturnType<typeof useSortable>, "attributes" | "listeners">;
248+type SortableProjectHandleProps = Pick<
249+ ReturnType<typeof useSortable>,
250+ "attributes" | "listeners" | "setActivatorNodeRef"
251+>;
252+
253+function ProjectSortMenu({
254+ projectSortOrder,
255+ threadSortOrder,
256+ onProjectSortOrderChange,
257+ onThreadSortOrderChange,
258+}: {
259+ projectSortOrder: SidebarProjectSortOrder;
260+ threadSortOrder: SidebarThreadSortOrder;
261+ onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
262+ onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
263+}) {
264+ return (
265+ <Menu>
266+ <Tooltip>
267+ <TooltipTrigger
268+ render={
269+ <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
270+ }
271+ >
272+ <ArrowUpDownIcon className="size-3.5" />
273+ </TooltipTrigger>
274+ <TooltipPopup side="right">Sort projects</TooltipPopup>
275+ </Tooltip>
276+ <MenuPopup align="end" side="bottom" className="min-w-44">
277+ <MenuGroup>
278+ <div className="px-2 py-1 text-[11px] font-medium text-muted-foreground">
279+ Sort projects
280+ </div>
281+ <MenuRadioGroup
282+ value={projectSortOrder}
283+ onValueChange={(value) => {
284+ onProjectSortOrderChange(value as SidebarProjectSortOrder);
285+ }}
286+ >
287+ {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
288+ ([value, label]) => (
289+ <MenuRadioItem key={value} value={value} className="min-h-7 py-1 text-xs">
290+ {label}
291+ </MenuRadioItem>
292+ ),
293+ )}
294+ </MenuRadioGroup>
295+ </MenuGroup>
296+ <MenuGroup>
297+ <div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground">
298+ Sort threads
299+ </div>
300+ <MenuRadioGroup
301+ value={threadSortOrder}
302+ onValueChange={(value) => {
303+ onThreadSortOrderChange(value as SidebarThreadSortOrder);
304+ }}
305+ >
306+ {(
307+ Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
308+ ).map(([value, label]) => (
309+ <MenuRadioItem key={value} value={value} className="min-h-7 py-1 text-xs">
310+ {label}
311+ </MenuRadioItem>
312+ ))}
313+ </MenuRadioGroup>
314+ </MenuGroup>
315+ </MenuPopup>
316+ </Menu>
317+ );
318+}
227319
228320 function SortableProjectItem({
229321 projectId,
322+ disabled = false,
230323 children,
231324 }: {
232325 projectId: ProjectId;
326+ disabled?: boolean;
233327 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
234328 }) {
235− const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =
236− useSortable({ id: projectId });
329+ const {
330+ attributes,
331+ listeners,
332+ setActivatorNodeRef,
333+ setNodeRef,
334+ transform,
335+ transition,
336+ isDragging,
337+ isOver,
338+ } = useSortable({ id: projectId, disabled });
237339 return (
238340 <li
239341 ref={setNodeRef}
@@ -247,7 +349,7 @@ function SortableProjectItem({ · 7 unmodified lines
247349 data-sidebar="menu-item"
248350 data-slot="sidebar-menu-item"
249351 >
250− {children({ attributes, listeners })}
352+ {children({ attributes, listeners, setActivatorNodeRef })}
251353 </li>
252354 );
253355 }
@@ -272,7 +374,7 @@ export default function Sidebar() { · 18 unmodified lines
272374 );
273375 const navigate = useNavigate();
274376 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
275− const { settings: appSettings } = useAppSettings();
377+ const { settings: appSettings, updateSettings } = useAppSettings();
276378 const { handleNewThread } = useHandleNewThread();
277379 const routeThreadId = useParams({
278380 strict: false,
@@ -385,13 +487,10 @@ export default function Sidebar() { · 106 unmodified lines
385487
386488 const focusMostRecentThreadForProject = useCallback(
387489 (projectId: ProjectId) => {
388− const latestThread = threads
389− .filter((thread) => thread.projectId === projectId)
390− .toSorted((a, b) => {
391− const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
392− if (byDate !== 0) return byDate;
393− return b.id.localeCompare(a.id);
394− })[0];
490+ const latestThread = sortThreadsForSidebar(
491+ threads.filter((thread) => thread.projectId === projectId),
492+ appSettings.sidebarThreadSortOrder,
493+ )[0];
395494 if (!latestThread) return;
396495
397496 void navigate({
@@ -399,7 +498,7 @@ export default function Sidebar() { · 1 unmodified line
399498 params: { threadId: latestThread.id },
400499 });
401500 },
402− [navigate, threads],
501+ [appSettings.sidebarThreadSortOrder, navigate, threads],
403502 );
404503
405504 const addProjectFromPath = useCallback(
@@ -931,6 +1030,10 @@ export default function Sidebar() { · 525 unmodified lines
9311030
9321031 const handleProjectDragEnd = useCallback(
9331032 (event: DragEndEvent) => {
1033+ if (appSettings.sidebarProjectSortOrder !== "manual") {
1034+ dragInProgressRef.current = false;
1035+ return;
1036+ }
9341037 dragInProgressRef.current = false;
9351038 const { active, over } = event;
9361039 if (!over || active.id === over.id) return;
@@ -939,22 +1042,352 @@ export default function Sidebar() { · 2 unmodified lines
9391042 if (!activeProject || !overProject) return;
9401043 reorderProjects(activeProject.id, overProject.id);
9411044 },
942− [projects, reorderProjects],
1045+ [appSettings.sidebarProjectSortOrder, projects, reorderProjects],
9431046 );
9441047
945− const handleProjectDragStart = useCallback((_event: DragStartEvent) => {
946− dragInProgressRef.current = true;
947− suppressProjectClickAfterDragRef.current = true;
948− }, []);
1048+ const handleProjectDragStart = useCallback(
1049+ (_event: DragStartEvent) => {
1050+ if (appSettings.sidebarProjectSortOrder !== "manual") {
1051+ return;
1052+ }
1053+ dragInProgressRef.current = true;
1054+ suppressProjectClickAfterDragRef.current = true;
1055+ },
1056+ [appSettings.sidebarProjectSortOrder],
1057+ );
9491058
9501059 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
9511060 dragInProgressRef.current = false;
9521061 }, []);
9531062
1063+ const animatedProjectListsRef = useRef(new WeakSet<HTMLElement>());
1064+ const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
1065+ if (!node || animatedProjectListsRef.current.has(node)) {
1066+ return;
1067+ }
1068+ autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
1069+ animatedProjectListsRef.current.add(node);
1070+ }, []);
1071+
1072+ const animatedThreadListsRef = useRef(new WeakSet<HTMLElement>());
1073+ const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
1074+ if (!node || animatedThreadListsRef.current.has(node)) {
1075+ return;
1076+ }
1077+ autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
1078+ animatedThreadListsRef.current.add(node);
1079+ }, []);
1080+
9541081 const handleProjectTitlePointerDownCapture = useCallback(() => {
9551082 suppressProjectClickAfterDragRef.current = false;
9561083 }, []);
9571084
1085+ const sortedProjects = useMemo(
1086+ () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder),
1087+ [appSettings.sidebarProjectSortOrder, projects, threads],
1088+ );
1089+ const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1090+
1091+ function renderProjectItem(
1092+ project: (typeof sortedProjects)[number],
1093+ dragHandleProps: SortableProjectHandleProps | null,
1094+ ) {
1095+ const projectThreads = sortThreadsForSidebar(
1096+ threads.filter((thread) => thread.projectId === project.id),
1097+ appSettings.sidebarThreadSortOrder,
1098+ );
1099+ const projectStatus = resolveProjectStatusIndicator(
1100+ projectThreads.map((thread) =>
1101+ resolveThreadStatusPill({
1102+ thread,
1103+ hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1104+ hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1105+ }),
1106+ ),
1107+ );
1108+ const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1109+ const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
1110+ const visibleThreads =
1111+ hasHiddenThreads && !isThreadListExpanded
1112+ ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
1113+ : projectThreads;
1114+ const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1115+
1116+ return (
1117+ <Collapsible className="group/collapsible" open={project.expanded}>
1118+ <div className="group/project-header relative">
1119+ <SidebarMenuButton
1120+ ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1121+ size="sm"
1122+ className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1123+ isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1124+ }`}
1125+ {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1126+ {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1127+ onPointerDownCapture={handleProjectTitlePointerDownCapture}
1128+ onClick={(event) => handleProjectTitleClick(event, project.id)}
1129+ onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1130+ onContextMenu={(event) => {
1131+ event.preventDefault();
1132+ void handleProjectContextMenu(project.id, {
1133+ x: event.clientX,
1134+ y: event.clientY,
1135+ });
1136+ }}
1137+ >
1138+ {!project.expanded && projectStatus ? (
1139+ <span
1140+ aria-hidden="true"
1141+ title={projectStatus.label}
1142+ className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1143+ >
1144+ <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1145+ <span
1146+ className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1147+ projectStatus.pulse ? "animate-pulse" : ""
1148+ }`}
1149+ />
1150+ </span>
1151+ <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1152+ </span>
1153+ ) : (
1154+ <ChevronRightIcon
1155+ className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1156+ project.expanded ? "rotate-90" : ""
1157+ }`}
1158+ />
1159+ )}
1160+ <ProjectFavicon cwd={project.cwd} />
1161+ <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1162+ {project.name}
1163+ </span>
1164+ </SidebarMenuButton>
1165+ <Tooltip>
1166+ <TooltipTrigger
1167+ render={
1168+ <SidebarMenuAction
1169+ render={
1170+ <button
1171+ type="button"
1172+ aria-label={`Create new thread in ${project.name}`}
1173+ data-testid="new-thread-button"
1174+ />
1175+ }
1176+ showOnHover
1177+ className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1178+ onClick={(event) => {
1179+ event.preventDefault();
1180+ event.stopPropagation();
1181+ void handleNewThread(project.id, {
1182+ envMode: resolveSidebarNewThreadEnvMode({
1183+ defaultEnvMode: appSettings.defaultThreadEnvMode,
1184+ }),
1185+ });
1186+ }}
1187+ >
1188+ <SquarePenIcon className="size-3.5" />
1189+ </SidebarMenuAction>
1190+ }
1191+ />
1192+ <TooltipPopup side="top">
1193+ {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1194+ </TooltipPopup>
1195+ </Tooltip>
1196+ </div>
1197+
1198+ <CollapsibleContent keepMounted>
1199+ <SidebarMenuSub
1200+ ref={attachThreadListAutoAnimateRef}
1201+ className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0"
1202+ >
1203+ {visibleThreads.map((thread) => {
1204+ const isActive = routeThreadId === thread.id;
1205+ const isSelected = selectedThreadIds.has(thread.id);
1206+ const isHighlighted = isActive || isSelected;
1207+ const threadStatus = resolveThreadStatusPill({
1208+ thread,
1209+ hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1210+ hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1211+ });
1212+ const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1213+ const terminalStatus = terminalStatusFromRunningIds(
1214+ selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
1215+ );
1216+
1217+ return (
1218+ <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
1219+ <SidebarMenuSubButton
1220+ render={<div role="button" tabIndex={0} />}
1221+ size="sm"
1222+ isActive={isActive}
1223+ className={resolveThreadRowClassName({
1224+ isActive,
1225+ isSelected,
1226+ })}
1227+ onClick={(event) => {
1228+ handleThreadClick(event, thread.id, orderedProjectThreadIds);
1229+ }}
1230+ onKeyDown={(event) => {
1231+ if (event.key !== "Enter" && event.key !== " ") return;
1232+ event.preventDefault();
1233+ if (selectedThreadIds.size > 0) {
1234+ clearSelection();
1235+ }
1236+ setSelectionAnchor(thread.id);
1237+ void navigate({
1238+ to: "/$threadId",
1239+ params: { threadId: thread.id },
1240+ });
1241+ }}
1242+ onContextMenu={(event) => {
1243+ event.preventDefault();
1244+ if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1245+ void handleMultiSelectContextMenu({
1246+ x: event.clientX,
1247+ y: event.clientY,
1248+ });
1249+ } else {
1250+ if (selectedThreadIds.size > 0) {
1251+ clearSelection();
1252+ }
1253+ void handleThreadContextMenu(thread.id, {
1254+ x: event.clientX,
1255+ y: event.clientY,
1256+ });
1257+ }
1258+ }}
1259+ >
1260+ <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1261+ {prStatus && (
1262+ <Tooltip>
1263+ <TooltipTrigger
1264+ render={
1265+ <button
1266+ type="button"
1267+ aria-label={prStatus.tooltip}
1268+ className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1269+ onClick={(event) => {
1270+ openPrLink(event, prStatus.url);
1271+ }}
1272+ >
1273+ <GitPullRequestIcon className="size-3" />
1274+ </button>
1275+ }
1276+ />
1277+ <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1278+ </Tooltip>
1279+ )}
1280+ {threadStatus && (
1281+ <span
1282+ className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1283+ >
1284+ <span
1285+ className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1286+ threadStatus.pulse ? "animate-pulse" : ""
1287+ }`}
1288+ />
1289+ <span className="hidden md:inline">{threadStatus.label}</span>
1290+ </span>
1291+ )}
1292+ {renamingThreadId === thread.id ? (
1293+ <input
1294+ ref={(el) => {
1295+ if (el && renamingInputRef.current !== el) {
1296+ renamingInputRef.current = el;
1297+ el.focus();
1298+ el.select();
1299+ }
1300+ }}
1301+ className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1302+ value={renamingTitle}
1303+ onChange={(e) => setRenamingTitle(e.target.value)}
1304+ onKeyDown={(e) => {
1305+ e.stopPropagation();
1306+ if (e.key === "Enter") {
1307+ e.preventDefault();
1308+ renamingCommittedRef.current = true;
1309+ void commitRename(thread.id, renamingTitle, thread.title);
1310+ } else if (e.key === "Escape") {
1311+ e.preventDefault();
1312+ renamingCommittedRef.current = true;
1313+ cancelRename();
1314+ }
1315+ }}
1316+ onBlur={() => {
1317+ if (!renamingCommittedRef.current) {
1318+ void commitRename(thread.id, renamingTitle, thread.title);
1319+ }
1320+ }}
1321+ onClick={(e) => e.stopPropagation()}
1322+ />
1323+ ) : (
1324+ <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
1325+ )}
1326+ </div>
1327+ <div className="ml-auto flex shrink-0 items-center gap-1.5">
1328+ {terminalStatus && (
1329+ <span
1330+ role="img"
1331+ aria-label={terminalStatus.label}
1332+ title={terminalStatus.label}
1333+ className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1334+ >
1335+ <TerminalIcon
1336+ className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1337+ />
1338+ </span>
1339+ )}
1340+ <span
1341+ className={`text-[10px] ${
1342+ isHighlighted
1343+ ? "text-foreground/72 dark:text-foreground/82"
1344+ : "text-muted-foreground/40"
1345+ }`}
1346+ >
1347+ {formatRelativeTime(thread.updatedAt ?? thread.createdAt)}
1348+ </span>
1349+ </div>
1350+ </SidebarMenuSubButton>
1351+ </SidebarMenuSubItem>
1352+ );
1353+ })}
1354+
1355+ {hasHiddenThreads && !isThreadListExpanded && (
1356+ <SidebarMenuSubItem className="w-full">
1357+ <SidebarMenuSubButton
1358+ render={<button type="button" />}
1359+ data-thread-selection-safe
1360+ size="sm"
1361+ 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"
1362+ onClick={() => {
1363+ expandThreadListForProject(project.id);
1364+ }}
1365+ >
1366+ <span>Show more</span>
1367+ </SidebarMenuSubButton>
1368+ </SidebarMenuSubItem>
1369+ )}
1370+ {hasHiddenThreads && isThreadListExpanded && (
1371+ <SidebarMenuSubItem className="w-full">
1372+ <SidebarMenuSubButton
1373+ render={<button type="button" />}
1374+ data-thread-selection-safe
1375+ size="sm"
1376+ 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"
1377+ onClick={() => {
1378+ collapseThreadListForProject(project.id);
1379+ }}
1380+ >
1381+ <span>Show less</span>
1382+ </SidebarMenuSubButton>
1383+ </SidebarMenuSubItem>
1384+ )}
1385+ </SidebarMenuSub>
1386+ </CollapsibleContent>
1387+ </Collapsible>
1388+ );
1389+ }
1390+
9581391 const handleProjectTitleClick = useCallback(
9591392 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
9601393 if (dragInProgressRef.current) {
@@ -1063,12 +1496,9 @@ export default function Sidebar() { · 102 unmodified lines
10631496 : shouldHighlightDesktopUpdateError(desktopUpdateState)
10641497 ? "text-rose-500 animate-pulse"
10651498 : "text-amber-500 animate-pulse";
1066− const newThreadShortcutLabel = useMemo(
1067− () =>
1068− shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1069− shortcutLabelForCommand(keybindings, "chat.new"),
1070− [keybindings],
1071− );
1499+ const newThreadShortcutLabel =
1500+ shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1501+ shortcutLabelForCommand(keybindings, "chat.new");
10721502
10731503 const handleDesktopUpdateButtonClick = useCallback(() => {
10741504 const bridge = window.desktopBridge;
@@ -1232,28 +1662,40 @@ export default function Sidebar() { · 157 unmodified lines
12321662 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
12331663 Projects
12341664 </span>
1235− <Tooltip>
1236− <TooltipTrigger
1237− render={
1238− <button
1239− type="button"
1240− aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1241− aria-pressed={shouldShowProjectPathEntry}
1242− className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1243− onClick={handleStartAddProject}
1665+ <div className="flex items-center gap-1">
1666+ <ProjectSortMenu
1667+ projectSortOrder={appSettings.sidebarProjectSortOrder}
1668+ threadSortOrder={appSettings.sidebarThreadSortOrder}
1669+ onProjectSortOrderChange={(sortOrder) => {
1670+ updateSettings({ sidebarProjectSortOrder: sortOrder });
1671+ }}
1672+ onThreadSortOrderChange={(sortOrder) => {
1673+ updateSettings({ sidebarThreadSortOrder: sortOrder });
1674+ }}
1675+ />
1676+ <Tooltip>
1677+ <TooltipTrigger
1678+ render={
1679+ <button
1680+ type="button"
1681+ aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1682+ aria-pressed={shouldShowProjectPathEntry}
1683+ className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1684+ onClick={handleStartAddProject}
1685+ />
1686+ }
1687+ >
1688+ <PlusIcon
1689+ className={`size-3.5 transition-transform duration-150 ${
1690+ shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1691+ }`}
12441692 />
1245− }
1246− >
1247− <PlusIcon
1248− className={`size-3.5 transition-transform duration-150 ${
1249− shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1250− }`}
1251− />
1252− </TooltipTrigger>
1253− <TooltipPopup side="right">
1254− {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1255− </TooltipPopup>
1256− </Tooltip>
1693+ </TooltipTrigger>
1694+ <TooltipPopup side="right">
1695+ {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1696+ </TooltipPopup>
1697+ </Tooltip>
1698+ </div>
12571699 </div>
12581700
12591701 {shouldShowProjectPathEntry && (
@@ -1321,352 +1763,37 @@ export default function Sidebar() { · 61 unmodified lines
13211763 </div>
13221764 )}
13231765
1324− <DndContext
1325− sensors={projectDnDSensors}
1326− collisionDetection={projectCollisionDetection}
1327− modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1328− onDragStart={handleProjectDragStart}
1329− onDragEnd={handleProjectDragEnd}
1330− onDragCancel={handleProjectDragCancel}
1331− >
1332− <SidebarMenu>
1333− <SortableContext
1334− items={projects.map((project) => project.id)}
1335− strategy={verticalListSortingStrategy}
1336− >
1337− {projects.map((project) => {
1338− const projectThreads = threads
1339− .filter((thread) => thread.projectId === project.id)
1340− .toSorted((a, b) => {
1341− const byDate =
1342− new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
1343− if (byDate !== 0) return byDate;
1344− return b.id.localeCompare(a.id);
1345− });
1346− const projectStatus = resolveProjectStatusIndicator(
1347− projectThreads.map((thread) =>
1348− resolveThreadStatusPill({
1349− thread,
1350− hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1351− hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1352− }),
1353− ),
1354− );
1355− const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1356− const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
1357− const visibleThreads =
1358− hasHiddenThreads && !isThreadListExpanded
1359− ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
1360− : projectThreads;
1361− const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1362−
1363− return (
1766+ {isManualProjectSorting ? (
1767+ <DndContext
1768+ sensors={projectDnDSensors}
1769+ collisionDetection={projectCollisionDetection}
1770+ modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1771+ onDragStart={handleProjectDragStart}
1772+ onDragEnd={handleProjectDragEnd}
1773+ onDragCancel={handleProjectDragCancel}
1774+ >
1775+ <SidebarMenu>
1776+ <SortableContext
1777+ items={sortedProjects.map((project) => project.id)}
1778+ strategy={verticalListSortingStrategy}
1779+ >
1780+ {sortedProjects.map((project) => (
13641781 <SortableProjectItem key={project.id} projectId={project.id}>
1365− {(dragHandleProps) => (
1366− <Collapsible className="group/collapsible" open={project.expanded}>
1367− <div className="group/project-header relative">
1368− <SidebarMenuButton
1369− size="sm"
1370− className="gap-2 px-2 py-1.5 text-left cursor-grab active:cursor-grabbing hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground"
1371− {...dragHandleProps.attributes}
1372− {...dragHandleProps.listeners}
1373− onPointerDownCapture={handleProjectTitlePointerDownCapture}
1374− onClick={(event) => handleProjectTitleClick(event, project.id)}
1375− onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1376− onContextMenu={(event) => {
1377− event.preventDefault();
1378− void handleProjectContextMenu(project.id, {
1379− x: event.clientX,
1380− y: event.clientY,
1381− });
1382− }}
1383− >
1384− {!project.expanded && projectStatus ? (
1385− <span
1386− aria-hidden="true"
1387− title={projectStatus.label}
1388− className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1389− >
1390− <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1391− <span
1392− className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1393− projectStatus.pulse ? "animate-pulse" : ""
1394− }`}
1395− />
1396− </span>
1397− <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1398− </span>
1399− ) : (
1400− <ChevronRightIcon
1401− className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1402− project.expanded ? "rotate-90" : ""
1403− }`}
1404− />
1405− )}
1406− <ProjectFavicon cwd={project.cwd} />
1407− <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1408− {project.name}
1409− </span>
1410− </SidebarMenuButton>
1411− <Tooltip>
1412− <TooltipTrigger
1413− render={
1414− <SidebarMenuAction
1415− render={
1416− <button
1417− type="button"
1418− aria-label={`Create new thread in ${project.name}`}
1419− data-testid="new-thread-button"
1420− />
1421− }
1422− showOnHover
1423− className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1424− onClick={(event) => {
1425− event.preventDefault();
1426− event.stopPropagation();
1427− void handleNewThread(project.id, {
1428− envMode: resolveSidebarNewThreadEnvMode({
1429− defaultEnvMode: appSettings.defaultThreadEnvMode,
1430− }),
1431− });
1432− }}
1433− >
1434− <SquarePenIcon className="size-3.5" />
1435− </SidebarMenuAction>
1436− }
1437− />
1438− <TooltipPopup side="top">
1439− {newThreadShortcutLabel
1440− ? `New thread (${newThreadShortcutLabel})`
1441− : "New thread"}
1442− </TooltipPopup>
1443− </Tooltip>
1444− </div>
1445−
1446− <CollapsibleContent keepMounted>
1447− <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0">
1448− {visibleThreads.map((thread) => {
1449− const isActive = routeThreadId === thread.id;
1450− const isSelected = selectedThreadIds.has(thread.id);
1451− const isHighlighted = isActive || isSelected;
1452− const threadStatus = resolveThreadStatusPill({
1453− thread,
1454− hasPendingApprovals:
1455− derivePendingApprovals(thread.activities).length > 0,
1456− hasPendingUserInput:
1457− derivePendingUserInputs(thread.activities).length > 0,
1458− });
1459− const prStatus = prStatusIndicator(
1460− prByThreadId.get(thread.id) ?? null,
1461− );
1462− const terminalStatus = terminalStatusFromRunningIds(
1463− selectThreadTerminalState(terminalStateByThreadId, thread.id)
1464− .runningTerminalIds,
1465− );
1466−
1467− return (
1468− <SidebarMenuSubItem
1469− key={thread.id}
1470− className="w-full"
1471− data-thread-item
1472− >
1473− <SidebarMenuSubButton
1474− render={<div role="button" tabIndex={0} />}
1475− size="sm"
1476− isActive={isActive}
1477− className={resolveThreadRowClassName({
1478− isActive,
1479− isSelected,
1480− })}
1481− onClick={(event) => {
1482− handleThreadClick(
1483− event,
1484− thread.id,
1485− orderedProjectThreadIds,
1486− );
1487− }}
1488− onKeyDown={(event) => {
1489− if (event.key !== "Enter" && event.key !== " ") return;
1490− event.preventDefault();
1491− if (selectedThreadIds.size > 0) {
1492− clearSelection();
1493− }
1494− setSelectionAnchor(thread.id);
1495− void navigate({
1496− to: "/$threadId",
1497− params: { threadId: thread.id },
1498− });
1499− }}
1500− onContextMenu={(event) => {
1501− event.preventDefault();
1502− if (
1503− selectedThreadIds.size > 0 &&
1504− selectedThreadIds.has(thread.id)
1505− ) {
1506− void handleMultiSelectContextMenu({
1507− x: event.clientX,
1508− y: event.clientY,
1509− });
1510− } else {
1511− if (selectedThreadIds.size > 0) {
1512− clearSelection();
1513− }
1514− void handleThreadContextMenu(thread.id, {
1515− x: event.clientX,
1516− y: event.clientY,
1517− });
1518− }
1519− }}
1520− >
1521− <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1522− {prStatus && (
1523− <Tooltip>
1524− <TooltipTrigger
1525− render={
1526− <button
1527− type="button"
1528− aria-label={prStatus.tooltip}
1529− className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1530− onClick={(event) => {
1531− openPrLink(event, prStatus.url);
1532− }}
1533− >
1534− <GitPullRequestIcon className="size-3" />
1535− </button>
1536− }
1537− />
1538− <TooltipPopup side="top">
1539− {prStatus.tooltip}
1540− </TooltipPopup>
1541− </Tooltip>
1542− )}
1543− {threadStatus && (
1544− <span
1545− className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1546− >
1547− <span
1548− className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1549− threadStatus.pulse ? "animate-pulse" : ""
1550− }`}
1551− />
1552− <span className="hidden md:inline">
1553− {threadStatus.label}
1554− </span>
1555− </span>
1556− )}
1557− {renamingThreadId === thread.id ? (
1558− <input
1559− ref={(el) => {
1560− if (el && renamingInputRef.current !== el) {
1561− renamingInputRef.current = el;
1562− el.focus();
1563− el.select();
1564− }
1565− }}
1566− className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1567− value={renamingTitle}
1568− onChange={(e) => setRenamingTitle(e.target.value)}
1569− onKeyDown={(e) => {
1570− e.stopPropagation();
1571− if (e.key === "Enter") {
1572− e.preventDefault();
1573− renamingCommittedRef.current = true;
1574− void commitRename(
1575− thread.id,
1576− renamingTitle,
1577− thread.title,
1578− );
1579− } else if (e.key === "Escape") {
1580− e.preventDefault();
1581− renamingCommittedRef.current = true;
1582− cancelRename();
1583− }
1584− }}
1585− onBlur={() => {
1586− if (!renamingCommittedRef.current) {
1587− void commitRename(
1588− thread.id,
1589− renamingTitle,
1590− thread.title,
1591− );
1592− }
1593− }}
1594− onClick={(e) => e.stopPropagation()}
1595− />
1596− ) : (
1597− <span className="min-w-0 flex-1 truncate text-xs">
1598− {thread.title}
1599− </span>
1600− )}
1601− </div>
1602− <div className="ml-auto flex shrink-0 items-center gap-1.5">
1603− {terminalStatus && (
1604− <span
1605− role="img"
1606− aria-label={terminalStatus.label}
1607− title={terminalStatus.label}
1608− className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1609− >
1610− <TerminalIcon
1611− className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1612− />
1613− </span>
1614− )}
1615− <span
1616− className={`text-[10px] ${
1617− isHighlighted
1618− ? "text-foreground/72 dark:text-foreground/82"
1619− : "text-muted-foreground/40"
1620− }`}
1621− >
1622− {formatRelativeTime(thread.createdAt)}
1623− </span>
1624− </div>
1625− </SidebarMenuSubButton>
1626− </SidebarMenuSubItem>
1627− );
1628− })}
1629−
1630− {hasHiddenThreads && !isThreadListExpanded && (
1631− <SidebarMenuSubItem className="w-full">
1632− <SidebarMenuSubButton
1633− render={<button type="button" />}
1634− data-thread-selection-safe
1635− size="sm"
1636− 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"
1637− onClick={() => {
1638− expandThreadListForProject(project.id);
1639− }}
1640− >
1641− <span>Show more</span>
1642− </SidebarMenuSubButton>
1643− </SidebarMenuSubItem>
1644− )}
1645− {hasHiddenThreads && isThreadListExpanded && (
1646− <SidebarMenuSubItem className="w-full">
1647− <SidebarMenuSubButton
1648− render={<button type="button" />}
1649− data-thread-selection-safe
1650− size="sm"
1651− 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"
1652− onClick={() => {
1653− collapseThreadListForProject(project.id);
1654− }}
1655− >
1656− <span>Show less</span>
1657− </SidebarMenuSubButton>
1658− </SidebarMenuSubItem>
1659− )}
1660− </SidebarMenuSub>
1661− </CollapsibleContent>
1662− </Collapsible>
1663− )}
1782+ {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}
16641783 </SortableProjectItem>
1665− );
1666− })}
1667− </SortableContext>
1784+ ))}
1785+ </SortableContext>
1786+ </SidebarMenu>
1787+ </DndContext>
1788+ ) : (
1789+ <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1790+ {sortedProjects.map((project) => (
1791+ <SidebarMenuItem key={project.id} className="rounded-md">
1792+ {renderProjectItem(project, null)}
1793+ </SidebarMenuItem>
1794+ ))}
16681795 </SidebarMenu>
1669− </DndContext>
1796+ )}
16701797
16711798 {projects.length === 0 && !shouldShowProjectPathEntry && (
16721799 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
apps/web/src/store.test.ts+14−0
@@ -226,6 +226,20 @@ describe("store read model sync", () => { · 225 unmodified lines
226226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
227227 });
228228
229+ it("preserves project and thread updatedAt timestamps from the read model", () => {
230+ const initialState = makeState(makeThread());
231+ const readModel = makeReadModel(
232+ makeReadModelThread({
233+ updatedAt: "2026-02-27T00:05:00.000Z",
234+ }),
235+ );
236+
237+ const next = syncServerReadModel(initialState, readModel);
238+
239+ expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");
240+ expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
241+ });
242+
229243 it("preserves the current project order when syncing incoming read model updates", () => {
230244 const project1 = ProjectId.makeUnsafe("project-1");
231245 const project2 = ProjectId.makeUnsafe("project-2");
apps/web/src/store.ts+3−0
@@ -145,6 +145,8 @@ function mapProjectsFromReadModel( · 144 unmodified lines
145145 (persistedExpandedProjectCwds.size > 0
146146 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
147147 : true),
148+ createdAt: project.createdAt,
149+ updatedAt: project.updatedAt,
148150 scripts: project.scripts.map((script) => ({ ...script })),
149151 } satisfies Project;
150152 });
@@ -304,6 +306,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea · 153 unmodified lines
304306 })),
305307 error: thread.session?.lastError ?? null,
306308 createdAt: thread.createdAt,
309+ updatedAt: thread.updatedAt,
307310 latestTurn: thread.latestTurn,
308311 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
309312 branch: thread.branch,
apps/web/src/types.ts+3−0
@@ -82,6 +82,8 @@ export interface Project { · 81 unmodified lines
8282 cwd: string;
8383 model: string;
8484 expanded: boolean;
85+ createdAt?: string | undefined;
86+ updatedAt?: string | undefined;
8587 scripts: ProjectScript[];
8688 }
8789
@@ -98,6 +100,7 @@ export interface Thread { · 10 unmodified lines
98100 proposedPlans: ProposedPlan[];
99101 error: string | null;
100102 createdAt: string;
103+ updatedAt?: string | undefined;
101104 latestTurn: OrchestrationLatestTurn | null;
102105 lastVisitedAt?: string | undefined;
103106 branch: string | null;
bun.lock+3−0
@@ -80,6 +80,7 @@ · 79 unmodified lines
8080 "@dnd-kit/modifiers": "^9.0.0",
8181 "@dnd-kit/sortable": "^10.0.0",
8282 "@dnd-kit/utilities": "^3.2.2",
83+ "@formkit/auto-animate": "^0.9.0",
8384 "@lexical/react": "^0.41.0",
8485 "@pierre/diffs": "^1.1.0-beta.16",
8586 "@t3tools/contracts": "workspace:*",
@@ -358,6 +359,8 @@ · 272 unmodified lines
358359
359360 "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
360361
362+ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
363+
361364 "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
362365
363366 "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],