Skip to content

Commit eb99338

Browse files
Sync git branch updates to draft thread context
- Update branch persistence to write to draft threads when no server thread exists - Derive toast thread scoping from draft routes - Add coverage for draft-thread branch sync
1 parent bbc2a3f commit eb99338

3 files changed

Lines changed: 158 additions & 39 deletions

File tree

‎apps/web/src/components/GitActionsControl.browser.tsx‎

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,25 @@ function createDeferredPromise<T>() {
2424

2525
const {
2626
activeRunStackedActionDeferredRef,
27+
activeDraftThreadRef,
28+
hasServerThreadRef,
2729
invalidateGitQueriesSpy,
2830
refreshGitStatusSpy,
2931
runStackedActionMutateAsyncSpy,
32+
setDraftThreadContextSpy,
3033
setThreadBranchSpy,
3134
toastAddSpy,
3235
toastCloseSpy,
3336
toastPromiseSpy,
3437
toastUpdateSpy,
3538
} = vi.hoisted(() => ({
3639
activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
40+
activeDraftThreadRef: { current: null as unknown },
41+
hasServerThreadRef: { current: true },
3742
invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
3843
refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
3944
runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
45+
setDraftThreadContextSpy: vi.fn(),
4046
setThreadBranchSpy: vi.fn(),
4147
toastAddSpy: vi.fn(() => "toast-1"),
4248
toastCloseSpy: vi.fn(),
@@ -123,6 +129,36 @@ vi.mock("~/localApi", () => ({
123129
readLocalApi: vi.fn(() => null),
124130
}));
125131

132+
vi.mock("~/composerDraftStore", async () => {
133+
const draftStoreState = {
134+
getDraftThreadByRef: () => activeDraftThreadRef.current,
135+
getDraftSession: () => activeDraftThreadRef.current,
136+
getDraftThread: () => activeDraftThreadRef.current,
137+
getDraftSessionByLogicalProjectKey: () => null,
138+
setDraftThreadContext: setDraftThreadContextSpy,
139+
setLogicalProjectDraftThreadId: vi.fn(),
140+
setProjectDraftThreadId: vi.fn(),
141+
hasDraftThreadsInEnvironment: () => false,
142+
clearDraftThread: vi.fn(),
143+
};
144+
145+
return {
146+
DraftId: {
147+
makeUnsafe: (value: string) => value,
148+
},
149+
useComposerDraftStore: Object.assign(
150+
(selector: (state: unknown) => unknown) => selector(draftStoreState),
151+
{ getState: () => draftStoreState },
152+
),
153+
markPromotedDraftThread: vi.fn(),
154+
markPromotedDraftThreadByRef: vi.fn(),
155+
markPromotedDraftThreads: vi.fn(),
156+
markPromotedDraftThreadsByRef: vi.fn(),
157+
finalizePromotedDraftThreadByRef: vi.fn(),
158+
finalizePromotedDraftThreadsByRef: vi.fn(),
159+
};
160+
});
161+
126162
vi.mock("~/store", () => ({
127163
selectEnvironmentState: (
128164
state: { environmentStateById: Record<string, unknown> },
@@ -155,13 +191,15 @@ vi.mock("~/store", () => ({
155191
setThreadBranch: setThreadBranchSpy,
156192
environmentStateById: {
157193
[ENVIRONMENT_A]: {
158-
threadShellById: {
159-
[SHARED_THREAD_ID]: {
160-
id: SHARED_THREAD_ID,
161-
branch: BRANCH_NAME,
162-
worktreePath: null,
163-
},
164-
},
194+
threadShellById: hasServerThreadRef.current
195+
? {
196+
[SHARED_THREAD_ID]: {
197+
id: SHARED_THREAD_ID,
198+
branch: BRANCH_NAME,
199+
worktreePath: null,
200+
},
201+
}
202+
: {},
165203
threadSessionById: {},
166204
threadTurnStateById: {},
167205
messageIdsByThreadId: {},
@@ -174,13 +212,15 @@ vi.mock("~/store", () => ({
174212
turnDiffSummaryByThreadId: {},
175213
},
176214
[ENVIRONMENT_B]: {
177-
threadShellById: {
178-
[SHARED_THREAD_ID]: {
179-
id: SHARED_THREAD_ID,
180-
branch: BRANCH_NAME,
181-
worktreePath: null,
182-
},
183-
},
215+
threadShellById: hasServerThreadRef.current
216+
? {
217+
[SHARED_THREAD_ID]: {
218+
id: SHARED_THREAD_ID,
219+
branch: BRANCH_NAME,
220+
worktreePath: null,
221+
},
222+
}
223+
: {},
184224
threadSessionById: {},
185225
threadTurnStateById: {},
186226
messageIdsByThreadId: {},
@@ -231,6 +271,8 @@ describe("GitActionsControl thread-scoped progress toast", () => {
231271
vi.useRealTimers();
232272
vi.clearAllMocks();
233273
activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
274+
activeDraftThreadRef.current = null;
275+
hasServerThreadRef.current = true;
234276
document.body.innerHTML = "";
235277
});
236278

@@ -343,4 +385,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {
343385
host.remove();
344386
}
345387
});
388+
389+
it("syncs the live branch into the active draft thread when no server thread exists", async () => {
390+
hasServerThreadRef.current = false;
391+
activeDraftThreadRef.current = {
392+
threadId: SHARED_THREAD_ID,
393+
environmentId: ENVIRONMENT_A,
394+
branch: null,
395+
worktreePath: null,
396+
};
397+
398+
const host = document.createElement("div");
399+
document.body.append(host);
400+
const screen = await render(
401+
<GitActionsControl
402+
gitCwd={GIT_CWD}
403+
activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
404+
/>,
405+
{
406+
container: host,
407+
},
408+
);
409+
410+
try {
411+
await Promise.resolve();
412+
413+
expect(setDraftThreadContextSpy).toHaveBeenCalledWith(
414+
scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID),
415+
{
416+
branch: BRANCH_NAME,
417+
worktreePath: null,
418+
},
419+
);
420+
expect(setThreadBranchSpy).not.toHaveBeenCalled();
421+
} finally {
422+
await screen.unmount();
423+
host.remove();
424+
}
425+
});
346426
});

‎apps/web/src/components/GitActionsControl.tsx‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050
import { newCommandId, randomUUID } from "~/lib/utils";
5151
import { resolvePathLinkTarget } from "~/terminal-links";
52+
import { useComposerDraftStore } from "~/composerDraftStore";
5253
import { readEnvironmentApi } from "~/environmentApi";
5354
import { readLocalApi } from "~/localApi";
5455
import { useStore } from "~/store";
@@ -219,6 +220,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
219220
[activeThreadRef],
220221
);
221222
const activeServerThread = useStore(activeServerThreadSelector);
223+
const activeDraftThread = useComposerDraftStore((store) =>
224+
activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null,
225+
);
226+
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
222227
const setThreadBranch = useStore((store) => store.setThreadBranch);
223228
const queryClient = useQueryClient();
224229
const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
@@ -246,27 +251,49 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
246251

247252
const persistThreadBranchSync = useCallback(
248253
(branch: string | null) => {
249-
if (!activeThreadRef || !activeServerThread || activeServerThread.branch === branch) {
254+
if (!activeThreadRef) {
250255
return;
251256
}
252257

253-
const worktreePath = activeServerThread.worktreePath;
254-
const api = readEnvironmentApi(activeThreadRef.environmentId);
255-
if (api) {
256-
void api.orchestration
257-
.dispatchCommand({
258-
type: "thread.meta.update",
259-
commandId: newCommandId(),
260-
threadId: activeThreadRef.threadId,
261-
branch,
262-
worktreePath,
263-
})
264-
.catch(() => undefined);
258+
if (activeServerThread) {
259+
if (activeServerThread.branch === branch) {
260+
return;
261+
}
262+
263+
const worktreePath = activeServerThread.worktreePath;
264+
const api = readEnvironmentApi(activeThreadRef.environmentId);
265+
if (api) {
266+
void api.orchestration
267+
.dispatchCommand({
268+
type: "thread.meta.update",
269+
commandId: newCommandId(),
270+
threadId: activeThreadRef.threadId,
271+
branch,
272+
worktreePath,
273+
})
274+
.catch(() => undefined);
275+
}
276+
277+
setThreadBranch(activeThreadRef, branch, worktreePath);
278+
return;
265279
}
266280

267-
setThreadBranch(activeThreadRef, branch, worktreePath);
281+
if (!activeDraftThread || activeDraftThread.branch === branch) {
282+
return;
283+
}
284+
285+
setDraftThreadContext(activeThreadRef, {
286+
branch,
287+
worktreePath: activeDraftThread.worktreePath,
288+
});
268289
},
269-
[activeServerThread, activeThreadRef, setThreadBranch],
290+
[
291+
activeDraftThread,
292+
activeServerThread,
293+
activeThreadRef,
294+
setDraftThreadContext,
295+
setThreadBranch,
296+
],
270297
);
271298

272299
const syncThreadBranchAfterGitAction = useCallback(

‎apps/web/src/components/ui/toast.tsx‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"use client";
22

33
import { Toast } from "@base-ui/react/toast";
4-
import { useEffect, type CSSProperties } from "react";
4+
import { useEffect, useMemo, type CSSProperties } from "react";
55
import { useParams } from "@tanstack/react-router";
6-
import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
6+
import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
77
import {
88
CheckIcon,
99
CircleAlertIcon,
@@ -16,7 +16,9 @@ import {
1616

1717
import { cn } from "~/lib/utils";
1818
import { buttonVariants } from "~/components/ui/button";
19+
import { useComposerDraftStore } from "~/composerDraftStore";
1920
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
21+
import { resolveThreadRouteTarget } from "~/threadRoutes";
2022
import {
2123
buildVisibleToastLayout,
2224
shouldHideCollapsedToastContent,
@@ -76,16 +78,26 @@ interface ToastProviderProps extends Toast.Provider.Props {
7678
}
7779

7880
function useActiveThreadRefFromRoute(): ScopedThreadRef | null {
79-
return useParams({
81+
const routeTarget = useParams({
8082
strict: false,
81-
select: (params) =>
82-
typeof params.environmentId === "string" && typeof params.threadId === "string"
83-
? {
84-
environmentId: EnvironmentId.makeUnsafe(params.environmentId),
85-
threadId: ThreadId.makeUnsafe(params.threadId),
86-
}
87-
: null,
83+
select: (params) => resolveThreadRouteTarget(params),
8884
});
85+
const activeDraftSession = useComposerDraftStore((store) =>
86+
routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
87+
);
88+
89+
return useMemo(() => {
90+
if (routeTarget?.kind === "server") {
91+
return routeTarget.threadRef;
92+
}
93+
if (routeTarget?.kind === "draft" && activeDraftSession) {
94+
return {
95+
environmentId: activeDraftSession.environmentId,
96+
threadId: activeDraftSession.threadId,
97+
};
98+
}
99+
return null;
100+
}, [activeDraftSession, routeTarget]);
89101
}
90102

91103
function ThreadToastVisibleAutoDismiss({

0 commit comments

Comments
 (0)