diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 11c458bbe9..32c80f6542 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -144,7 +144,7 @@ export function BranchToolbarBranchSelector({ }); } if (hasServerThread) { - setThreadBranchAction(activeThreadId, branch, worktreePath); + setThreadBranchAction(threadRef, branch, worktreePath); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 29a2f178ac..544bbaddec 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -4,9 +4,9 @@ import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -const THREAD_A = ThreadId.makeUnsafe("thread-a"); -const THREAD_B = ThreadId.makeUnsafe("thread-b"); -const ENVIRONMENT_ID = "environment-local" as never; +const SHARED_THREAD_ID = ThreadId.makeUnsafe("thread-shared"); +const ENVIRONMENT_A = "environment-local" as never; +const ENVIRONMENT_B = "environment-remote" as never; const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; @@ -24,9 +24,12 @@ function createDeferredPromise() { const { activeRunStackedActionDeferredRef, + activeDraftThreadRef, + hasServerThreadRef, invalidateGitQueriesSpy, refreshGitStatusSpy, runStackedActionMutateAsyncSpy, + setDraftThreadContextSpy, setThreadBranchSpy, toastAddSpy, toastCloseSpy, @@ -34,9 +37,12 @@ const { toastUpdateSpy, } = vi.hoisted(() => ({ activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, + activeDraftThreadRef: { current: null as unknown }, + hasServerThreadRef: { current: true }, invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), + setDraftThreadContextSpy: vi.fn(), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), toastCloseSpy: vi.fn(), @@ -123,6 +129,36 @@ vi.mock("~/localApi", () => ({ readLocalApi: vi.fn(() => null), })); +vi.mock("~/composerDraftStore", async () => { + const draftStoreState = { + getDraftThreadByRef: () => activeDraftThreadRef.current, + getDraftSession: () => activeDraftThreadRef.current, + getDraftThread: () => activeDraftThreadRef.current, + getDraftSessionByLogicalProjectKey: () => null, + setDraftThreadContext: setDraftThreadContextSpy, + setLogicalProjectDraftThreadId: vi.fn(), + setProjectDraftThreadId: vi.fn(), + hasDraftThreadsInEnvironment: () => false, + clearDraftThread: vi.fn(), + }; + + return { + DraftId: { + makeUnsafe: (value: string) => value, + }, + useComposerDraftStore: Object.assign( + (selector: (state: unknown) => unknown) => selector(draftStoreState), + { getState: () => draftStoreState }, + ), + markPromotedDraftThread: vi.fn(), + markPromotedDraftThreadByRef: vi.fn(), + markPromotedDraftThreads: vi.fn(), + markPromotedDraftThreadsByRef: vi.fn(), + finalizePromotedDraftThreadByRef: vi.fn(), + finalizePromotedDraftThreadsByRef: vi.fn(), + }; +}); + vi.mock("~/store", () => ({ selectEnvironmentState: ( state: { environmentStateById: Record }, @@ -154,11 +190,37 @@ vi.mock("~/store", () => ({ selector({ setThreadBranch: setThreadBranchSpy, environmentStateById: { - [ENVIRONMENT_ID]: { - threadShellById: { - [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, - [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, - }, + [ENVIRONMENT_A]: { + threadShellById: hasServerThreadRef.current + ? { + [SHARED_THREAD_ID]: { + id: SHARED_THREAD_ID, + branch: BRANCH_NAME, + worktreePath: null, + }, + } + : {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + }, + [ENVIRONMENT_B]: { + threadShellById: hasServerThreadRef.current + ? { + [SHARED_THREAD_ID]: { + id: SHARED_THREAD_ID, + branch: BRANCH_NAME, + worktreePath: null, + }, + } + : {}, threadSessionById: {}, threadTurnStateById: {}, messageIdsByThreadId: {}, @@ -187,17 +249,19 @@ function findButtonByText(text: string): HTMLButtonElement | null { } function Harness() { - const [activeThreadId, setActiveThreadId] = useState(THREAD_A); + const [activeThreadRef, setActiveThreadRef] = useState( + scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), + ); return ( <> - - + ); } @@ -207,10 +271,12 @@ describe("GitActionsControl thread-scoped progress toast", () => { vi.useRealTimers(); vi.clearAllMocks(); activeRunStackedActionDeferredRef.current = createDeferredPromise(); + activeDraftThreadRef.current = null; + hasServerThreadRef.current = true; document.body.innerHTML = ""; }); - it("keeps an in-flight git action toast pinned to the thread that started it", async () => { + it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { vi.useFakeTimers(); const host = document.createElement("div"); @@ -227,7 +293,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), @@ -238,24 +304,27 @@ describe("GitActionsControl thread-scoped progress toast", () => { expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), ); - const switchThreadButton = findButtonByText("Switch thread"); - expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy(); - if (!(switchThreadButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Switch thread"'); + const switchEnvironmentButton = findButtonByText("Switch environment"); + expect( + switchEnvironmentButton, + 'Unable to find button containing "Switch environment"', + ).toBeTruthy(); + if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { + throw new Error('Unable to find button containing "Switch environment"'); } - switchThreadButton.click(); + switchEnvironmentButton.click(); await vi.advanceTimersByTimeAsync(1_000); expect(toastUpdateSpy).toHaveBeenLastCalledWith( "toast-1", expect.objectContaining({ - data: { threadId: THREAD_A }, + data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, title: "Pushing...", type: "loading", }), @@ -284,7 +353,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { const screen = await render( , { container: host, @@ -304,7 +373,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { await vi.advanceTimersByTimeAsync(1); expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); expect(refreshGitStatusSpy).toHaveBeenCalledWith({ - environmentId: ENVIRONMENT_ID, + environmentId: ENVIRONMENT_A, cwd: GIT_CWD, }); } finally { @@ -316,4 +385,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { host.remove(); } }); + + it("syncs the live branch into the active draft thread when no server thread exists", async () => { + hasServerThreadRef.current = false; + activeDraftThreadRef.current = { + threadId: SHARED_THREAD_ID, + environmentId: ENVIRONMENT_A, + branch: null, + worktreePath: null, + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setDraftThreadContextSpy).toHaveBeenCalledWith( + scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), + { + branch: BRANCH_NAME, + worktreePath: null, + }, + ); + expect(setThreadBranchSpy).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 06bd21cb20..d8ff73da78 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -49,6 +49,7 @@ import { import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; +import { useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; @@ -209,17 +210,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { - const activeThreadId = activeThreadRef?.threadId ?? null; const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( - () => (activeThreadId ? { threadId: activeThreadId } : undefined), - [activeThreadId], + () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), + [activeThreadRef], ); const activeServerThreadSelector = useMemo( () => createThreadSelectorByRef(activeThreadRef), [activeThreadRef], ); const activeServerThread = useStore(activeServerThreadSelector); + const activeDraftThread = useComposerDraftStore((store) => + activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, + ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -247,27 +251,49 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction const persistThreadBranchSync = useCallback( (branch: string | null) => { - if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) { + if (!activeThreadRef) { + return; + } + + if (activeServerThread) { + if (activeServerThread.branch === branch) { + return; + } + + const worktreePath = activeServerThread.worktreePath; + const api = readEnvironmentApi(activeThreadRef.environmentId); + if (api) { + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }) + .catch(() => undefined); + } + + setThreadBranch(activeThreadRef, branch, worktreePath); return; } - const worktreePath = activeServerThread.worktreePath; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }) - .catch(() => undefined); + if (!activeDraftThread || activeDraftThread.branch === branch) { + return; } - setThreadBranch(activeThreadId, branch, worktreePath); + setDraftThreadContext(activeThreadRef, { + branch, + worktreePath: activeDraftThread.worktreePath, + }); }, - [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch], + [ + activeDraftThread, + activeServerThread, + activeThreadRef, + setDraftThreadContext, + setThreadBranch, + ], ); const syncThreadBranchAfterGitAction = useCallback( diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index 4e4595f73f..e634d7d946 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -1,5 +1,10 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; import { assert, describe, it } from "vitest"; -import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; +import { + buildVisibleToastLayout, + shouldHideCollapsedToastContent, + shouldRenderThreadScopedToast, +} from "./toast.logic"; describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { @@ -61,3 +66,49 @@ describe("buildVisibleToastLayout", () => { ); }); }); + +describe("shouldRenderThreadScopedToast", () => { + const activeThreadRef = { + environmentId: "environment-a", + threadId: "thread-1", + } as ScopedThreadRef; + + it("renders a toast scoped to the active thread ref", () => { + assert.equal( + shouldRenderThreadScopedToast( + { + threadRef: activeThreadRef, + }, + activeThreadRef, + ), + true, + ); + }); + + it("hides a scoped toast when the environment differs", () => { + assert.equal( + shouldRenderThreadScopedToast( + { + threadRef: { + environmentId: "environment-b", + threadId: "thread-1", + } as ScopedThreadRef, + }, + activeThreadRef, + ), + false, + ); + }); + + it("keeps legacy thread-id scoped toasts working", () => { + assert.equal( + shouldRenderThreadScopedToast( + { + threadId: "thread-1" as never, + }, + activeThreadRef, + ), + true, + ); + }); +}); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index eaa4e0db4f..09905a7a67 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -1,3 +1,5 @@ +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; + export function shouldHideCollapsedToastContent( visibleToastIndex: number, visibleToastCount: number, @@ -44,3 +46,28 @@ export function buildVisibleToastLayout( function normalizeToastHeight(height: number | null | undefined): number { return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; } + +export function shouldRenderThreadScopedToast( + data: + | { + threadRef?: ScopedThreadRef | null; + threadId?: ThreadId | null; + } + | undefined, + activeThreadRef: ScopedThreadRef | null, +): boolean { + if (data?.threadRef) { + return ( + activeThreadRef !== null && + data.threadRef.environmentId === activeThreadRef.environmentId && + data.threadRef.threadId === activeThreadRef.threadId + ); + } + + const toastThreadId = data?.threadId; + if (!toastThreadId) { + return true; + } + + return activeThreadRef?.threadId === toastThreadId; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index dbe546cc82..c0f75575ef 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,9 +1,9 @@ "use client"; import { Toast } from "@base-ui/react/toast"; -import { useEffect, type CSSProperties } from "react"; +import { useEffect, useMemo, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; -import { ThreadId } from "@t3tools/contracts"; +import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { CheckIcon, CircleAlertIcon, @@ -16,10 +16,17 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; +import { useComposerDraftStore } from "~/composerDraftStore"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; +import { resolveThreadRouteTarget } from "~/threadRoutes"; +import { + buildVisibleToastLayout, + shouldHideCollapsedToastContent, + shouldRenderThreadScopedToast, +} from "./toast.logic"; export type ThreadToastData = { + threadRef?: ScopedThreadRef | null; threadId?: ThreadId | null; tooltipStyle?: boolean; dismissAfterVisibleMs?: number; @@ -70,21 +77,27 @@ interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; } -function shouldRenderForActiveThread( - data: ThreadToastData | undefined, - activeThreadId: ThreadId | null, -): boolean { - const toastThreadId = data?.threadId; - if (!toastThreadId) return true; - return toastThreadId === activeThreadId; -} - -function useActiveThreadIdFromRoute(): ThreadId | null { - return useParams({ +function useActiveThreadRefFromRoute(): ScopedThreadRef | null { + const routeTarget = useParams({ strict: false, - select: (params) => - typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, + select: (params) => resolveThreadRouteTarget(params), }); + const activeDraftSession = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + + return useMemo(() => { + if (routeTarget?.kind === "server") { + return routeTarget.threadRef; + } + if (routeTarget?.kind === "draft" && activeDraftSession) { + return { + environmentId: activeDraftSession.environmentId, + threadId: activeDraftSession.threadId, + }; + } + return null; + }, [activeDraftSession, routeTarget]); } function ThreadToastVisibleAutoDismiss({ @@ -176,10 +189,10 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv function Toasts({ position = "top-right" }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); - const activeThreadId = useActiveThreadIdFromRoute(); + const activeThreadRef = useActiveThreadRefFromRoute(); const isTop = position.startsWith("top"); const visibleToasts = toasts.filter((toast) => - shouldRenderForActiveThread(toast.data, activeThreadId), + shouldRenderThreadScopedToast(toast.data, activeThreadRef), ); const visibleToastLayout = buildVisibleToastLayout(visibleToasts); @@ -350,13 +363,13 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { function AnchoredToasts() { const { toasts } = Toast.useToastManager(); - const activeThreadId = useActiveThreadIdFromRoute(); + const activeThreadRef = useActiveThreadRefFromRoute(); return ( {toasts - .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) + .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) .map((toast) => { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const tooltipStyle = toast.data?.tooltipStyle ?? false; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index ccde42d82b..7606fad305 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,3 +1,4 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; import { CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, @@ -17,6 +18,7 @@ import { applyOrchestrationEvents, selectEnvironmentState, selectProjectsAcrossEnvironments, + setThreadBranch, selectThreadsAcrossEnvironments, syncServerReadModel, type AppState, @@ -25,6 +27,7 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); +const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote"); function withActiveEnvironmentState( environmentState: EnvironmentState, @@ -86,7 +89,7 @@ function makeState(thread: Thread): AppState { const projectId = ProjectId.makeUnsafe("project-1"); const project = { id: projectId, - environmentId: localEnvironmentId, + environmentId: thread.environmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -171,7 +174,9 @@ function makeState(thread: Thread): AppState { sidebarThreadSummaryById: {}, bootstrapComplete: true, }; - return withActiveEnvironmentState(environmentState); + return withActiveEnvironmentState(environmentState, { + activeEnvironmentId: thread.environmentId, + }); } function makeEmptyState(overrides: Partial = {}): AppState { @@ -201,6 +206,10 @@ function localEnvironmentStateOf(state: AppState): EnvironmentState { return selectEnvironmentState(state, localEnvironmentId); } +function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState { + return selectEnvironmentState(state, environmentId); +} + function projectsOf(state: AppState) { return selectProjectsAcrossEnvironments(state); } @@ -305,6 +314,46 @@ function makeReadModelProject( }; } +describe("setThreadBranch", () => { + it("updates only the scoped thread environment", () => { + const sharedThreadId = ThreadId.makeUnsafe("thread-shared"); + const localThread = makeThread({ + id: sharedThreadId, + environmentId: localEnvironmentId, + branch: "local-branch", + }); + const remoteThread = makeThread({ + id: sharedThreadId, + environmentId: remoteEnvironmentId, + branch: "remote-branch", + }); + const state: AppState = { + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), + [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), + }, + }; + + const next = setThreadBranch( + state, + scopeThreadRef(remoteEnvironmentId, sharedThreadId), + "remote-next", + "/tmp/remote-worktree", + ); + + expect( + environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch, + ).toBe("local-branch"); + expect( + environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch, + ).toBe("remote-next"); + expect( + environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, + ).toBe("/tmp/remote-worktree"); + }); +}); + describe("store read model sync", () => { it("marks bootstrap complete after snapshot sync", () => { const initialState = withActiveEnvironmentState( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 1fbb722a05..10f4eac387 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1737,17 +1737,13 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme export function setThreadBranch( state: AppState, - threadId: ThreadId, + threadRef: ScopedThreadRef, branch: string | null, worktreePath: string | null, ): AppState { - if (state.activeEnvironmentId === null) { - return state; - } - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, state.activeEnvironmentId), - threadId, + getStoredEnvironmentState(state, threadRef.environmentId), + threadRef.threadId, (thread) => { if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; const cwdChanged = thread.worktreePath !== worktreePath; @@ -1759,7 +1755,7 @@ export function setThreadBranch( }; }, ); - return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); + return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); } interface AppStore extends AppState { @@ -1771,7 +1767,11 @@ interface AppStore extends AppState { environmentId: EnvironmentId, ) => void; setError: (threadId: ThreadId, error: string | null) => void; - setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; + setThreadBranch: ( + threadRef: ScopedThreadRef, + branch: string | null, + worktreePath: string | null, + ) => void; } export const useStore = create((set) => ({ @@ -1785,6 +1785,6 @@ export const useStore = create((set) => ({ applyOrchestrationEvents: (events, environmentId) => set((state) => applyOrchestrationEvents(state, events, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), - setThreadBranch: (threadId, branch, worktreePath) => - set((state) => setThreadBranch(state, threadId, branch, worktreePath)), + setThreadBranch: (threadRef, branch, worktreePath) => + set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), }));