authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 12:40:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 12:40:42-07:00
log28afb14004bbb92a523a87c334de02421f72a627
tree748e4a66ea76bf14e517ffb51c2d14a3a0406c3f
parent8f3515572de09d41cbfe586a94696277ef000bb9
signature Signed by PGP key B5690EEEBB952194

Sort sidebar projects and threads by recency (#1372)


10 files changed, 1082 insertions(+), 392 deletions(-)

apps/web/package.json+1
...@@ -19,6 +19,7 @@...@@ -19,6 +19,7 @@
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
...@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";...@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
33
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", () => {...@@ -111,6 +113,16 @@ describe("timestamp format defaults", () => {
111 });113 });
112});114});
113115
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", () => {...@@ -245,6 +257,8 @@ describe("AppSettingsSchema", () => {
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
...@@ -21,6 +21,12 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;...@@ -21,6 +21,12 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
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({...@@ -58,6 +64,12 @@ export const AppSettingsSchema = Schema.Struct({
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+346
...@@ -1,13 +1,24 @@...@@ -1,13 +1,24 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 getVisibleThreadsForProject,
5 getProjectSortTimestamp,
4 hasUnseenCompletion,6 hasUnseenCompletion,
5 resolveProjectStatusIndicator,7 resolveProjectStatusIndicator,
6 resolveSidebarNewThreadEnvMode,8 resolveSidebarNewThreadEnvMode,
7 resolveThreadRowClassName,9 resolveThreadRowClassName,
8 resolveThreadStatusPill,10 resolveThreadStatusPill,
9 shouldClearThreadSelectionOnMouseDown,11 shouldClearThreadSelectionOnMouseDown,
12 sortProjectsForSidebar,
13 sortThreadsForSidebar,
10} from "./Sidebar.logic";14} from "./Sidebar.logic";
15import { ProjectId, ThreadId } from "@t3tools/contracts";
16import {
17 DEFAULT_INTERACTION_MODE,
18 DEFAULT_RUNTIME_MODE,
19 type Project,
20 type Thread,
21} from "../types";
1122
12function makeLatestTurn(overrides?: {23function makeLatestTurn(overrides?: {
13 completedAt?: string | null;24 completedAt?: string | null;
...@@ -281,3 +292,338 @@ describe("resolveProjectStatusIndicator", () => {...@@ -281,3 +292,338 @@ describe("resolveProjectStatusIndicator", () => {
281 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });292 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });
282 });293 });
283});294});
295
296describe("getVisibleThreadsForProject", () => {
297 it("includes the active thread even when it falls below the folded preview", () => {
298 const threads = Array.from({ length: 8 }, (_, index) =>
299 makeThread({
300 id: ThreadId.makeUnsafe(`thread-${index + 1}`),
301 title: `Thread ${index + 1}`,
302 }),
303 );
304
305 const result = getVisibleThreadsForProject({
306 threads,
307 activeThreadId: ThreadId.makeUnsafe("thread-8"),
308 isThreadListExpanded: false,
309 previewLimit: 6,
310 });
311
312 expect(result.hasHiddenThreads).toBe(true);
313 expect(result.visibleThreads.map((thread) => thread.id)).toEqual([
314 ThreadId.makeUnsafe("thread-1"),
315 ThreadId.makeUnsafe("thread-2"),
316 ThreadId.makeUnsafe("thread-3"),
317 ThreadId.makeUnsafe("thread-4"),
318 ThreadId.makeUnsafe("thread-5"),
319 ThreadId.makeUnsafe("thread-6"),
320 ThreadId.makeUnsafe("thread-8"),
321 ]);
322 });
323
324 it("returns all threads when the list is expanded", () => {
325 const threads = Array.from({ length: 8 }, (_, index) =>
326 makeThread({
327 id: ThreadId.makeUnsafe(`thread-${index + 1}`),
328 }),
329 );
330
331 const result = getVisibleThreadsForProject({
332 threads,
333 activeThreadId: ThreadId.makeUnsafe("thread-8"),
334 isThreadListExpanded: true,
335 previewLimit: 6,
336 });
337
338 expect(result.hasHiddenThreads).toBe(true);
339 expect(result.visibleThreads.map((thread) => thread.id)).toEqual(
340 threads.map((thread) => thread.id),
341 );
342 });
343});
344
345function makeProject(overrides: Partial<Project> = {}): Project {
346 return {
347 id: ProjectId.makeUnsafe("project-1"),
348 name: "Project",
349 cwd: "/tmp/project",
350 model: "gpt-5.4",
351 expanded: true,
352 createdAt: "2026-03-09T10:00:00.000Z",
353 updatedAt: "2026-03-09T10:00:00.000Z",
354 scripts: [],
355 ...overrides,
356 };
357}
358
359function makeThread(overrides: Partial<Thread> = {}): Thread {
360 return {
361 id: ThreadId.makeUnsafe("thread-1"),
362 codexThreadId: null,
363 projectId: ProjectId.makeUnsafe("project-1"),
364 title: "Thread",
365 model: "gpt-5.4",
366 runtimeMode: DEFAULT_RUNTIME_MODE,
367 interactionMode: DEFAULT_INTERACTION_MODE,
368 session: null,
369 messages: [],
370 proposedPlans: [],
371 error: null,
372 createdAt: "2026-03-09T10:00:00.000Z",
373 updatedAt: "2026-03-09T10:00:00.000Z",
374 latestTurn: null,
375 branch: null,
376 worktreePath: null,
377 turnDiffSummaries: [],
378 activities: [],
379 ...overrides,
380 };
381}
382
383describe("sortThreadsForSidebar", () => {
384 it("sorts threads by the latest user message in recency mode", () => {
385 const sorted = sortThreadsForSidebar(
386 [
387 makeThread({
388 id: ThreadId.makeUnsafe("thread-1"),
389 createdAt: "2026-03-09T10:00:00.000Z",
390 updatedAt: "2026-03-09T10:10:00.000Z",
391 messages: [
392 {
393 id: "message-1" as never,
394 role: "user",
395 text: "older",
396 createdAt: "2026-03-09T10:01:00.000Z",
397 streaming: false,
398 completedAt: "2026-03-09T10:01:00.000Z",
399 },
400 ],
401 }),
402 makeThread({
403 id: ThreadId.makeUnsafe("thread-2"),
404 createdAt: "2026-03-09T10:05:00.000Z",
405 updatedAt: "2026-03-09T10:05:00.000Z",
406 messages: [
407 {
408 id: "message-2" as never,
409 role: "user",
410 text: "newer",
411 createdAt: "2026-03-09T10:06:00.000Z",
412 streaming: false,
413 completedAt: "2026-03-09T10:06:00.000Z",
414 },
415 ],
416 }),
417 ],
418 "updated_at",
419 );
420
421 expect(sorted.map((thread) => thread.id)).toEqual([
422 ThreadId.makeUnsafe("thread-2"),
423 ThreadId.makeUnsafe("thread-1"),
424 ]);
425 });
426
427 it("falls back to thread timestamps when there is no user message", () => {
428 const sorted = sortThreadsForSidebar(
429 [
430 makeThread({
431 id: ThreadId.makeUnsafe("thread-1"),
432 createdAt: "2026-03-09T10:00:00.000Z",
433 updatedAt: "2026-03-09T10:01:00.000Z",
434 messages: [
435 {
436 id: "message-1" as never,
437 role: "assistant",
438 text: "assistant only",
439 createdAt: "2026-03-09T10:02:00.000Z",
440 streaming: false,
441 completedAt: "2026-03-09T10:02:00.000Z",
442 },
443 ],
444 }),
445 makeThread({
446 id: ThreadId.makeUnsafe("thread-2"),
447 createdAt: "2026-03-09T10:05:00.000Z",
448 updatedAt: "2026-03-09T10:05:00.000Z",
449 messages: [],
450 }),
451 ],
452 "updated_at",
453 );
454
455 expect(sorted.map((thread) => thread.id)).toEqual([
456 ThreadId.makeUnsafe("thread-2"),
457 ThreadId.makeUnsafe("thread-1"),
458 ]);
459 });
460
461 it("falls back to id ordering when threads have no sortable timestamps", () => {
462 const sorted = sortThreadsForSidebar(
463 [
464 makeThread({
465 id: ThreadId.makeUnsafe("thread-1"),
466 createdAt: "" as never,
467 updatedAt: undefined,
468 messages: [],
469 }),
470 makeThread({
471 id: ThreadId.makeUnsafe("thread-2"),
472 createdAt: "" as never,
473 updatedAt: undefined,
474 messages: [],
475 }),
476 ],
477 "updated_at",
478 );
479
480 expect(sorted.map((thread) => thread.id)).toEqual([
481 ThreadId.makeUnsafe("thread-2"),
482 ThreadId.makeUnsafe("thread-1"),
483 ]);
484 });
485
486 it("can sort threads by createdAt when configured", () => {
487 const sorted = sortThreadsForSidebar(
488 [
489 makeThread({
490 id: ThreadId.makeUnsafe("thread-1"),
491 createdAt: "2026-03-09T10:05:00.000Z",
492 updatedAt: "2026-03-09T10:05:00.000Z",
493 }),
494 makeThread({
495 id: ThreadId.makeUnsafe("thread-2"),
496 createdAt: "2026-03-09T10:00:00.000Z",
497 updatedAt: "2026-03-09T10:10:00.000Z",
498 }),
499 ],
500 "created_at",
501 );
502
503 expect(sorted.map((thread) => thread.id)).toEqual([
504 ThreadId.makeUnsafe("thread-1"),
505 ThreadId.makeUnsafe("thread-2"),
506 ]);
507 });
508});
509
510describe("sortProjectsForSidebar", () => {
511 it("sorts projects by the most recent user message across their threads", () => {
512 const projects = [
513 makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project" }),
514 makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project" }),
515 ];
516 const threads = [
517 makeThread({
518 projectId: ProjectId.makeUnsafe("project-1"),
519 updatedAt: "2026-03-09T10:20:00.000Z",
520 messages: [
521 {
522 id: "message-1" as never,
523 role: "user",
524 text: "older project user message",
525 createdAt: "2026-03-09T10:01:00.000Z",
526 streaming: false,
527 completedAt: "2026-03-09T10:01:00.000Z",
528 },
529 ],
530 }),
531 makeThread({
532 id: ThreadId.makeUnsafe("thread-2"),
533 projectId: ProjectId.makeUnsafe("project-2"),
534 updatedAt: "2026-03-09T10:05:00.000Z",
535 messages: [
536 {
537 id: "message-2" as never,
538 role: "user",
539 text: "newer project user message",
540 createdAt: "2026-03-09T10:05:00.000Z",
541 streaming: false,
542 completedAt: "2026-03-09T10:05:00.000Z",
543 },
544 ],
545 }),
546 ];
547
548 const sorted = sortProjectsForSidebar(projects, threads, "updated_at");
549
550 expect(sorted.map((project) => project.id)).toEqual([
551 ProjectId.makeUnsafe("project-2"),
552 ProjectId.makeUnsafe("project-1"),
553 ]);
554 });
555
556 it("falls back to project timestamps when a project has no threads", () => {
557 const sorted = sortProjectsForSidebar(
558 [
559 makeProject({
560 id: ProjectId.makeUnsafe("project-1"),
561 name: "Older project",
562 updatedAt: "2026-03-09T10:01:00.000Z",
563 }),
564 makeProject({
565 id: ProjectId.makeUnsafe("project-2"),
566 name: "Newer project",
567 updatedAt: "2026-03-09T10:05:00.000Z",
568 }),
569 ],
570 [],
571 "updated_at",
572 );
573
574 expect(sorted.map((project) => project.id)).toEqual([
575 ProjectId.makeUnsafe("project-2"),
576 ProjectId.makeUnsafe("project-1"),
577 ]);
578 });
579
580 it("falls back to name and id ordering when projects have no sortable timestamps", () => {
581 const sorted = sortProjectsForSidebar(
582 [
583 makeProject({
584 id: ProjectId.makeUnsafe("project-2"),
585 name: "Beta",
586 createdAt: undefined,
587 updatedAt: undefined,
588 }),
589 makeProject({
590 id: ProjectId.makeUnsafe("project-1"),
591 name: "Alpha",
592 createdAt: undefined,
593 updatedAt: undefined,
594 }),
595 ],
596 [],
597 "updated_at",
598 );
599
600 expect(sorted.map((project) => project.id)).toEqual([
601 ProjectId.makeUnsafe("project-1"),
602 ProjectId.makeUnsafe("project-2"),
603 ]);
604 });
605
606 it("preserves manual project ordering", () => {
607 const projects = [
608 makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }),
609 makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "First" }),
610 ];
611
612 const sorted = sortProjectsForSidebar(projects, [], "manual");
613
614 expect(sorted.map((project) => project.id)).toEqual([
615 ProjectId.makeUnsafe("project-2"),
616 ProjectId.makeUnsafe("project-1"),
617 ]);
618 });
619
620 it("returns the project timestamp when no threads are present", () => {
621 const timestamp = getProjectSortTimestamp(
622 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
623 [],
624 "updated_at",
625 );
626
627 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
628 });
629});
apps/web/src/components/Sidebar.logic.ts+152
...@@ -1,3 +1,4 @@...@@ -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 {...@@ -8,6 +9,13 @@ import {
89
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">;
1119
12export interface ThreadStatusPill {20export interface ThreadStatusPill {
13 label:21 label:
...@@ -178,3 +186,147 @@ export function resolveProjectStatusIndicator(...@@ -178,3 +186,147 @@ export function resolveProjectStatusIndicator(
178186
179 return highestPriorityStatus;187 return highestPriorityStatus;
180}188}
189
190export function getVisibleThreadsForProject(input: {
191 threads: readonly Thread[];
192 activeThreadId: Thread["id"] | undefined;
193 isThreadListExpanded: boolean;
194 previewLimit: number;
195}): {
196 hasHiddenThreads: boolean;
197 visibleThreads: Thread[];
198} {
199 const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input;
200 const hasHiddenThreads = threads.length > previewLimit;
201
202 if (!hasHiddenThreads || isThreadListExpanded) {
203 return {
204 hasHiddenThreads,
205 visibleThreads: [...threads],
206 };
207 }
208
209 const previewThreads = threads.slice(0, previewLimit);
210 if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) {
211 return {
212 hasHiddenThreads: true,
213 visibleThreads: previewThreads,
214 };
215 }
216
217 const activeThread = threads.find((thread) => thread.id === activeThreadId);
218 if (!activeThread) {
219 return {
220 hasHiddenThreads: true,
221 visibleThreads: previewThreads,
222 };
223 }
224
225 const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id));
226
227 return {
228 hasHiddenThreads: true,
229 visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)),
230 };
231}
232
233function toSortableTimestamp(iso: string | undefined): number | null {
234 if (!iso) return null;
235 const ms = Date.parse(iso);
236 return Number.isFinite(ms) ? ms : null;
237}
238
239function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {
240 let latestUserMessageTimestamp: number | null = null;
241
242 for (const message of thread.messages) {
243 if (message.role !== "user") continue;
244 const messageTimestamp = toSortableTimestamp(message.createdAt);
245 if (messageTimestamp === null) continue;
246 latestUserMessageTimestamp =
247 latestUserMessageTimestamp === null
248 ? messageTimestamp
249 : Math.max(latestUserMessageTimestamp, messageTimestamp);
250 }
251
252 if (latestUserMessageTimestamp !== null) {
253 return latestUserMessageTimestamp;
254 }
255
256 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
257}
258
259function getThreadSortTimestamp(
260 thread: SidebarThreadSortInput,
261 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
262): number {
263 if (sortOrder === "created_at") {
264 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
265 }
266 return getLatestUserMessageTimestamp(thread);
267}
268
269export function sortThreadsForSidebar<
270 T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "messages">,
271>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {
272 return threads.toSorted((left, right) => {
273 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
274 const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
275 const byTimestamp =
276 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
277 if (byTimestamp !== 0) return byTimestamp;
278 return right.id.localeCompare(left.id);
279 });
280}
281
282export function getProjectSortTimestamp(
283 project: SidebarProject,
284 projectThreads: readonly SidebarThreadSortInput[],
285 sortOrder: Exclude<SidebarProjectSortOrder, "manual">,
286): number {
287 if (projectThreads.length > 0) {
288 return projectThreads.reduce(
289 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
290 Number.NEGATIVE_INFINITY,
291 );
292 }
293
294 if (sortOrder === "created_at") {
295 return toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY;
296 }
297 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
298}
299
300export function sortProjectsForSidebar<TProject extends SidebarProject, TThread extends Thread>(
301 projects: readonly TProject[],
302 threads: readonly TThread[],
303 sortOrder: SidebarProjectSortOrder,
304): TProject[] {
305 if (sortOrder === "manual") {
306 return [...projects];
307 }
308
309 const threadsByProjectId = new Map<string, TThread[]>();
310 for (const thread of threads) {
311 const existing = threadsByProjectId.get(thread.projectId) ?? [];
312 existing.push(thread);
313 threadsByProjectId.set(thread.projectId, existing);
314 }
315
316 return [...projects].toSorted((left, right) => {
317 const rightTimestamp = getProjectSortTimestamp(
318 right,
319 threadsByProjectId.get(right.id) ?? [],
320 sortOrder,
321 );
322 const leftTimestamp = getProjectSortTimestamp(
323 left,
324 threadsByProjectId.get(left.id) ?? [],
325 sortOrder,
326 );
327 const byTimestamp =
328 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
329 if (byTimestamp !== 0) return byTimestamp;
330 return left.name.localeCompare(right.name) || left.id.localeCompare(right.id);
331 });
332}
apps/web/src/components/Sidebar.tsx+534-392
...@@ -1,5 +1,6 @@...@@ -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 {...@@ -10,6 +11,7 @@ import {
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 {...@@ -36,7 +38,11 @@ import {
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 {...@@ -64,6 +70,7 @@ import {
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,
...@@ -84,16 +91,33 @@ import { useThreadSelectionStore } from "../threadSelectionStore";...@@ -84,16 +91,33 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";91import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
85import { isNonEmpty as isNonEmptyString } from "effect/String";92import { isNonEmpty as isNonEmptyString } from "effect/String";
86import {93import {
94 getVisibleThreadsForProject,
87 resolveProjectStatusIndicator,95 resolveProjectStatusIndicator,
88 resolveSidebarNewThreadEnvMode,96 resolveSidebarNewThreadEnvMode,
89 resolveThreadRowClassName,97 resolveThreadRowClassName,
90 resolveThreadStatusPill,98 resolveThreadStatusPill,
91 shouldClearThreadSelectionOnMouseDown,99 shouldClearThreadSelectionOnMouseDown,
100 sortProjectsForSidebar,
101 sortThreadsForSidebar,
92} from "./Sidebar.logic";102} from "./Sidebar.logic";
93import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";103import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
94104
95const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];105const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
96const THREAD_PREVIEW_LIMIT = 6;106const THREAD_PREVIEW_LIMIT = 6;
107const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
108 updated_at: "Last user message",
109 created_at: "Created at",
110 manual: "Manual",
111};
112const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
113 updated_at: "Last user message",
114 created_at: "Created at",
115};
116const SIDEBAR_LIST_ANIMATION_OPTIONS = {
117 duration: 180,
118 easing: "ease-out",
119} as const;
120const loadedProjectFaviconSrcs = new Set<string>();
97121
98function formatRelativeTime(iso: string): string {122function formatRelativeTime(iso: string): string {
99 const diff = Date.now() - new Date(iso).getTime();123 const diff = Date.now() - new Date(iso).getTime();
...@@ -204,9 +228,10 @@ function getServerHttpOrigin(): string {...@@ -204,9 +228,10 @@ function getServerHttpOrigin(): string {
204const serverHttpOrigin = getServerHttpOrigin();228const serverHttpOrigin = getServerHttpOrigin();
205229
206function ProjectFavicon({ cwd }: { cwd: string }) {230function ProjectFavicon({ cwd }: { cwd: string }) {
207 const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
208
209 const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;231 const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
232 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
233 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
234 );
210235
211 if (status === "error") {236 if (status === "error") {
212 return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />;237 return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />;
...@@ -217,23 +242,106 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -217,23 +242,106 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
217 src={src}242 src={src}
218 alt=""243 alt=""
219 className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`}244 className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`}
220 onLoad={() => setStatus("loaded")}245 onLoad={() => {
246 loadedProjectFaviconSrcs.add(src);
247 setStatus("loaded");
248 }}
221 onError={() => setStatus("error")}249 onError={() => setStatus("error")}
222 />250 />
223 );251 );
224}252}
225253
226type SortableProjectHandleProps = Pick<ReturnType<typeof useSortable>, "attributes" | "listeners">;254type SortableProjectHandleProps = Pick<
255 ReturnType<typeof useSortable>,
256 "attributes" | "listeners" | "setActivatorNodeRef"
257>;
258
259function ProjectSortMenu({
260 projectSortOrder,
261 threadSortOrder,
262 onProjectSortOrderChange,
263 onThreadSortOrderChange,
264}: {
265 projectSortOrder: SidebarProjectSortOrder;
266 threadSortOrder: SidebarThreadSortOrder;
267 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
268 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
269}) {
270 return (
271 <Menu>
272 <Tooltip>
273 <TooltipTrigger
274 render={
275 <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" />
276 }
277 >
278 <ArrowUpDownIcon className="size-3.5" />
279 </TooltipTrigger>
280 <TooltipPopup side="right">Sort projects</TooltipPopup>
281 </Tooltip>
282 <MenuPopup align="end" side="bottom" className="min-w-44">
283 <MenuGroup>
284 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
285 Sort projects
286 </div>
287 <MenuRadioGroup
288 value={projectSortOrder}
289 onValueChange={(value) => {
290 onProjectSortOrderChange(value as SidebarProjectSortOrder);
291 }}
292 >
293 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
294 ([value, label]) => (
295 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
296 {label}
297 </MenuRadioItem>
298 ),
299 )}
300 </MenuRadioGroup>
301 </MenuGroup>
302 <MenuGroup>
303 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
304 Sort threads
305 </div>
306 <MenuRadioGroup
307 value={threadSortOrder}
308 onValueChange={(value) => {
309 onThreadSortOrderChange(value as SidebarThreadSortOrder);
310 }}
311 >
312 {(
313 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
314 ).map(([value, label]) => (
315 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
316 {label}
317 </MenuRadioItem>
318 ))}
319 </MenuRadioGroup>
320 </MenuGroup>
321 </MenuPopup>
322 </Menu>
323 );
324}
227325
228function SortableProjectItem({326function SortableProjectItem({
229 projectId,327 projectId,
328 disabled = false,
230 children,329 children,
231}: {330}: {
232 projectId: ProjectId;331 projectId: ProjectId;
332 disabled?: boolean;
233 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;333 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
234}) {334}) {
235 const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =335 const {
236 useSortable({ id: projectId });336 attributes,
337 listeners,
338 setActivatorNodeRef,
339 setNodeRef,
340 transform,
341 transition,
342 isDragging,
343 isOver,
344 } = useSortable({ id: projectId, disabled });
237 return (345 return (
238 <li346 <li
239 ref={setNodeRef}347 ref={setNodeRef}
...@@ -247,7 +355,7 @@ function SortableProjectItem({...@@ -247,7 +355,7 @@ function SortableProjectItem({
247 data-sidebar="menu-item"355 data-sidebar="menu-item"
248 data-slot="sidebar-menu-item"356 data-slot="sidebar-menu-item"
249 >357 >
250 {children({ attributes, listeners })}358 {children({ attributes, listeners, setActivatorNodeRef })}
251 </li>359 </li>
252 );360 );
253}361}
...@@ -272,7 +380,7 @@ export default function Sidebar() {...@@ -272,7 +380,7 @@ export default function Sidebar() {
272 );380 );
273 const navigate = useNavigate();381 const navigate = useNavigate();
274 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });382 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
275 const { settings: appSettings } = useAppSettings();383 const { settings: appSettings, updateSettings } = useAppSettings();
276 const { handleNewThread } = useHandleNewThread();384 const { handleNewThread } = useHandleNewThread();
277 const routeThreadId = useParams({385 const routeThreadId = useParams({
278 strict: false,386 strict: false,
...@@ -385,13 +493,10 @@ export default function Sidebar() {...@@ -385,13 +493,10 @@ export default function Sidebar() {
385493
386 const focusMostRecentThreadForProject = useCallback(494 const focusMostRecentThreadForProject = useCallback(
387 (projectId: ProjectId) => {495 (projectId: ProjectId) => {
388 const latestThread = threads496 const latestThread = sortThreadsForSidebar(
389 .filter((thread) => thread.projectId === projectId)497 threads.filter((thread) => thread.projectId === projectId),
390 .toSorted((a, b) => {498 appSettings.sidebarThreadSortOrder,
391 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();499 )[0];
392 if (byDate !== 0) return byDate;
393 return b.id.localeCompare(a.id);
394 })[0];
395 if (!latestThread) return;500 if (!latestThread) return;
396501
397 void navigate({502 void navigate({
...@@ -399,7 +504,7 @@ export default function Sidebar() {...@@ -399,7 +504,7 @@ export default function Sidebar() {
399 params: { threadId: latestThread.id },504 params: { threadId: latestThread.id },
400 });505 });
401 },506 },
402 [navigate, threads],507 [appSettings.sidebarThreadSortOrder, navigate, threads],
403 );508 );
404509
405 const addProjectFromPath = useCallback(510 const addProjectFromPath = useCallback(
...@@ -931,6 +1036,10 @@ export default function Sidebar() {...@@ -931,6 +1036,10 @@ export default function Sidebar() {
9311036
932 const handleProjectDragEnd = useCallback(1037 const handleProjectDragEnd = useCallback(
933 (event: DragEndEvent) => {1038 (event: DragEndEvent) => {
1039 if (appSettings.sidebarProjectSortOrder !== "manual") {
1040 dragInProgressRef.current = false;
1041 return;
1042 }
934 dragInProgressRef.current = false;1043 dragInProgressRef.current = false;
935 const { active, over } = event;1044 const { active, over } = event;
936 if (!over || active.id === over.id) return;1045 if (!over || active.id === over.id) return;
...@@ -939,22 +1048,361 @@ export default function Sidebar() {...@@ -939,22 +1048,361 @@ export default function Sidebar() {
939 if (!activeProject || !overProject) return;1048 if (!activeProject || !overProject) return;
940 reorderProjects(activeProject.id, overProject.id);1049 reorderProjects(activeProject.id, overProject.id);
941 },1050 },
942 [projects, reorderProjects],1051 [appSettings.sidebarProjectSortOrder, projects, reorderProjects],
943 );1052 );
9441053
945 const handleProjectDragStart = useCallback((_event: DragStartEvent) => {1054 const handleProjectDragStart = useCallback(
946 dragInProgressRef.current = true;1055 (_event: DragStartEvent) => {
947 suppressProjectClickAfterDragRef.current = true;1056 if (appSettings.sidebarProjectSortOrder !== "manual") {
948 }, []);1057 return;
1058 }
1059 dragInProgressRef.current = true;
1060 suppressProjectClickAfterDragRef.current = true;
1061 },
1062 [appSettings.sidebarProjectSortOrder],
1063 );
9491064
950 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {1065 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
951 dragInProgressRef.current = false;1066 dragInProgressRef.current = false;
952 }, []);1067 }, []);
9531068
1069 const animatedProjectListsRef = useRef(new WeakSet<HTMLElement>());
1070 const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
1071 if (!node || animatedProjectListsRef.current.has(node)) {
1072 return;
1073 }
1074 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
1075 animatedProjectListsRef.current.add(node);
1076 }, []);
1077
1078 const animatedThreadListsRef = useRef(new WeakSet<HTMLElement>());
1079 const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
1080 if (!node || animatedThreadListsRef.current.has(node)) {
1081 return;
1082 }
1083 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
1084 animatedThreadListsRef.current.add(node);
1085 }, []);
1086
954 const handleProjectTitlePointerDownCapture = useCallback(() => {1087 const handleProjectTitlePointerDownCapture = useCallback(() => {
955 suppressProjectClickAfterDragRef.current = false;1088 suppressProjectClickAfterDragRef.current = false;
956 }, []);1089 }, []);
9571090
1091 const sortedProjects = useMemo(
1092 () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder),
1093 [appSettings.sidebarProjectSortOrder, projects, threads],
1094 );
1095 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1096
1097 function renderProjectItem(
1098 project: (typeof sortedProjects)[number],
1099 dragHandleProps: SortableProjectHandleProps | null,
1100 ) {
1101 const projectThreads = sortThreadsForSidebar(
1102 threads.filter((thread) => thread.projectId === project.id),
1103 appSettings.sidebarThreadSortOrder,
1104 );
1105 const projectStatus = resolveProjectStatusIndicator(
1106 projectThreads.map((thread) =>
1107 resolveThreadStatusPill({
1108 thread,
1109 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1110 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1111 }),
1112 ),
1113 );
1114 const activeThreadId = routeThreadId ?? undefined;
1115 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1116 const pinnedCollapsedThread =
1117 !project.expanded && activeThreadId
1118 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
1119 : null;
1120 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1121 const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({
1122 threads: projectThreads,
1123 activeThreadId,
1124 isThreadListExpanded,
1125 previewLimit: THREAD_PREVIEW_LIMIT,
1126 });
1127 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1128 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads;
1129 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
1130 const isActive = routeThreadId === thread.id;
1131 const isSelected = selectedThreadIds.has(thread.id);
1132 const isHighlighted = isActive || isSelected;
1133 const threadStatus = resolveThreadStatusPill({
1134 thread,
1135 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1136 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1137 });
1138 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1139 const terminalStatus = terminalStatusFromRunningIds(
1140 selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
1141 );
1142
1143 return (
1144 <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
1145 <SidebarMenuSubButton
1146 render={<div role="button" tabIndex={0} />}
1147 size="sm"
1148 isActive={isActive}
1149 className={resolveThreadRowClassName({
1150 isActive,
1151 isSelected,
1152 })}
1153 onClick={(event) => {
1154 handleThreadClick(event, thread.id, orderedProjectThreadIds);
1155 }}
1156 onKeyDown={(event) => {
1157 if (event.key !== "Enter" && event.key !== " ") return;
1158 event.preventDefault();
1159 if (selectedThreadIds.size > 0) {
1160 clearSelection();
1161 }
1162 setSelectionAnchor(thread.id);
1163 void navigate({
1164 to: "/$threadId",
1165 params: { threadId: thread.id },
1166 });
1167 }}
1168 onContextMenu={(event) => {
1169 event.preventDefault();
1170 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1171 void handleMultiSelectContextMenu({
1172 x: event.clientX,
1173 y: event.clientY,
1174 });
1175 } else {
1176 if (selectedThreadIds.size > 0) {
1177 clearSelection();
1178 }
1179 void handleThreadContextMenu(thread.id, {
1180 x: event.clientX,
1181 y: event.clientY,
1182 });
1183 }
1184 }}
1185 >
1186 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1187 {prStatus && (
1188 <Tooltip>
1189 <TooltipTrigger
1190 render={
1191 <button
1192 type="button"
1193 aria-label={prStatus.tooltip}
1194 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1195 onClick={(event) => {
1196 openPrLink(event, prStatus.url);
1197 }}
1198 >
1199 <GitPullRequestIcon className="size-3" />
1200 </button>
1201 }
1202 />
1203 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1204 </Tooltip>
1205 )}
1206 {threadStatus && (
1207 <span
1208 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1209 >
1210 <span
1211 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1212 threadStatus.pulse ? "animate-pulse" : ""
1213 }`}
1214 />
1215 <span className="hidden md:inline">{threadStatus.label}</span>
1216 </span>
1217 )}
1218 {renamingThreadId === thread.id ? (
1219 <input
1220 ref={(el) => {
1221 if (el && renamingInputRef.current !== el) {
1222 renamingInputRef.current = el;
1223 el.focus();
1224 el.select();
1225 }
1226 }}
1227 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1228 value={renamingTitle}
1229 onChange={(e) => setRenamingTitle(e.target.value)}
1230 onKeyDown={(e) => {
1231 e.stopPropagation();
1232 if (e.key === "Enter") {
1233 e.preventDefault();
1234 renamingCommittedRef.current = true;
1235 void commitRename(thread.id, renamingTitle, thread.title);
1236 } else if (e.key === "Escape") {
1237 e.preventDefault();
1238 renamingCommittedRef.current = true;
1239 cancelRename();
1240 }
1241 }}
1242 onBlur={() => {
1243 if (!renamingCommittedRef.current) {
1244 void commitRename(thread.id, renamingTitle, thread.title);
1245 }
1246 }}
1247 onClick={(e) => e.stopPropagation()}
1248 />
1249 ) : (
1250 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
1251 )}
1252 </div>
1253 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1254 {terminalStatus && (
1255 <span
1256 role="img"
1257 aria-label={terminalStatus.label}
1258 title={terminalStatus.label}
1259 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1260 >
1261 <TerminalIcon
1262 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1263 />
1264 </span>
1265 )}
1266 <span
1267 className={`text-[10px] ${
1268 isHighlighted
1269 ? "text-foreground/72 dark:text-foreground/82"
1270 : "text-muted-foreground/40"
1271 }`}
1272 >
1273 {formatRelativeTime(thread.updatedAt ?? thread.createdAt)}
1274 </span>
1275 </div>
1276 </SidebarMenuSubButton>
1277 </SidebarMenuSubItem>
1278 );
1279 };
1280
1281 return (
1282 <Collapsible className="group/collapsible" open={shouldShowThreadPanel}>
1283 <div className="group/project-header relative">
1284 <SidebarMenuButton
1285 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1286 size="sm"
1287 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 ${
1288 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1289 }`}
1290 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1291 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1292 onPointerDownCapture={handleProjectTitlePointerDownCapture}
1293 onClick={(event) => handleProjectTitleClick(event, project.id)}
1294 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1295 onContextMenu={(event) => {
1296 event.preventDefault();
1297 void handleProjectContextMenu(project.id, {
1298 x: event.clientX,
1299 y: event.clientY,
1300 });
1301 }}
1302 >
1303 {!project.expanded && projectStatus ? (
1304 <span
1305 aria-hidden="true"
1306 title={projectStatus.label}
1307 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1308 >
1309 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1310 <span
1311 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1312 projectStatus.pulse ? "animate-pulse" : ""
1313 }`}
1314 />
1315 </span>
1316 <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" />
1317 </span>
1318 ) : (
1319 <ChevronRightIcon
1320 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1321 project.expanded ? "rotate-90" : ""
1322 }`}
1323 />
1324 )}
1325 <ProjectFavicon cwd={project.cwd} />
1326 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1327 {project.name}
1328 </span>
1329 </SidebarMenuButton>
1330 <Tooltip>
1331 <TooltipTrigger
1332 render={
1333 <SidebarMenuAction
1334 render={
1335 <button
1336 type="button"
1337 aria-label={`Create new thread in ${project.name}`}
1338 data-testid="new-thread-button"
1339 />
1340 }
1341 showOnHover
1342 className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1343 onClick={(event) => {
1344 event.preventDefault();
1345 event.stopPropagation();
1346 void handleNewThread(project.id, {
1347 envMode: resolveSidebarNewThreadEnvMode({
1348 defaultEnvMode: appSettings.defaultThreadEnvMode,
1349 }),
1350 });
1351 }}
1352 >
1353 <SquarePenIcon className="size-3.5" />
1354 </SidebarMenuAction>
1355 }
1356 />
1357 <TooltipPopup side="top">
1358 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1359 </TooltipPopup>
1360 </Tooltip>
1361 </div>
1362
1363 <CollapsibleContent>
1364 <SidebarMenuSub
1365 ref={attachThreadListAutoAnimateRef}
1366 className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0"
1367 >
1368 {renderedThreads.map((thread) => renderThreadRow(thread))}
1369
1370 {project.expanded && 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 expandThreadListForProject(project.id);
1379 }}
1380 >
1381 <span>Show more</span>
1382 </SidebarMenuSubButton>
1383 </SidebarMenuSubItem>
1384 )}
1385 {project.expanded && hasHiddenThreads && isThreadListExpanded && (
1386 <SidebarMenuSubItem className="w-full">
1387 <SidebarMenuSubButton
1388 render={<button type="button" />}
1389 data-thread-selection-safe
1390 size="sm"
1391 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"
1392 onClick={() => {
1393 collapseThreadListForProject(project.id);
1394 }}
1395 >
1396 <span>Show less</span>
1397 </SidebarMenuSubButton>
1398 </SidebarMenuSubItem>
1399 )}
1400 </SidebarMenuSub>
1401 </CollapsibleContent>
1402 </Collapsible>
1403 );
1404 }
1405
958 const handleProjectTitleClick = useCallback(1406 const handleProjectTitleClick = useCallback(
959 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {1407 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
960 if (dragInProgressRef.current) {1408 if (dragInProgressRef.current) {
...@@ -1063,12 +1511,9 @@ export default function Sidebar() {...@@ -1063,12 +1511,9 @@ export default function Sidebar() {
1063 : shouldHighlightDesktopUpdateError(desktopUpdateState)1511 : shouldHighlightDesktopUpdateError(desktopUpdateState)
1064 ? "text-rose-500 animate-pulse"1512 ? "text-rose-500 animate-pulse"
1065 : "text-amber-500 animate-pulse";1513 : "text-amber-500 animate-pulse";
1066 const newThreadShortcutLabel = useMemo(1514 const newThreadShortcutLabel =
1067 () =>1515 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1068 shortcutLabelForCommand(keybindings, "chat.newLocal") ??1516 shortcutLabelForCommand(keybindings, "chat.new");
1069 shortcutLabelForCommand(keybindings, "chat.new"),
1070 [keybindings],
1071 );
10721517
1073 const handleDesktopUpdateButtonClick = useCallback(() => {1518 const handleDesktopUpdateButtonClick = useCallback(() => {
1074 const bridge = window.desktopBridge;1519 const bridge = window.desktopBridge;
...@@ -1232,28 +1677,40 @@ export default function Sidebar() {...@@ -1232,28 +1677,40 @@ export default function Sidebar() {
1232 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">1677 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1233 Projects1678 Projects
1234 </span>1679 </span>
1235 <Tooltip>1680 <div className="flex items-center gap-1">
1236 <TooltipTrigger1681 <ProjectSortMenu
1237 render={1682 projectSortOrder={appSettings.sidebarProjectSortOrder}
1238 <button1683 threadSortOrder={appSettings.sidebarThreadSortOrder}
1239 type="button"1684 onProjectSortOrderChange={(sortOrder) => {
1240 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}1685 updateSettings({ sidebarProjectSortOrder: sortOrder });
1241 aria-pressed={shouldShowProjectPathEntry}1686 }}
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"1687 onThreadSortOrderChange={(sortOrder) => {
1243 onClick={handleStartAddProject}1688 updateSettings({ sidebarThreadSortOrder: sortOrder });
1689 }}
1690 />
1691 <Tooltip>
1692 <TooltipTrigger
1693 render={
1694 <button
1695 type="button"
1696 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1697 aria-pressed={shouldShowProjectPathEntry}
1698 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"
1699 onClick={handleStartAddProject}
1700 />
1701 }
1702 >
1703 <PlusIcon
1704 className={`size-3.5 transition-transform duration-150 ${
1705 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1706 }`}
1244 />1707 />
1245 }1708 </TooltipTrigger>
1246 >1709 <TooltipPopup side="right">
1247 <PlusIcon1710 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1248 className={`size-3.5 transition-transform duration-150 ${1711 </TooltipPopup>
1249 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"1712 </Tooltip>
1250 }`}1713 </div>
1251 />
1252 </TooltipTrigger>
1253 <TooltipPopup side="right">
1254 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1255 </TooltipPopup>
1256 </Tooltip>
1257 </div>1714 </div>
12581715
1259 {shouldShowProjectPathEntry && (1716 {shouldShowProjectPathEntry && (
...@@ -1321,352 +1778,37 @@ export default function Sidebar() {...@@ -1321,352 +1778,37 @@ export default function Sidebar() {
1321 </div>1778 </div>
1322 )}1779 )}
13231780
1324 <DndContext1781 {isManualProjectSorting ? (
1325 sensors={projectDnDSensors}1782 <DndContext
1326 collisionDetection={projectCollisionDetection}1783 sensors={projectDnDSensors}
1327 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}1784 collisionDetection={projectCollisionDetection}
1328 onDragStart={handleProjectDragStart}1785 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1329 onDragEnd={handleProjectDragEnd}1786 onDragStart={handleProjectDragStart}
1330 onDragCancel={handleProjectDragCancel}1787 onDragEnd={handleProjectDragEnd}
1331 >1788 onDragCancel={handleProjectDragCancel}
1332 <SidebarMenu>1789 >
1333 <SortableContext1790 <SidebarMenu>
1334 items={projects.map((project) => project.id)}1791 <SortableContext
1335 strategy={verticalListSortingStrategy}1792 items={sortedProjects.map((project) => project.id)}
1336 >1793 strategy={verticalListSortingStrategy}
1337 {projects.map((project) => {1794 >
1338 const projectThreads = threads1795 {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}>1796 <SortableProjectItem key={project.id} projectId={project.id}>
1365 {(dragHandleProps) => (1797 {(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>1798 </SortableProjectItem>
1665 );1799 ))}
1666 })}1800 </SortableContext>
1667 </SortableContext>1801 </SidebarMenu>
1802 </DndContext>
1803 ) : (
1804 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1805 {sortedProjects.map((project) => (
1806 <SidebarMenuItem key={project.id} className="rounded-md">
1807 {renderProjectItem(project, null)}
1808 </SidebarMenuItem>
1809 ))}
1668 </SidebarMenu>1810 </SidebarMenu>
1669 </DndContext>1811 )}
16701812
1671 {projects.length === 0 && !shouldShowProjectPathEntry && (1813 {projects.length === 0 && !shouldShowProjectPathEntry && (
1672 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">1814 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
apps/web/src/store.test.ts+14
...@@ -226,6 +226,20 @@ describe("store read model sync", () => {...@@ -226,6 +226,20 @@ describe("store read model sync", () => {
226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
227 });227 });
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
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
...@@ -145,6 +145,8 @@ function mapProjectsFromReadModel(...@@ -145,6 +145,8 @@ function mapProjectsFromReadModel(
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...@@ -304,6 +306,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
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
...@@ -82,6 +82,8 @@ export interface Project {...@@ -82,6 +82,8 @@ export interface Project {
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}
8789
...@@ -98,6 +100,7 @@ export interface Thread {...@@ -98,6 +100,7 @@ export interface Thread {
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
...@@ -80,6 +80,7 @@...@@ -80,6 +80,7 @@
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 @@...@@ -358,6 +359,8 @@
358359
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=="],
360361
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=="],
362365
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=="],