diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9eebee3666..dcfe3f6b69 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, @@ -516,6 +517,76 @@ describe("sortThreadsForSidebar", () => { }); }); +describe("getFallbackThreadIdAfterDelete", () => { + it("returns the top remaining thread in the deleted thread's project sidebar order", () => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-oldest"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:00:00.000Z", + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-active"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:05:00.000Z", + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newest"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:10:00.000Z", + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-other-project"), + projectId: ProjectId.makeUnsafe("project-2"), + createdAt: "2026-03-09T10:20:00.000Z", + messages: [], + }), + ], + deletedThreadId: ThreadId.makeUnsafe("thread-active"), + sortOrder: "created_at", + }); + + expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); + }); + + it("skips other threads being deleted in the same action", () => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-active"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:05:00.000Z", + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newest"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:10:00.000Z", + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-next"), + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-09T10:07:00.000Z", + messages: [], + }), + ], + deletedThreadId: ThreadId.makeUnsafe("thread-active"), + deletedThreadIds: new Set([ + ThreadId.makeUnsafe("thread-active"), + ThreadId.makeUnsafe("thread-newest"), + ]), + sortOrder: "created_at", + }); + + expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-next")); + }); +}); + describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9ce12d3b8c..de5859af92 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -279,6 +279,33 @@ export function sortThreadsForSidebar< }); } +export function getFallbackThreadIdAfterDelete< + T extends Pick, +>(input: { + threads: readonly T[]; + deletedThreadId: T["id"]; + sortOrder: SidebarThreadSortOrder; + deletedThreadIds?: ReadonlySet; +}): T["id"] | null { + const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; + const deletedThread = threads.find((thread) => thread.id === deletedThreadId); + if (!deletedThread) { + return null; + } + + return ( + sortThreadsForSidebar( + threads.filter( + (thread) => + thread.projectId === deletedThread.projectId && + thread.id !== deletedThreadId && + !deletedThreadIds?.has(thread.id), + ), + sortOrder, + )[0]?.id ?? null + ); +} + export function getProjectSortTimestamp( project: SidebarProject, projectThreads: readonly SidebarThreadSortInput[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 120b7c4759..923c30b2f9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, @@ -715,8 +716,12 @@ export default function Sidebar() { const allDeletedIds = deletedIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; - const fallbackThreadId = - threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null; + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads, + deletedThreadId: threadId, + deletedThreadIds: allDeletedIds, + sortOrder: appSettings.sidebarThreadSortOrder, + }); await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), @@ -763,6 +768,7 @@ export default function Sidebar() { } }, [ + appSettings.sidebarThreadSortOrder, clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState,