| author | |
| committer | |
| log | bf71e0bc5eb0af9494a5969302f27f3d95b694c5 |
| tree | 9422223961c3e4ae87ffdfff7703ed481b6dc1a3 |
| parent | a542a3b168423ce5d13e46edb08c0a98cc3b57df |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 106 insertions(+), 2 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+71| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | getFallbackThreadIdAfterDelete, | |
| 4 | 5 | getVisibleThreadsForProject, |
| 5 | 6 | getProjectSortTimestamp, |
| 6 | 7 | hasUnseenCompletion, |
| ... | ... | @@ -516,6 +517,76 @@ describe("sortThreadsForSidebar", () => { |
| 516 | 517 | }); |
| 517 | 518 | }); |
| 518 | 519 | |
| 520 | describe("getFallbackThreadIdAfterDelete", () => { | |
| 521 | it("returns the top remaining thread in the deleted thread's project sidebar order", () => { | |
| 522 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ | |
| 523 | threads: [ | |
| 524 | makeThread({ | |
| 525 | id: ThreadId.makeUnsafe("thread-oldest"), | |
| 526 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 527 | createdAt: "2026-03-09T10:00:00.000Z", | |
| 528 | messages: [], | |
| 529 | }), | |
| 530 | makeThread({ | |
| 531 | id: ThreadId.makeUnsafe("thread-active"), | |
| 532 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 533 | createdAt: "2026-03-09T10:05:00.000Z", | |
| 534 | messages: [], | |
| 535 | }), | |
| 536 | makeThread({ | |
| 537 | id: ThreadId.makeUnsafe("thread-newest"), | |
| 538 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 539 | createdAt: "2026-03-09T10:10:00.000Z", | |
| 540 | messages: [], | |
| 541 | }), | |
| 542 | makeThread({ | |
| 543 | id: ThreadId.makeUnsafe("thread-other-project"), | |
| 544 | projectId: ProjectId.makeUnsafe("project-2"), | |
| 545 | createdAt: "2026-03-09T10:20:00.000Z", | |
| 546 | messages: [], | |
| 547 | }), | |
| 548 | ], | |
| 549 | deletedThreadId: ThreadId.makeUnsafe("thread-active"), | |
| 550 | sortOrder: "created_at", | |
| 551 | }); | |
| 552 | ||
| 553 | expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); | |
| 554 | }); | |
| 555 | ||
| 556 | it("skips other threads being deleted in the same action", () => { | |
| 557 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ | |
| 558 | threads: [ | |
| 559 | makeThread({ | |
| 560 | id: ThreadId.makeUnsafe("thread-active"), | |
| 561 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 562 | createdAt: "2026-03-09T10:05:00.000Z", | |
| 563 | messages: [], | |
| 564 | }), | |
| 565 | makeThread({ | |
| 566 | id: ThreadId.makeUnsafe("thread-newest"), | |
| 567 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 568 | createdAt: "2026-03-09T10:10:00.000Z", | |
| 569 | messages: [], | |
| 570 | }), | |
| 571 | makeThread({ | |
| 572 | id: ThreadId.makeUnsafe("thread-next"), | |
| 573 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 574 | createdAt: "2026-03-09T10:07:00.000Z", | |
| 575 | messages: [], | |
| 576 | }), | |
| 577 | ], | |
| 578 | deletedThreadId: ThreadId.makeUnsafe("thread-active"), | |
| 579 | deletedThreadIds: new Set([ | |
| 580 | ThreadId.makeUnsafe("thread-active"), | |
| 581 | ThreadId.makeUnsafe("thread-newest"), | |
| 582 | ]), | |
| 583 | sortOrder: "created_at", | |
| 584 | }); | |
| 585 | ||
| 586 | expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-next")); | |
| 587 | }); | |
| 588 | }); | |
| 589 | ||
| 519 | 590 | describe("sortProjectsForSidebar", () => { |
| 520 | 591 | it("sorts projects by the most recent user message across their threads", () => { |
| 521 | 592 | const projects = [ |
apps/web/src/components/Sidebar.logic.ts+27| ... | ... | @@ -279,6 +279,33 @@ export function sortThreadsForSidebar< |
| 279 | 279 | }); |
| 280 | 280 | } |
| 281 | 281 | |
| 282 | export function getFallbackThreadIdAfterDelete< | |
| 283 | T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt" | "messages">, | |
| 284 | >(input: { | |
| 285 | threads: readonly T[]; | |
| 286 | deletedThreadId: T["id"]; | |
| 287 | sortOrder: SidebarThreadSortOrder; | |
| 288 | deletedThreadIds?: ReadonlySet<T["id"]>; | |
| 289 | }): T["id"] | null { | |
| 290 | const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; | |
| 291 | const deletedThread = threads.find((thread) => thread.id === deletedThreadId); | |
| 292 | if (!deletedThread) { | |
| 293 | return null; | |
| 294 | } | |
| 295 | ||
| 296 | return ( | |
| 297 | sortThreadsForSidebar( | |
| 298 | threads.filter( | |
| 299 | (thread) => | |
| 300 | thread.projectId === deletedThread.projectId && | |
| 301 | thread.id !== deletedThreadId && | |
| 302 | !deletedThreadIds?.has(thread.id), | |
| 303 | ), | |
| 304 | sortOrder, | |
| 305 | )[0]?.id ?? null | |
| 306 | ); | |
| 307 | } | |
| 308 | ||
| 282 | 309 | export function getProjectSortTimestamp( |
| 283 | 310 | project: SidebarProject, |
| 284 | 311 | projectThreads: readonly SidebarThreadSortInput[], |
apps/web/src/components/Sidebar.tsx+8-2| ... | ... | @@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 91 | 91 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 92 | 92 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 93 | 93 | import { |
| 94 | getFallbackThreadIdAfterDelete, | |
| 94 | 95 | getVisibleThreadsForProject, |
| 95 | 96 | resolveProjectStatusIndicator, |
| 96 | 97 | resolveSidebarNewThreadEnvMode, |
| ... | ... | @@ -715,8 +716,12 @@ export default function Sidebar() { |
| 715 | 716 | |
| 716 | 717 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); |
| 717 | 718 | const shouldNavigateToFallback = routeThreadId === threadId; |
| 718 | const fallbackThreadId = | |
| 719 | threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null; | |
| 719 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ | |
| 720 | threads, | |
| 721 | deletedThreadId: threadId, | |
| 722 | deletedThreadIds: allDeletedIds, | |
| 723 | sortOrder: appSettings.sidebarThreadSortOrder, | |
| 724 | }); | |
| 720 | 725 | await api.orchestration.dispatchCommand({ |
| 721 | 726 | type: "thread.delete", |
| 722 | 727 | commandId: newCommandId(), |
| ... | ... | @@ -763,6 +768,7 @@ export default function Sidebar() { |
| 763 | 768 | } |
| 764 | 769 | }, |
| 765 | 770 | [ |
| 771 | appSettings.sidebarThreadSortOrder, | |
| 766 | 772 | clearComposerDraftForThread, |
| 767 | 773 | clearProjectDraftThreadById, |
| 768 | 774 | clearTerminalState, |