authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 23:38:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 23:38:53-07:00
logbf71e0bc5eb0af9494a5969302f27f3d95b694c5
tree9422223961c3e4ae87ffdfff7703ed481b6dc1a3
parenta542a3b168423ce5d13e46edb08c0a98cc3b57df
signature Signed by PGP key B5690EEEBB952194

Pick sidebar fallback thread after delete (#1395)


3 files changed, 106 insertions(+), 2 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+71
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 getFallbackThreadIdAfterDelete,
45 getVisibleThreadsForProject,
56 getProjectSortTimestamp,
67 hasUnseenCompletion,
......@@ -516,6 +517,76 @@ describe("sortThreadsForSidebar", () => {
516517 });
517518});
518519
520describe("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
519590describe("sortProjectsForSidebar", () => {
520591 it("sorts projects by the most recent user message across their threads", () => {
521592 const projects = [
apps/web/src/components/Sidebar.logic.ts+27
......@@ -279,6 +279,33 @@ export function sortThreadsForSidebar<
279279 });
280280}
281281
282export 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
282309export function getProjectSortTimestamp(
283310 project: SidebarProject,
284311 projectThreads: readonly SidebarThreadSortInput[],
apps/web/src/components/Sidebar.tsx+8-2
......@@ -91,6 +91,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
9191import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
9292import { isNonEmpty as isNonEmptyString } from "effect/String";
9393import {
94 getFallbackThreadIdAfterDelete,
9495 getVisibleThreadsForProject,
9596 resolveProjectStatusIndicator,
9697 resolveSidebarNewThreadEnvMode,
......@@ -715,8 +716,12 @@ export default function Sidebar() {
715716
716717 const allDeletedIds = deletedIds ?? new Set<ThreadId>();
717718 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 });
720725 await api.orchestration.dispatchCommand({
721726 type: "thread.delete",
722727 commandId: newCommandId(),
......@@ -763,6 +768,7 @@ export default function Sidebar() {
763768 }
764769 },
765770 [
771 appSettings.sidebarThreadSortOrder,
766772 clearComposerDraftForThread,
767773 clearProjectDraftThreadById,
768774 clearTerminalState,