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
19 "@dnd-kit/modifiers": "^9.0.0",19 "@dnd-kit/modifiers": "^9.0.0",
20 "@dnd-kit/sortable": "^10.0.0",20 "@dnd-kit/sortable": "^10.0.0",
21 "@dnd-kit/utilities": "^3.2.2",21 "@dnd-kit/utilities": "^3.2.2",
22 "@formkit/auto-animate": "^0.9.0",
22 "@lexical/react": "^0.41.0",23 "@lexical/react": "^0.41.0",
23 "@pierre/diffs": "^1.1.0-beta.16",24 "@pierre/diffs": "^1.1.0-beta.16",
24 "@t3tools/contracts": "workspace:*",25 "@t3tools/contracts": "workspace:*",
apps/web/src/appSettings.test.ts+14−0
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
3 3
4import {4import {
5 AppSettingsSchema,5 AppSettingsSchema,
6 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
7 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
6 DEFAULT_TIMESTAMP_FORMAT,8 DEFAULT_TIMESTAMP_FORMAT,
7 getAppModelOptions,9 getAppModelOptions,
8 getCustomModelOptionsByProvider,10 getCustomModelOptionsByProvider,
@@ -111,6 +113,16 @@ describe("timestamp format defaults", () => { · 102 unmodified lines
111 });113 });
112});114});
113 115
116describe("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
114describe("provider-specific custom models", () => {126describe("provider-specific custom models", () => {
115 it("includes provider-specific custom slugs in non-codex model lists", () => {127 it("includes provider-specific custom slugs in non-codex model lists", () => {
116 const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]);128 const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]);
@@ -245,6 +257,8 @@ describe("AppSettingsSchema", () => { · 128 unmodified lines
245 defaultThreadEnvMode: "local",257 defaultThreadEnvMode: "local",
246 confirmThreadDelete: false,258 confirmThreadDelete: false,
247 enableAssistantStreaming: false,259 enableAssistantStreaming: false,
260 sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
261 sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
248 timestampFormat: DEFAULT_TIMESTAMP_FORMAT,262 timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
249 customCodexModels: [],263 customCodexModels: [],
250 customClaudeModels: [],264 customClaudeModels: [],
apps/web/src/appSettings.ts+12−0
@@ -21,6 +21,12 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; · 20 unmodified lines
21export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);21export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
22export type TimestampFormat = typeof TimestampFormat.Type;22export type TimestampFormat = typeof TimestampFormat.Type;
23export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";23export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
24export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
25export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
26export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
27export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
28export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
29export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
24type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";30type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
25export type ProviderCustomModelConfig = {31export type ProviderCustomModelConfig = {
26 provider: ProviderKind;32 provider: ProviderKind;
@@ -58,6 +64,12 @@ export const AppSettingsSchema = Schema.Struct({ · 31 unmodified lines
58 confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),64 confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
59 diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),65 diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
60 enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),66 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 ),
61 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),73 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
62 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),74 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
63 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),75 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
apps/web/src/components/Sidebar.logic.test.ts+245−0
@@ -1,13 +1,23 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2 2
3import {3import {
4 getProjectSortTimestamp,
4 hasUnseenCompletion,5 hasUnseenCompletion,
5 resolveProjectStatusIndicator,6 resolveProjectStatusIndicator,
6 resolveSidebarNewThreadEnvMode,7 resolveSidebarNewThreadEnvMode,
7 resolveThreadRowClassName,8 resolveThreadRowClassName,
8 resolveThreadStatusPill,9 resolveThreadStatusPill,
9 shouldClearThreadSelectionOnMouseDown,10 shouldClearThreadSelectionOnMouseDown,
11 sortProjectsForSidebar,
12 sortThreadsForSidebar,
10} from "./Sidebar.logic";13} from "./Sidebar.logic";
14import { ProjectId, ThreadId } from "@t3tools/contracts";
15import {
16 DEFAULT_INTERACTION_MODE,
17 DEFAULT_RUNTIME_MODE,
18 type Project,
19 type Thread,
20} from "../types";
11 21
12function makeLatestTurn(overrides?: {22function makeLatestTurn(overrides?: {
13 completedAt?: string | null;23 completedAt?: string | null;
@@ -281,3 +291,238 @@ describe("resolveProjectStatusIndicator", () => { · 267 unmodified lines
281 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });291 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });
282 });292 });
283});293});
294
295function 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
309function 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
333describe("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
435describe("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 @@
1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings";
1import type { Thread } from "../types";2import type { Thread } from "../types";
2import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
3import {4import {
@@ -8,6 +9,13 @@ import { · 4 unmodified lines
8 9
9export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";10export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
10export type SidebarNewThreadEnvMode = "local" | "worktree";11export type SidebarNewThreadEnvMode = "local" | "worktree";
12type SidebarProject = {
13 id: string;
14 name: string;
15 createdAt?: string | undefined;
16 updatedAt?: string | undefined;
17};
18type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
11 19
12export interface ThreadStatusPill {20export interface ThreadStatusPill {
13 label:21 label:
@@ -178,3 +186,91 @@ export function resolveProjectStatusIndicator( · 164 unmodified lines
178 186
179 return highestPriorityStatus;187 return highestPriorityStatus;
180}188}
189
190function 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
196function 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
214export 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
224export 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
235export 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
253export 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 @@
1import {1import {
2 ArrowLeftIcon,2 ArrowLeftIcon,
3 ArrowUpDownIcon,
3 ChevronRightIcon,4 ChevronRightIcon,
4 FolderIcon,5 FolderIcon,
5 GitPullRequestIcon,6 GitPullRequestIcon,
@@ -10,6 +11,7 @@ import { · 4 unmodified lines
10 TerminalIcon,11 TerminalIcon,
11 TriangleAlertIcon,12 TriangleAlertIcon,
12} from "lucide-react";13} from "lucide-react";
14import { autoAnimate } from "@formkit/auto-animate";
13import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";15import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
14import {16import {
15 DndContext,17 DndContext,
@@ -36,7 +38,11 @@ import { · 20 unmodified lines
36} from "@t3tools/contracts";38} from "@t3tools/contracts";
37import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";39import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
38import { useLocation, useNavigate, useParams } from "@tanstack/react-router";40import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
39import { useAppSettings } from "../appSettings";41import {
42 type SidebarProjectSortOrder,
43 type SidebarThreadSortOrder,
44 useAppSettings,
45} from "../appSettings";
40import { isElectron } from "../env";46import { isElectron } from "../env";
41import { APP_STAGE_LABEL, APP_VERSION } from "../branding";47import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
42import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";48import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
@@ -64,6 +70,7 @@ import { · 21 unmodified lines
64import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";70import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
65import { Button } from "./ui/button";71import { Button } from "./ui/button";
66import { Collapsible, CollapsibleContent } from "./ui/collapsible";72import { Collapsible, CollapsibleContent } from "./ui/collapsible";
73import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";74import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
68import {75import {
69 SidebarContent,76 SidebarContent,
@@ -89,11 +96,26 @@ import { · 19 unmodified lines
89 resolveThreadRowClassName,96 resolveThreadRowClassName,
90 resolveThreadStatusPill,97 resolveThreadStatusPill,
91 shouldClearThreadSelectionOnMouseDown,98 shouldClearThreadSelectionOnMouseDown,
99 sortProjectsForSidebar,
100 sortThreadsForSidebar,
92} from "./Sidebar.logic";101} from "./Sidebar.logic";
93import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";102import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
94 103
95const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];104const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
96const THREAD_PREVIEW_LIMIT = 6;105const THREAD_PREVIEW_LIMIT = 6;
106const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
107 updated_at: "Last user message",
108 created_at: "Created",
109 manual: "Manual",
110};
111const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
112 updated_at: "Last user message",
113 created_at: "Created",
114};
115const SIDEBAR_LIST_ANIMATION_OPTIONS = {
116 duration: 180,
117 easing: "ease-out",
118} as const;
97 119
98function formatRelativeTime(iso: string): string {120function formatRelativeTime(iso: string): string {
99 const diff = Date.now() - new Date(iso).getTime();121 const diff = Date.now() - new Date(iso).getTime();
@@ -223,17 +245,97 @@ function ProjectFavicon({ cwd }: { cwd: string }) { · 123 unmodified lines
223 );245 );
224}246}
225 247
226type SortableProjectHandleProps = Pick<ReturnType<typeof useSortable>, "attributes" | "listeners">;248type SortableProjectHandleProps = Pick<
249 ReturnType<typeof useSortable>,
250 "attributes" | "listeners" | "setActivatorNodeRef"
251>;
252
253function 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}
227 319
228function SortableProjectItem({320function SortableProjectItem({
229 projectId,321 projectId,
322 disabled = false,
230 children,323 children,
231}: {324}: {
232 projectId: ProjectId;325 projectId: ProjectId;
326 disabled?: boolean;
233 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;327 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
234}) {328}) {
235 const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =329 const {
236 useSortable({ id: projectId });330 attributes,
331 listeners,
332 setActivatorNodeRef,
333 setNodeRef,
334 transform,
335 transition,
336 isDragging,
337 isOver,
338 } = useSortable({ id: projectId, disabled });
237 return (339 return (
238 <li340 <li
239 ref={setNodeRef}341 ref={setNodeRef}
@@ -247,7 +349,7 @@ function SortableProjectItem({ · 7 unmodified lines
247 data-sidebar="menu-item"349 data-sidebar="menu-item"
248 data-slot="sidebar-menu-item"350 data-slot="sidebar-menu-item"
249 >351 >
250 {children({ attributes, listeners })}352 {children({ attributes, listeners, setActivatorNodeRef })}
251 </li>353 </li>
252 );354 );
253}355}
@@ -272,7 +374,7 @@ export default function Sidebar() { · 18 unmodified lines
272 );374 );
273 const navigate = useNavigate();375 const navigate = useNavigate();
274 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });376 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
275 const { settings: appSettings } = useAppSettings();377 const { settings: appSettings, updateSettings } = useAppSettings();
276 const { handleNewThread } = useHandleNewThread();378 const { handleNewThread } = useHandleNewThread();
277 const routeThreadId = useParams({379 const routeThreadId = useParams({
278 strict: false,380 strict: false,
@@ -385,13 +487,10 @@ export default function Sidebar() { · 106 unmodified lines
385 487
386 const focusMostRecentThreadForProject = useCallback(488 const focusMostRecentThreadForProject = useCallback(
387 (projectId: ProjectId) => {489 (projectId: ProjectId) => {
388 const latestThread = threads490 const latestThread = sortThreadsForSidebar(
389 .filter((thread) => thread.projectId === projectId)491 threads.filter((thread) => thread.projectId === projectId),
390 .toSorted((a, b) => {492 appSettings.sidebarThreadSortOrder,
391 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();493 )[0];
392 if (byDate !== 0) return byDate;
393 return b.id.localeCompare(a.id);
394 })[0];
395 if (!latestThread) return;494 if (!latestThread) return;
396 495
397 void navigate({496 void navigate({
@@ -399,7 +498,7 @@ export default function Sidebar() { · 1 unmodified line
399 params: { threadId: latestThread.id },498 params: { threadId: latestThread.id },
400 });499 });
401 },500 },
402 [navigate, threads],501 [appSettings.sidebarThreadSortOrder, navigate, threads],
403 );502 );
404 503
405 const addProjectFromPath = useCallback(504 const addProjectFromPath = useCallback(
@@ -931,6 +1030,10 @@ export default function Sidebar() { · 525 unmodified lines
931 1030
932 const handleProjectDragEnd = useCallback(1031 const handleProjectDragEnd = useCallback(
933 (event: DragEndEvent) => {1032 (event: DragEndEvent) => {
1033 if (appSettings.sidebarProjectSortOrder !== "manual") {
1034 dragInProgressRef.current = false;
1035 return;
1036 }
934 dragInProgressRef.current = false;1037 dragInProgressRef.current = false;
935 const { active, over } = event;1038 const { active, over } = event;
936 if (!over || active.id === over.id) return;1039 if (!over || active.id === over.id) return;
@@ -939,22 +1042,352 @@ export default function Sidebar() { · 2 unmodified lines
939 if (!activeProject || !overProject) return;1042 if (!activeProject || !overProject) return;
940 reorderProjects(activeProject.id, overProject.id);1043 reorderProjects(activeProject.id, overProject.id);
941 },1044 },
942 [projects, reorderProjects],1045 [appSettings.sidebarProjectSortOrder, projects, reorderProjects],
943 );1046 );
944 1047
945 const handleProjectDragStart = useCallback((_event: DragStartEvent) => {1048 const handleProjectDragStart = useCallback(
946 dragInProgressRef.current = true;1049 (_event: DragStartEvent) => {
947 suppressProjectClickAfterDragRef.current = true;1050 if (appSettings.sidebarProjectSortOrder !== "manual") {
948 }, []);1051 return;
1052 }
1053 dragInProgressRef.current = true;
1054 suppressProjectClickAfterDragRef.current = true;
1055 },
1056 [appSettings.sidebarProjectSortOrder],
1057 );
949 1058
950 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {1059 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
951 dragInProgressRef.current = false;1060 dragInProgressRef.current = false;
952 }, []);1061 }, []);
953 1062
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
954 const handleProjectTitlePointerDownCapture = useCallback(() => {1081 const handleProjectTitlePointerDownCapture = useCallback(() => {
955 suppressProjectClickAfterDragRef.current = false;1082 suppressProjectClickAfterDragRef.current = false;
956 }, []);1083 }, []);
957 1084
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
958 const handleProjectTitleClick = useCallback(1391 const handleProjectTitleClick = useCallback(
959 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {1392 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
960 if (dragInProgressRef.current) {1393 if (dragInProgressRef.current) {
@@ -1063,12 +1496,9 @@ export default function Sidebar() { · 102 unmodified lines
1063 : shouldHighlightDesktopUpdateError(desktopUpdateState)1496 : shouldHighlightDesktopUpdateError(desktopUpdateState)
1064 ? "text-rose-500 animate-pulse"1497 ? "text-rose-500 animate-pulse"
1065 : "text-amber-500 animate-pulse";1498 : "text-amber-500 animate-pulse";
1066 const newThreadShortcutLabel = useMemo(1499 const newThreadShortcutLabel =
1067 () =>1500 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1068 shortcutLabelForCommand(keybindings, "chat.newLocal") ??1501 shortcutLabelForCommand(keybindings, "chat.new");
1069 shortcutLabelForCommand(keybindings, "chat.new"),
1070 [keybindings],
1071 );
1072 1502
1073 const handleDesktopUpdateButtonClick = useCallback(() => {1503 const handleDesktopUpdateButtonClick = useCallback(() => {
1074 const bridge = window.desktopBridge;1504 const bridge = window.desktopBridge;
@@ -1232,28 +1662,40 @@ export default function Sidebar() { · 157 unmodified lines
1232 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">1662 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1233 Projects1663 Projects
1234 </span>1664 </span>
1235 <Tooltip>1665 <div className="flex items-center gap-1">
1236 <TooltipTrigger1666 <ProjectSortMenu
1237 render={1667 projectSortOrder={appSettings.sidebarProjectSortOrder}
1238 <button1668 threadSortOrder={appSettings.sidebarThreadSortOrder}
1239 type="button"1669 onProjectSortOrderChange={(sortOrder) => {
1240 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}1670 updateSettings({ sidebarProjectSortOrder: sortOrder });
1241 aria-pressed={shouldShowProjectPathEntry}1671 }}
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"1672 onThreadSortOrderChange={(sortOrder) => {
1243 onClick={handleStartAddProject}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 }`}
1244 />1692 />
1245 }1693 </TooltipTrigger>
1246 >1694 <TooltipPopup side="right">
1247 <PlusIcon1695 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1248 className={`size-3.5 transition-transform duration-150 ${1696 </TooltipPopup>
1249 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"1697 </Tooltip>
1250 }`}1698 </div>
1251 />
1252 </TooltipTrigger>
1253 <TooltipPopup side="right">
1254 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1255 </TooltipPopup>
1256 </Tooltip>
1257 </div>1699 </div>
1258 1700
1259 {shouldShowProjectPathEntry && (1701 {shouldShowProjectPathEntry && (
@@ -1321,352 +1763,37 @@ export default function Sidebar() { · 61 unmodified lines
1321 </div>1763 </div>
1322 )}1764 )}
1323 1765
1324 <DndContext1766 {isManualProjectSorting ? (
1325 sensors={projectDnDSensors}1767 <DndContext
1326 collisionDetection={projectCollisionDetection}1768 sensors={projectDnDSensors}
1327 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}1769 collisionDetection={projectCollisionDetection}
1328 onDragStart={handleProjectDragStart}1770 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1329 onDragEnd={handleProjectDragEnd}1771 onDragStart={handleProjectDragStart}
1330 onDragCancel={handleProjectDragCancel}1772 onDragEnd={handleProjectDragEnd}
1331 >1773 onDragCancel={handleProjectDragCancel}
1332 <SidebarMenu>1774 >
1333 <SortableContext1775 <SidebarMenu>
1334 items={projects.map((project) => project.id)}1776 <SortableContext
1335 strategy={verticalListSortingStrategy}1777 items={sortedProjects.map((project) => project.id)}
1336 >1778 strategy={verticalListSortingStrategy}
1337 {projects.map((project) => {1779 >
1338 const projectThreads = threads1780 {sortedProjects.map((project) => (
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 (
1364 <SortableProjectItem key={project.id} projectId={project.id}>1781 <SortableProjectItem key={project.id} projectId={project.id}>
1365 {(dragHandleProps) => (1782 {(dragHandleProps) => renderProjectItem(project, 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 )}
1664 </SortableProjectItem>1783 </SortableProjectItem>
1665 );1784 ))}
1666 })}1785 </SortableContext>
1667 </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 ))}
1668 </SidebarMenu>1795 </SidebarMenu>
1669 </DndContext>1796 )}
1670 1797
1671 {projects.length === 0 && !shouldShowProjectPathEntry && (1798 {projects.length === 0 && !shouldShowProjectPathEntry && (
1672 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">1799 <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
226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
227 });227 });
228 228
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
229 it("preserves the current project order when syncing incoming read model updates", () => {243 it("preserves the current project order when syncing incoming read model updates", () => {
230 const project1 = ProjectId.makeUnsafe("project-1");244 const project1 = ProjectId.makeUnsafe("project-1");
231 const project2 = ProjectId.makeUnsafe("project-2");245 const project2 = ProjectId.makeUnsafe("project-2");
apps/web/src/store.ts+3−0
@@ -145,6 +145,8 @@ function mapProjectsFromReadModel( · 144 unmodified lines
145 (persistedExpandedProjectCwds.size > 0145 (persistedExpandedProjectCwds.size > 0
146 ? persistedExpandedProjectCwds.has(project.workspaceRoot)146 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
147 : true),147 : true),
148 createdAt: project.createdAt,
149 updatedAt: project.updatedAt,
148 scripts: project.scripts.map((script) => ({ ...script })),150 scripts: project.scripts.map((script) => ({ ...script })),
149 } satisfies Project;151 } satisfies Project;
150 });152 });
@@ -304,6 +306,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea · 153 unmodified lines
304 })),306 })),
305 error: thread.session?.lastError ?? null,307 error: thread.session?.lastError ?? null,
306 createdAt: thread.createdAt,308 createdAt: thread.createdAt,
309 updatedAt: thread.updatedAt,
307 latestTurn: thread.latestTurn,310 latestTurn: thread.latestTurn,
308 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,311 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
309 branch: thread.branch,312 branch: thread.branch,
apps/web/src/types.ts+3−0
@@ -82,6 +82,8 @@ export interface Project { · 81 unmodified lines
82 cwd: string;82 cwd: string;
83 model: string;83 model: string;
84 expanded: boolean;84 expanded: boolean;
85 createdAt?: string | undefined;
86 updatedAt?: string | undefined;
85 scripts: ProjectScript[];87 scripts: ProjectScript[];
86}88}
87 89
@@ -98,6 +100,7 @@ export interface Thread { · 10 unmodified lines
98 proposedPlans: ProposedPlan[];100 proposedPlans: ProposedPlan[];
99 error: string | null;101 error: string | null;
100 createdAt: string;102 createdAt: string;
103 updatedAt?: string | undefined;
101 latestTurn: OrchestrationLatestTurn | null;104 latestTurn: OrchestrationLatestTurn | null;
102 lastVisitedAt?: string | undefined;105 lastVisitedAt?: string | undefined;
103 branch: string | null;106 branch: string | null;
bun.lock+3−0
@@ -80,6 +80,7 @@ · 79 unmodified lines
80 "@dnd-kit/modifiers": "^9.0.0",80 "@dnd-kit/modifiers": "^9.0.0",
81 "@dnd-kit/sortable": "^10.0.0",81 "@dnd-kit/sortable": "^10.0.0",
82 "@dnd-kit/utilities": "^3.2.2",82 "@dnd-kit/utilities": "^3.2.2",
83 "@formkit/auto-animate": "^0.9.0",
83 "@lexical/react": "^0.41.0",84 "@lexical/react": "^0.41.0",
84 "@pierre/diffs": "^1.1.0-beta.16",85 "@pierre/diffs": "^1.1.0-beta.16",
85 "@t3tools/contracts": "workspace:*",86 "@t3tools/contracts": "workspace:*",
@@ -358,6 +359,8 @@ · 272 unmodified lines
358 359
359 "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],360 "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
360 361
362 "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
363
361 "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],364 "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
362 365
363 "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],366 "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],