From 18fa89c4adaae722e619f6abde093becf80efc08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:26:41 +0200 Subject: [PATCH] Preserve draft thread highlighting during promotion (#4283) Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 13 ++++++++++--- apps/web/src/threadRoutes.test.ts | 28 ++++++++++++++++++++++++++++ apps/web/src/threadRoutes.ts | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf3fa30cf8d61e1fb90f7b3ac6e9de075178e11e..a765fe64d926eb1949d4061e08eac3d36d566f0e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,7 +118,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, - resolveThreadRouteRef, + resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -3068,10 +3068,17 @@ export default function Sidebar() { const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); - const routeThreadRef = useParams({ + const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params) => resolveThreadRouteTarget(params), }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index d15a233a3047666e72dafcbd34ba355851783dd2..644cdccb5c16832f106b064cb816b4cb8f0a4eb4 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + resolveActiveThreadRouteRef, resolveThreadRouteRef, resolveThreadRouteTarget, } from "./threadRoutes"; @@ -64,4 +65,31 @@ describe("threadRoutes", () => { draftId: "draft-1", }); }); + + it("resolves the backing thread while a draft route is being promoted", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: scopeThreadRef("env-2" as never, ThreadId.make("server-thread")), + }), + ).toEqual({ + environmentId: "env-2", + threadId: "server-thread", + }); + }); + + it("does not treat a draft's reserved thread ref as an active sidebar thread", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: null, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 19a7d5ca6033575798a7c17e67978b791412a9c9..a4d853c0a7fcb345006f7a0010735c579932ef13 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -12,6 +12,12 @@ export type ThreadRouteTarget = draftId: DraftId; }; +type DraftThreadRouteState = { + environmentId: EnvironmentId; + threadId: ThreadId; + promotedTo?: ScopedThreadRef | null; +}; + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId; @@ -57,3 +63,20 @@ export function resolveThreadRouteTarget( draftId: params.draftId as DraftId, }; } + +/** + * Resolves the thread represented by either a canonical thread route or a + * draft route whose promotion to a server thread has been recorded. + */ +export function resolveActiveThreadRouteRef( + target: ThreadRouteTarget | null, + draftThread: DraftThreadRouteState | null, +): ScopedThreadRef | null { + if (target?.kind === "server") { + return target.threadRef; + } + if (target?.kind !== "draft" || !draftThread?.promotedTo) { + return null; + } + return draftThread.promotedTo; +} -- 2.54.0