| 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,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | getFallbackThreadIdAfterDelete, | ||
| 4 | getVisibleThreadsForProject, | 5 | getVisibleThreadsForProject, |
| 5 | getProjectSortTimestamp, | 6 | getProjectSortTimestamp, |
| 6 | hasUnseenCompletion, | 7 | hasUnseenCompletion, |
| ... | @@ -516,6 +517,76 @@ describe("sortThreadsForSidebar", () => { | ... | @@ -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 | describe("sortProjectsForSidebar", () => { | 590 | describe("sortProjectsForSidebar", () => { |
| 520 | it("sorts projects by the most recent user message across their threads", () => { | 591 | it("sorts projects by the most recent user message across their threads", () => { |
| 521 | const projects = [ | 592 | const projects = [ |
apps/web/src/components/Sidebar.logic.ts+27| ... | @@ -279,6 +279,33 @@ export function sortThreadsForSidebar< | ... | @@ -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 | export function getProjectSortTimestamp( | 309 | export function getProjectSortTimestamp( |
| 283 | project: SidebarProject, | 310 | project: SidebarProject, |
| 284 | projectThreads: readonly SidebarThreadSortInput[], | 311 | projectThreads: readonly SidebarThreadSortInput[], |
apps/web/src/components/Sidebar.tsx+8-2| ... | @@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; | ... | @@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 91 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 91 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 92 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 92 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 93 | import { | 93 | import { |
| 94 | getFallbackThreadIdAfterDelete, | ||
| 94 | getVisibleThreadsForProject, | 95 | getVisibleThreadsForProject, |
| 95 | resolveProjectStatusIndicator, | 96 | resolveProjectStatusIndicator, |
| 96 | resolveSidebarNewThreadEnvMode, | 97 | resolveSidebarNewThreadEnvMode, |
| ... | @@ -715,8 +716,12 @@ export default function Sidebar() { | ... | @@ -715,8 +716,12 @@ export default function Sidebar() { |
| 715 | 716 | ||
| 716 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); | 717 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); |
| 717 | const shouldNavigateToFallback = routeThreadId === threadId; | 718 | const shouldNavigateToFallback = routeThreadId === threadId; |
| 718 | const fallbackThreadId = | 719 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ |
| 719 | threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null; | 720 | threads, |
| 721 | deletedThreadId: threadId, | ||
| 722 | deletedThreadIds: allDeletedIds, | ||
| 723 | sortOrder: appSettings.sidebarThreadSortOrder, | ||
| 724 | }); | ||
| 720 | await api.orchestration.dispatchCommand({ | 725 | await api.orchestration.dispatchCommand({ |
| 721 | type: "thread.delete", | 726 | type: "thread.delete", |
| 722 | commandId: newCommandId(), | 727 | commandId: newCommandId(), |
| ... | @@ -763,6 +768,7 @@ export default function Sidebar() { | ... | @@ -763,6 +768,7 @@ export default function Sidebar() { |
| 763 | } | 768 | } |
| 764 | }, | 769 | }, |
| 765 | [ | 770 | [ |
| 771 | appSettings.sidebarThreadSortOrder, | ||
| 766 | clearComposerDraftForThread, | 772 | clearComposerDraftForThread, |
| 767 | clearProjectDraftThreadById, | 773 | clearProjectDraftThreadById, |
| 768 | clearTerminalState, | 774 | clearTerminalState, |