authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 12:59:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 19:59:30+00:00
logb547fee77e77b3e813ce81284aea25781fec28b3
treefb19f6812045917b0c01749a97339bbbab5710fe
parent7b3cdc6a8c69224aa2314e4969579c3e82b6b0ba
signature Signed by PGP key B5690EEEBB952194

Scope git toast state by thread ref (#1855)


8 files changed, 352 insertions(+), 79 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+1-1
......@@ -144,7 +144,7 @@ export function BranchToolbarBranchSelector({
144144 });
145145 }
146146 if (hasServerThread) {
147 setThreadBranchAction(activeThreadId, branch, worktreePath);
147 setThreadBranchAction(threadRef, branch, worktreePath);
148148 return;
149149 }
150150 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
apps/web/src/components/GitActionsControl.browser.tsx+133-26
......@@ -4,9 +4,9 @@ import { useState } from "react";
44import { afterEach, describe, expect, it, vi } from "vitest";
55import { render } from "vitest-browser-react";
66
7const THREAD_A = ThreadId.makeUnsafe("thread-a");
8const THREAD_B = ThreadId.makeUnsafe("thread-b");
9const ENVIRONMENT_ID = "environment-local" as never;
7const SHARED_THREAD_ID = ThreadId.makeUnsafe("thread-shared");
8const ENVIRONMENT_A = "environment-local" as never;
9const ENVIRONMENT_B = "environment-remote" as never;
1010const GIT_CWD = "/repo/project";
1111const BRANCH_NAME = "feature/toast-scope";
1212
......@@ -24,9 +24,12 @@ function createDeferredPromise<T>() {
2424
2525const {
2626 activeRunStackedActionDeferredRef,
27 activeDraftThreadRef,
28 hasServerThreadRef,
2729 invalidateGitQueriesSpy,
2830 refreshGitStatusSpy,
2931 runStackedActionMutateAsyncSpy,
32 setDraftThreadContextSpy,
3033 setThreadBranchSpy,
3134 toastAddSpy,
3235 toastCloseSpy,
......@@ -34,9 +37,12 @@ const {
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
132vi.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
126162vi.mock("~/store", () => ({
127163 selectEnvironmentState: (
128164 state: { environmentStateById: Record<string, unknown> },
......@@ -154,11 +190,37 @@ vi.mock("~/store", () => ({
154190 selector({
155191 setThreadBranch: setThreadBranchSpy,
156192 environmentStateById: {
157 [ENVIRONMENT_ID]: {
158 threadShellById: {
159 [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
160 [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
161 },
193 [ENVIRONMENT_A]: {
194 threadShellById: hasServerThreadRef.current
195 ? {
196 [SHARED_THREAD_ID]: {
197 id: SHARED_THREAD_ID,
198 branch: BRANCH_NAME,
199 worktreePath: null,
200 },
201 }
202 : {},
203 threadSessionById: {},
204 threadTurnStateById: {},
205 messageIdsByThreadId: {},
206 messageByThreadId: {},
207 activityIdsByThreadId: {},
208 activityByThreadId: {},
209 proposedPlanIdsByThreadId: {},
210 proposedPlanByThreadId: {},
211 turnDiffIdsByThreadId: {},
212 turnDiffSummaryByThreadId: {},
213 },
214 [ENVIRONMENT_B]: {
215 threadShellById: hasServerThreadRef.current
216 ? {
217 [SHARED_THREAD_ID]: {
218 id: SHARED_THREAD_ID,
219 branch: BRANCH_NAME,
220 worktreePath: null,
221 },
222 }
223 : {},
162224 threadSessionById: {},
163225 threadTurnStateById: {},
164226 messageIdsByThreadId: {},
......@@ -187,17 +249,19 @@ function findButtonByText(text: string): HTMLButtonElement | null {
187249}
188250
189251function Harness() {
190 const [activeThreadId, setActiveThreadId] = useState(THREAD_A);
252 const [activeThreadRef, setActiveThreadRef] = useState(
253 scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID),
254 );
191255
192256 return (
193257 <>
194 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>
195 Switch thread
258 <button
259 type="button"
260 onClick={() => setActiveThreadRef(scopeThreadRef(ENVIRONMENT_B, SHARED_THREAD_ID))}
261 >
262 Switch environment
196263 </button>
197 <GitActionsControl
198 gitCwd={GIT_CWD}
199 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)}
200 />
264 <GitActionsControl gitCwd={GIT_CWD} activeThreadRef={activeThreadRef} />
201265 </>
202266 );
203267}
......@@ -207,10 +271,12 @@ describe("GitActionsControl thread-scoped progress toast", () => {
207271 vi.useRealTimers();
208272 vi.clearAllMocks();
209273 activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
274 activeDraftThreadRef.current = null;
275 hasServerThreadRef.current = true;
210276 document.body.innerHTML = "";
211277 });
212278
213 it("keeps an in-flight git action toast pinned to the thread that started it", async () => {
279 it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => {
214280 vi.useFakeTimers();
215281
216282 const host = document.createElement("div");
......@@ -227,7 +293,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
227293
228294 expect(toastAddSpy).toHaveBeenCalledWith(
229295 expect.objectContaining({
230 data: { threadId: THREAD_A },
296 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
231297 title: "Pushing...",
232298 type: "loading",
233299 }),
......@@ -238,24 +304,27 @@ describe("GitActionsControl thread-scoped progress toast", () => {
238304 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
239305 "toast-1",
240306 expect.objectContaining({
241 data: { threadId: THREAD_A },
307 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
242308 title: "Pushing...",
243309 type: "loading",
244310 }),
245311 );
246312
247 const switchThreadButton = findButtonByText("Switch thread");
248 expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy();
249 if (!(switchThreadButton instanceof HTMLButtonElement)) {
250 throw new Error('Unable to find button containing "Switch thread"');
313 const switchEnvironmentButton = findButtonByText("Switch environment");
314 expect(
315 switchEnvironmentButton,
316 'Unable to find button containing "Switch environment"',
317 ).toBeTruthy();
318 if (!(switchEnvironmentButton instanceof HTMLButtonElement)) {
319 throw new Error('Unable to find button containing "Switch environment"');
251320 }
252 switchThreadButton.click();
321 switchEnvironmentButton.click();
253322 await vi.advanceTimersByTimeAsync(1_000);
254323
255324 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
256325 "toast-1",
257326 expect.objectContaining({
258 data: { threadId: THREAD_A },
327 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
259328 title: "Pushing...",
260329 type: "loading",
261330 }),
......@@ -284,7 +353,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
284353 const screen = await render(
285354 <GitActionsControl
286355 gitCwd={GIT_CWD}
287 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)}
356 activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
288357 />,
289358 {
290359 container: host,
......@@ -304,7 +373,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
304373 await vi.advanceTimersByTimeAsync(1);
305374 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
306375 expect(refreshGitStatusSpy).toHaveBeenCalledWith({
307 environmentId: ENVIRONMENT_ID,
376 environmentId: ENVIRONMENT_A,
308377 cwd: GIT_CWD,
309378 });
310379 } finally {
......@@ -316,4 +385,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {
316385 host.remove();
317386 }
318387 });
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 });
319426});
apps/web/src/components/GitActionsControl.tsx+44-18
......@@ -49,6 +49,7 @@ import {
4949import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050import { newCommandId, randomUUID } from "~/lib/utils";
5151import { resolvePathLinkTarget } from "~/terminal-links";
52import { useComposerDraftStore } from "~/composerDraftStore";
5253import { readEnvironmentApi } from "~/environmentApi";
5354import { readLocalApi } from "~/localApi";
5455import { useStore } from "~/store";
......@@ -209,17 +210,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
209210}
210211
211212export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
212 const activeThreadId = activeThreadRef?.threadId ?? null;
213213 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
214214 const threadToastData = useMemo(
215 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
216 [activeThreadId],
215 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
216 [activeThreadRef],
217217 );
218218 const activeServerThreadSelector = useMemo(
219219 () => createThreadSelectorByRef(activeThreadRef),
220220 [activeThreadRef],
221221 );
222222 const activeServerThread = useStore(activeServerThreadSelector);
223 const activeDraftThread = useComposerDraftStore((store) =>
224 activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null,
225 );
226 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
223227 const setThreadBranch = useStore((store) => store.setThreadBranch);
224228 const queryClient = useQueryClient();
225229 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
......@@ -247,27 +251,49 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
247251
248252 const persistThreadBranchSync = useCallback(
249253 (branch: string | null) => {
250 if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) {
254 if (!activeThreadRef) {
255 return;
256 }
257
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);
251278 return;
252279 }
253280
254 const worktreePath = activeServerThread.worktreePath;
255 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
256 if (api) {
257 void api.orchestration
258 .dispatchCommand({
259 type: "thread.meta.update",
260 commandId: newCommandId(),
261 threadId: activeThreadId,
262 branch,
263 worktreePath,
264 })
265 .catch(() => undefined);
281 if (!activeDraftThread || activeDraftThread.branch === branch) {
282 return;
266283 }
267284
268 setThreadBranch(activeThreadId, branch, worktreePath);
285 setDraftThreadContext(activeThreadRef, {
286 branch,
287 worktreePath: activeDraftThread.worktreePath,
288 });
269289 },
270 [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],
290 [
291 activeDraftThread,
292 activeServerThread,
293 activeThreadRef,
294 setDraftThreadContext,
295 setThreadBranch,
296 ],
271297 );
272298
273299 const syncThreadBranchAfterGitAction = useCallback(
apps/web/src/components/ui/toast.logic.test.ts+52-1
......@@ -1,5 +1,10 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
12import { assert, describe, it } from "vitest";
2import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
3import {
4 buildVisibleToastLayout,
5 shouldHideCollapsedToastContent,
6 shouldRenderThreadScopedToast,
7} from "./toast.logic";
38
49describe("shouldHideCollapsedToastContent", () => {
510 it("keeps a single visible toast readable", () => {
......@@ -61,3 +66,49 @@ describe("buildVisibleToastLayout", () => {
6166 );
6267 });
6368});
69
70describe("shouldRenderThreadScopedToast", () => {
71 const activeThreadRef = {
72 environmentId: "environment-a",
73 threadId: "thread-1",
74 } as ScopedThreadRef;
75
76 it("renders a toast scoped to the active thread ref", () => {
77 assert.equal(
78 shouldRenderThreadScopedToast(
79 {
80 threadRef: activeThreadRef,
81 },
82 activeThreadRef,
83 ),
84 true,
85 );
86 });
87
88 it("hides a scoped toast when the environment differs", () => {
89 assert.equal(
90 shouldRenderThreadScopedToast(
91 {
92 threadRef: {
93 environmentId: "environment-b",
94 threadId: "thread-1",
95 } as ScopedThreadRef,
96 },
97 activeThreadRef,
98 ),
99 false,
100 );
101 });
102
103 it("keeps legacy thread-id scoped toasts working", () => {
104 assert.equal(
105 shouldRenderThreadScopedToast(
106 {
107 threadId: "thread-1" as never,
108 },
109 activeThreadRef,
110 ),
111 true,
112 );
113 });
114});
apps/web/src/components/ui/toast.logic.ts+27
......@@ -1,3 +1,5 @@
1import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
2
13export function shouldHideCollapsedToastContent(
24 visibleToastIndex: number,
35 visibleToastCount: number,
......@@ -44,3 +46,28 @@ export function buildVisibleToastLayout<TToast extends object>(
4446function normalizeToastHeight(height: number | null | undefined): number {
4547 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0;
4648}
49
50export function shouldRenderThreadScopedToast(
51 data:
52 | {
53 threadRef?: ScopedThreadRef | null;
54 threadId?: ThreadId | null;
55 }
56 | undefined,
57 activeThreadRef: ScopedThreadRef | null,
58): boolean {
59 if (data?.threadRef) {
60 return (
61 activeThreadRef !== null &&
62 data.threadRef.environmentId === activeThreadRef.environmentId &&
63 data.threadRef.threadId === activeThreadRef.threadId
64 );
65 }
66
67 const toastThreadId = data?.threadId;
68 if (!toastThreadId) {
69 return true;
70 }
71
72 return activeThreadRef?.threadId === toastThreadId;
73}
apps/web/src/components/ui/toast.tsx+33-20
......@@ -1,9 +1,9 @@
11"use client";
22
33import { Toast } from "@base-ui/react/toast";
4import { useEffect, type CSSProperties } from "react";
4import { useEffect, useMemo, type CSSProperties } from "react";
55import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";
6import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
77import {
88 CheckIcon,
99 CircleAlertIcon,
......@@ -16,10 +16,17 @@ import {
1616
1717import { cn } from "~/lib/utils";
1818import { buttonVariants } from "~/components/ui/button";
19import { useComposerDraftStore } from "~/composerDraftStore";
1920import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
20import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
21import { resolveThreadRouteTarget } from "~/threadRoutes";
22import {
23 buildVisibleToastLayout,
24 shouldHideCollapsedToastContent,
25 shouldRenderThreadScopedToast,
26} from "./toast.logic";
2127
2228export type ThreadToastData = {
29 threadRef?: ScopedThreadRef | null;
2330 threadId?: ThreadId | null;
2431 tooltipStyle?: boolean;
2532 dismissAfterVisibleMs?: number;
......@@ -70,21 +77,27 @@ interface ToastProviderProps extends Toast.Provider.Props {
7077 position?: ToastPosition;
7178}
7279
73function shouldRenderForActiveThread(
74 data: ThreadToastData | undefined,
75 activeThreadId: ThreadId | null,
76): boolean {
77 const toastThreadId = data?.threadId;
78 if (!toastThreadId) return true;
79 return toastThreadId === activeThreadId;
80}
81
82function useActiveThreadIdFromRoute(): ThreadId | null {
83 return useParams({
80function useActiveThreadRefFromRoute(): ScopedThreadRef | null {
81 const routeTarget = useParams({
8482 strict: false,
85 select: (params) =>
86 typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null,
83 select: (params) => resolveThreadRouteTarget(params),
8784 });
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]);
88101}
89102
90103function ThreadToastVisibleAutoDismiss({
......@@ -176,10 +189,10 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv
176189
177190function Toasts({ position = "top-right" }: { position: ToastPosition }) {
178191 const { toasts } = Toast.useToastManager<ThreadToastData>();
179 const activeThreadId = useActiveThreadIdFromRoute();
192 const activeThreadRef = useActiveThreadRefFromRoute();
180193 const isTop = position.startsWith("top");
181194 const visibleToasts = toasts.filter((toast) =>
182 shouldRenderForActiveThread(toast.data, activeThreadId),
195 shouldRenderThreadScopedToast(toast.data, activeThreadRef),
183196 );
184197 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
185198
......@@ -350,13 +363,13 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
350363
351364function AnchoredToasts() {
352365 const { toasts } = Toast.useToastManager<ThreadToastData>();
353 const activeThreadId = useActiveThreadIdFromRoute();
366 const activeThreadRef = useActiveThreadRefFromRoute();
354367
355368 return (
356369 <Toast.Portal data-slot="toast-portal-anchored">
357370 <Toast.Viewport className="outline-none" data-slot="toast-viewport-anchored">
358371 {toasts
359 .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId))
372 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))
360373 .map((toast) => {
361374 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
362375 const tooltipStyle = toast.data?.tooltipStyle ?? false;
apps/web/src/store.test.ts+51-2
......@@ -1,3 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
12import {
23 CheckpointRef,
34 DEFAULT_MODEL_BY_PROVIDER,
......@@ -17,6 +18,7 @@ import {
1718 applyOrchestrationEvents,
1819 selectEnvironmentState,
1920 selectProjectsAcrossEnvironments,
21 setThreadBranch,
2022 selectThreadsAcrossEnvironments,
2123 syncServerReadModel,
2224 type AppState,
......@@ -25,6 +27,7 @@ import {
2527import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
2628
2729const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
30const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote");
2831
2932function withActiveEnvironmentState(
3033 environmentState: EnvironmentState,
......@@ -86,7 +89,7 @@ function makeState(thread: Thread): AppState {
8689 const projectId = ProjectId.makeUnsafe("project-1");
8790 const project = {
8891 id: projectId,
89 environmentId: localEnvironmentId,
92 environmentId: thread.environmentId,
9093 name: "Project",
9194 cwd: "/tmp/project",
9295 defaultModelSelection: {
......@@ -171,7 +174,9 @@ function makeState(thread: Thread): AppState {
171174 sidebarThreadSummaryById: {},
172175 bootstrapComplete: true,
173176 };
174 return withActiveEnvironmentState(environmentState);
177 return withActiveEnvironmentState(environmentState, {
178 activeEnvironmentId: thread.environmentId,
179 });
175180}
176181
177182function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {
......@@ -201,6 +206,10 @@ function localEnvironmentStateOf(state: AppState): EnvironmentState {
201206 return selectEnvironmentState(state, localEnvironmentId);
202207}
203208
209function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState {
210 return selectEnvironmentState(state, environmentId);
211}
212
204213function projectsOf(state: AppState) {
205214 return selectProjectsAcrossEnvironments(state);
206215}
......@@ -305,6 +314,46 @@ function makeReadModelProject(
305314 };
306315}
307316
317describe("setThreadBranch", () => {
318 it("updates only the scoped thread environment", () => {
319 const sharedThreadId = ThreadId.makeUnsafe("thread-shared");
320 const localThread = makeThread({
321 id: sharedThreadId,
322 environmentId: localEnvironmentId,
323 branch: "local-branch",
324 });
325 const remoteThread = makeThread({
326 id: sharedThreadId,
327 environmentId: remoteEnvironmentId,
328 branch: "remote-branch",
329 });
330 const state: AppState = {
331 activeEnvironmentId: localEnvironmentId,
332 environmentStateById: {
333 [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId),
334 [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId),
335 },
336 };
337
338 const next = setThreadBranch(
339 state,
340 scopeThreadRef(remoteEnvironmentId, sharedThreadId),
341 "remote-next",
342 "/tmp/remote-worktree",
343 );
344
345 expect(
346 environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch,
347 ).toBe("local-branch");
348 expect(
349 environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch,
350 ).toBe("remote-next");
351 expect(
352 environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath,
353 ).toBe("/tmp/remote-worktree");
354 });
355});
356
308357describe("store read model sync", () => {
309358 it("marks bootstrap complete after snapshot sync", () => {
310359 const initialState = withActiveEnvironmentState(
apps/web/src/store.ts+11-11
......@@ -1737,17 +1737,13 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme
17371737
17381738export function setThreadBranch(
17391739 state: AppState,
1740 threadId: ThreadId,
1740 threadRef: ScopedThreadRef,
17411741 branch: string | null,
17421742 worktreePath: string | null,
17431743): AppState {
1744 if (state.activeEnvironmentId === null) {
1745 return state;
1746 }
1747
17481744 const nextEnvironmentState = updateThreadState(
1749 getStoredEnvironmentState(state, state.activeEnvironmentId),
1750 threadId,
1745 getStoredEnvironmentState(state, threadRef.environmentId),
1746 threadRef.threadId,
17511747 (thread) => {
17521748 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
17531749 const cwdChanged = thread.worktreePath !== worktreePath;
......@@ -1759,7 +1755,7 @@ export function setThreadBranch(
17591755 };
17601756 },
17611757 );
1762 return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState);
1758 return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState);
17631759}
17641760
17651761interface AppStore extends AppState {
......@@ -1771,7 +1767,11 @@ interface AppStore extends AppState {
17711767 environmentId: EnvironmentId,
17721768 ) => void;
17731769 setError: (threadId: ThreadId, error: string | null) => void;
1774 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
1770 setThreadBranch: (
1771 threadRef: ScopedThreadRef,
1772 branch: string | null,
1773 worktreePath: string | null,
1774 ) => void;
17751775}
17761776
17771777export const useStore = create<AppStore>((set) => ({
......@@ -1785,6 +1785,6 @@ export const useStore = create<AppStore>((set) => ({
17851785 applyOrchestrationEvents: (events, environmentId) =>
17861786 set((state) => applyOrchestrationEvents(state, events, environmentId)),
17871787 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
1788 setThreadBranch: (threadId, branch, worktreePath) =>
1789 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
1788 setThreadBranch: (threadRef, branch, worktreePath) =>
1789 set((state) => setThreadBranch(state, threadRef, branch, worktreePath)),
17901790}));