authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-22 17:26:41+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 17:26:41+02:00
log18fa89c4adaae722e619f6abde093becf80efc08
treeae47196c180750ef2d0db2c6b744ee9b18ac853a
parentf74eb62661734927aea45a243e2c3effced43fb9
signature Signed by PGP key B5690EEEBB952194

Preserve draft thread highlighting during promotion (#4283)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 61 insertions(+), 3 deletions(-)

apps/web/src/components/Sidebar.tsx+10-3
...@@ -118,7 +118,7 @@ import { vcsEnvironment } from "../state/vcs";...@@ -118,7 +118,7 @@ import { vcsEnvironment } from "../state/vcs";
118import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";118import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
119import {119import {
120 buildThreadRouteParams,120 buildThreadRouteParams,
121 resolveThreadRouteRef,121 resolveActiveThreadRouteRef,
122 resolveThreadRouteTarget,122 resolveThreadRouteTarget,
123} from "../threadRoutes";123} from "../threadRoutes";
124import { stackedThreadToast, toastManager } from "./ui/toast";124import { stackedThreadToast, toastManager } from "./ui/toast";
...@@ -3068,10 +3068,17 @@ export default function Sidebar() {...@@ -3068,10 +3068,17 @@ export default function Sidebar() {
3068 const handleNewThread = useNewThreadHandler();3068 const handleNewThread = useNewThreadHandler();
3069 const { archiveThread, deleteThread } = useThreadActions();3069 const { archiveThread, deleteThread } = useThreadActions();
3070 const { isMobile, setOpenMobile } = useSidebar();3070 const { isMobile, setOpenMobile } = useSidebar();
3071 const routeThreadRef = useParams({3071 const routeTarget = useParams({
3072 strict: false,3072 strict: false,
3073 select: (params) => resolveThreadRouteRef(params),3073 select: (params) => resolveThreadRouteTarget(params),
3074 });3074 });
3075 const routeDraftThread = useComposerDraftStore((store) =>
3076 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
3077 );
3078 const routeThreadRef = useMemo(
3079 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
3080 [routeDraftThread, routeTarget],
3081 );
3075 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;3082 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
3076 const routeTerminalOpen = useTerminalUiStateStore((state) =>3083 const routeTerminalOpen = useTerminalUiStateStore((state) =>
3077 routeThreadRef3084 routeThreadRef
apps/web/src/threadRoutes.test.ts+28
...@@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore";...@@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore";
6import {6import {
7 buildDraftThreadRouteParams,7 buildDraftThreadRouteParams,
8 buildThreadRouteParams,8 buildThreadRouteParams,
9 resolveActiveThreadRouteRef,
9 resolveThreadRouteRef,10 resolveThreadRouteRef,
10 resolveThreadRouteTarget,11 resolveThreadRouteTarget,
11} from "./threadRoutes";12} from "./threadRoutes";
...@@ -64,4 +65,31 @@ describe("threadRoutes", () => {...@@ -64,4 +65,31 @@ describe("threadRoutes", () => {
64 draftId: "draft-1",65 draftId: "draft-1",
65 });66 });
66 });67 });
68
69 it("resolves the backing thread while a draft route is being promoted", () => {
70 const target = resolveThreadRouteTarget({ draftId: "draft-1" });
71
72 expect(
73 resolveActiveThreadRouteRef(target, {
74 environmentId: "env-1" as never,
75 threadId: ThreadId.make("draft-thread"),
76 promotedTo: scopeThreadRef("env-2" as never, ThreadId.make("server-thread")),
77 }),
78 ).toEqual({
79 environmentId: "env-2",
80 threadId: "server-thread",
81 });
82 });
83
84 it("does not treat a draft's reserved thread ref as an active sidebar thread", () => {
85 const target = resolveThreadRouteTarget({ draftId: "draft-1" });
86
87 expect(
88 resolveActiveThreadRouteRef(target, {
89 environmentId: "env-1" as never,
90 threadId: ThreadId.make("draft-thread"),
91 promotedTo: null,
92 }),
93 ).toBeNull();
94 });
67});95});
apps/web/src/threadRoutes.ts+23
...@@ -12,6 +12,12 @@ export type ThreadRouteTarget =...@@ -12,6 +12,12 @@ export type ThreadRouteTarget =
12 draftId: DraftId;12 draftId: DraftId;
13 };13 };
1414
15type DraftThreadRouteState = {
16 environmentId: EnvironmentId;
17 threadId: ThreadId;
18 promotedTo?: ScopedThreadRef | null;
19};
20
15export function buildThreadRouteParams(ref: ScopedThreadRef): {21export function buildThreadRouteParams(ref: ScopedThreadRef): {
16 environmentId: EnvironmentId;22 environmentId: EnvironmentId;
17 threadId: ThreadId;23 threadId: ThreadId;
...@@ -57,3 +63,20 @@ export function resolveThreadRouteTarget(...@@ -57,3 +63,20 @@ export function resolveThreadRouteTarget(
57 draftId: params.draftId as DraftId,63 draftId: params.draftId as DraftId,
58 };64 };
59}65}
66
67/**
68 * Resolves the thread represented by either a canonical thread route or a
69 * draft route whose promotion to a server thread has been recorded.
70 */
71export function resolveActiveThreadRouteRef(
72 target: ThreadRouteTarget | null,
73 draftThread: DraftThreadRouteState | null,
74): ScopedThreadRef | null {
75 if (target?.kind === "server") {
76 return target.threadRef;
77 }
78 if (target?.kind !== "draft" || !draftThread?.promotedTo) {
79 return null;
80 }
81 return draftThread.promotedTo;
82}