diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index fb923355..9b1c74df 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -345,3 +346,56 @@ describe("GitWorkflowService", () => { }).pipe(Effect.provide(testLayer)); }); }); + +describe("resolveRepositoryRootRelation", () => { + it("reads normalization-only differences as same", () => { + assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt"), "same"); + assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt/"), "same"); + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation( + "C:\\Users\\Will\\repo", + "c:/users/will/repo", + ), + "same", + ); + }); + + it("reads a genuine subdirectory as ancestor", () => { + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation("/repo", "/repo/apps/web"), + "ancestor", + ); + }); + + it("resolves symlinks before comparing", () => { + const base = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "tl-root-relation-")); + try { + const repo = NodePath.join(base, "repo"); + const sub = NodePath.join(repo, "packages", "app"); + NodeFS.mkdirSync(sub, { recursive: true }); + const rootLink = NodePath.join(base, "root-link"); + const subLink = NodePath.join(base, "sub-link"); + NodeFS.symlinkSync(repo, rootLink); + NodeFS.symlinkSync(sub, subLink); + + // A symlinked spelling of the repository root is the same checkout — + // this was the false "ancestor" that locked healthy panels. + assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, rootLink), "same"); + // A symlink INTO the repository is a genuine parent situation and must + // keep the safety gate up. + assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, subLink), "ancestor"); + } finally { + NodeFS.rmSync(base, { recursive: true, force: true }); + } + }); + + it("keeps the gate up when paths still diverge after resolution", () => { + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation( + "/definitely-not-here-a/x", + "/definitely-not-here-b/x", + ), + "ancestor", + ); + }); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 3e0255be..0f2632cb 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,4 +1,5 @@ -import * as nodePath from "node:path"; +import * as NodeFS from "node:fs"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -178,11 +179,42 @@ function withRepositoryContext( return { ...status, repositoryRoot, - repositoryRootRelation: - nodePath.resolve(repositoryRoot) === nodePath.resolve(cwd) ? "same" : "ancestor", + repositoryRootRelation: resolveRepositoryRootRelation(repositoryRoot, cwd), }; } +/** Symlink-resolved form for comparison; the raw value when resolution fails. */ +function toComparableRealPath(value: string): string { + try { + return NodeFS.realpathSync.native(value); + } catch { + return value; + } +} + +/** + * "ancestor" gates the whole source-control panel behind a confirmation. + * Plain string comparison used to raise that gate for healthy checkouts + * whenever git's resolved root differed from the configured cwd only by + * symlinks, casing, or separators (git realpaths its answers; the configured + * cwd may be the symlinked spelling). Compare after resolving symlinks and + * normalizing separators and case instead. Anything still unequal keeps the + * gate up: a false gate is a visible, dismissible banner, while a false + * "same" would silently expose repository-wide actions — e.g. a cwd + * symlinked into a subdirectory of a larger repository. + */ +export function resolveRepositoryRootRelation( + repositoryRoot: string, + cwd: string, +): "same" | "ancestor" { + if (areFilesystemPathsEqual(repositoryRoot, cwd)) { + return "same"; + } + return areFilesystemPathsEqual(toComparableRealPath(repositoryRoot), toComparableRealPath(cwd)) + ? "same" + : "ancestor"; +} + const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => new GitManagerError({ operation, diff --git a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts new file mode 100644 index 00000000..11ebfe4c --- /dev/null +++ b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts @@ -0,0 +1,215 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationSession, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-checkout-switch"); +const projectId = ProjectId.make("project-checkout-switch"); +const workspaceRoot = "/repos/project"; +const worktreeA = "/repos/project/.worktrees/feature-a"; +const worktreeB = "/repos/project/.worktrees/feature-b"; + +function makeSession(input: { + status: OrchestrationSession["status"]; + checkoutCwd?: string | null; +}): OrchestrationSession { + return { + threadId, + status: input.status, + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + ...(input.checkoutCwd !== undefined ? { checkoutCwd: input.checkoutCwd } : {}), + updatedAt: now, + }; +} + +function makeReadModel(input: { + session: OrchestrationSession | null; + effectiveCwd: string | null; + worktreePath?: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: now, + projects: [ + { + id: projectId, + kind: "workspace", + title: "Checkout Switch Project", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: threadId, + projectId, + title: "Checkout Switch Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "feature-a", + worktreePath: input.worktreePath !== undefined ? input.worktreePath : worktreeA, + effectiveCwd: input.effectiveCwd, + ...(input.effectiveCwd !== null ? { effectiveCwdSource: "session" as const } : {}), + goal: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + pinnedAt: null, + doneOverride: null, + lastSeenAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + diffStatBaselineTurnCount: 0, + session: input.session, + }, + ], + }; +} + +function metaUpdateCommand(input: { + worktreePath?: string | null; + title?: string; +}): Extract { + return { + type: "thread.meta.update", + commandId: CommandId.make("cmd-meta-update"), + threadId, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.worktreePath !== undefined + ? { branch: "main", worktreePath: input.worktreePath } + : {}), + }; +} + +function sessionSetCommand( + session: OrchestrationSession, +): Extract { + return { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId, + session, + createdAt: "2026-01-01T00:00:10.000Z", + }; +} + +async function decide(command: OrchestrationCommand, readModel: OrchestrationReadModel) { + const decided = await Effect.runPromise(decideOrchestrationCommand({ command, readModel })); + return Array.isArray(decided) ? decided : [decided]; +} + +describe("decider checkout switch effectiveCwd", () => { + it("clears the stale effectiveCwd when a stopped thread's worktree changes", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeB }), + makeReadModel({ + session: makeSession({ status: "stopped", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + }), + ); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { threadId, effectiveCwd: null }, + }); + expect(events[1]?.causationEventId).toBe(events[0]?.eventId); + }); + + it("keeps the effectiveCwd while a live session still runs in the old checkout", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeB }), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + + it("keeps effectiveCwd when a branch-only update carries the unchanged worktree path", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeA }), + makeReadModel({ + session: makeSession({ status: "stopped", checkoutCwd: worktreeA }), + effectiveCwd: `${worktreeA}/packages/deep`, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + + it("leaves effectiveCwd alone for meta updates that do not move the checkout", async () => { + const events = await decide( + metaUpdateCommand({ title: "Renamed" }), + makeReadModel({ session: null, effectiveCwd: worktreeA }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + + it("applies a queued switch when the session stops away from the thread checkout", async () => { + const events = await decide( + sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + worktreePath: worktreeB, + }), + ); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: "thread.session-set" }); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { threadId, effectiveCwd: null }, + }); + }); + + it("keeps a cwd-follow effectiveCwd when the session stops in its own checkout", async () => { + const events = await decide( + sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: `${worktreeA}/packages/deep`, + worktreePath: worktreeA, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.session-set" }); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 040de186..66a30cfb 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -581,7 +581,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? undefined : normalizeWorktreePath(command.worktreePath, project.workspaceRoot); const occurredAt = yield* nowIso; - return { + const metaUpdatedEvent = { ...withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -599,7 +599,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(worktreePath !== undefined ? { worktreePath } : {}), updatedAt: occurredAt, }, - }; + } as const; + + // A checkout move with no live session applies now, so the stale + // session-scoped effectiveCwd must stop shadowing the new checkout. + // Emitted as a real event rather than special-cased in the folds: the + // in-memory projector, the SQLite pipeline, and the web store all + // already know what thread.effective-cwd-set means. Only an actual + // move clears — branch-only updates carry the unchanged worktree path + // and must not wipe a valid cwd-follow value. + const checkoutChanged = + worktreePath !== undefined && + (worktreePath === null || thread.worktreePath === null + ? worktreePath !== thread.worktreePath + : !areFilesystemPathsEqual(worktreePath, thread.worktreePath)); + const sessionInactive = thread.session == null || thread.session.status === "stopped"; + if (!checkoutChanged || !sessionInactive || thread.effectiveCwd == null) { + return metaUpdatedEvent; + } + return [ + metaUpdatedEvent, + { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + causationEventId: metaUpdatedEvent.eventId, + type: "thread.effective-cwd-set", + payload: { + threadId: command.threadId, + effectiveCwd: null, + updatedAt: occurredAt, + }, + }, + ]; } case "thread.runtime-mode.set": { @@ -1124,12 +1159,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent = { ...withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1142,7 +1177,41 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, session: command.session, }, - }; + } as const; + + // A session stopping with a queued checkout switch applies the switch: + // the effectiveCwd the dead session left behind must not keep pointing + // panels at the checkout the user already moved away from. A session + // stopping in its own configured checkout keeps its effectiveCwd, so a + // cwd-follow into a subfolder still reads correctly after a stop. + const configuredCheckout = thread.worktreePath ?? null; + const sessionCheckout = command.session.checkoutCwd ?? null; + const checkoutDiffers = + configuredCheckout === null || sessionCheckout === null + ? configuredCheckout !== sessionCheckout + : !areFilesystemPathsEqual(configuredCheckout, sessionCheckout); + if (command.session.status !== "stopped" || !checkoutDiffers || thread.effectiveCwd == null) { + return sessionSetEvent; + } + return [ + sessionSetEvent, + { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: {}, + }), + causationEventId: sessionSetEvent.eventId, + type: "thread.effective-cwd-set", + payload: { + threadId: command.threadId, + effectiveCwd: null, + updatedAt: command.createdAt, + }, + }, + ]; } case "thread.realtime.state.set": { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5a7162e0..d4f29f33 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1134,6 +1134,10 @@ export default function GitActionsControl({ ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); + const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); + // Identifies the newest optimistic branch dispatch so a stale rejection + // cannot roll back state a later dispatch already replaced. + const branchDispatchIdRef = useRef(0); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1170,17 +1174,48 @@ export default function GitActionsControl({ 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); + if (!api) { + // No connection means the change cannot be saved at all; applying + // the optimistic update anyway would leave the label lying. + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save the branch change", + description: "Not connected to the environment. Try again once it reconnects.", + }), + ); + return; } + const snapshot = { + branch: activeServerThread.branch, + worktreePath, + session: activeServerThread.session ?? null, + }; + branchDispatchIdRef.current += 1; + const dispatchId = branchDispatchIdRef.current; + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }) + .catch(() => { + // Roll the optimistic update back; a branch label that silently + // disagrees with the server is a lying UI. A stale rejection + // never overwrites a newer dispatch's state. + if (branchDispatchIdRef.current === dispatchId) { + restoreThreadCheckout(activeThreadRef, snapshot); + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save the branch change", + description: "The update didn't reach the server. Try again.", + }), + ); + }); setThreadBranch(activeThreadRef, branch, worktreePath); return; @@ -1200,6 +1235,7 @@ export default function GitActionsControl({ activeServerThread, activeThreadRef, draftId, + restoreThreadCheckout, setDraftThreadContext, setThreadBranch, ], diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 83708bee..4c94839c 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -1817,6 +1817,10 @@ function SourceControlBranchMenu({ }) { const queryClient = useQueryClient(); const setThreadBranch = useStore((store) => store.setThreadBranch); + const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); + // Identifies the newest optimistic checkout dispatch so a stale rejection + // cannot roll back state a later dispatch already replaced. + const checkoutDispatchIdRef = useRef(0); const activeThreadSession = useStore(useMemo(() => createThreadSelectorByRef(activeThreadRef), [activeThreadRef])) ?.session ?? null; @@ -1906,22 +1910,58 @@ function SourceControlBranchMenu({ return; } const api = readEnvironmentApi(target.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => undefined); + if (!api) { + // No connection means the switch cannot happen at all; applying the + // optimistic update anyway would leave the panel lying indefinitely. + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't move the thread", + description: "Not connected to the environment. Try again once it reconnects.", + }), + ); + return; } + const snapshot = { + branch: currentBranch, + worktreePath: target.worktreePath, + session: activeThreadSession, + }; + checkoutDispatchIdRef.current += 1; + const dispatchId = checkoutDispatchIdRef.current; + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }) + .catch(() => { + // Roll the optimistic update back to what the panel showed before — + // including the session the optimistic switch cleared. A switch + // that silently stays put is this panel's worst failure mode, and + // one that lies about having happened is the second. A stale + // rejection never overwrites a newer dispatch's state. + if (checkoutDispatchIdRef.current === dispatchId) { + restoreThreadCheckout(activeThreadRef, snapshot); + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't move the thread", + description: "The checkout switch didn't reach the server. Try again.", + }), + ); + }); setThreadBranch(activeThreadRef, branch, worktreePath); }, [ activeThreadRef, + activeThreadSession, + currentBranch, onActiveBranchChange, + restoreThreadCheckout, setThreadBranch, target.environmentId, target.worktreePath, @@ -1959,6 +1999,13 @@ function SourceControlBranchMenu({ const switchCheckoutToWorktree = useCallback( (row: WorktreeCleanupRow) => { if (!row.refName) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Can't switch to this worktree", + description: "It has no branch checked out, so the thread can't follow it.", + }), + ); return; } setCleanupRows(null); @@ -1997,9 +2044,25 @@ function SourceControlBranchMenu({ [applyCheckoutSwitch, checkoutMutation, refreshPanel, target.projectCwd, target.worktreePath], ); + /** + * A refused action must say why. The branch menu shows the safety reason in + * place, but these handlers are also reachable from rows and dialogs that + * render no reason — a silent return there reads as a dead button. + */ + const notifyRepositorySafetyBlocked = useCallback((reason: string) => { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Action paused", + description: reason, + }), + ); + }, []); + const runSwitchRef = useCallback( (ref: VcsRef) => { if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const selectionTarget = resolveBranchSelectionTarget({ @@ -2019,6 +2082,7 @@ function SourceControlBranchMenu({ [ activeThreadSession, executeSwitchRef, + notifyRepositorySafetyBlocked, repositorySafetyReason, target.projectCwd, target.worktreePath, @@ -2027,6 +2091,7 @@ function SourceControlBranchMenu({ const runCreateBranch = useCallback(() => { if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const refName = createBranchName.trim(); @@ -2054,13 +2119,18 @@ function SourceControlBranchMenu({ }, [ createBranchMutation, createBranchName, + notifyRepositorySafetyBlocked, refreshPanel, repositorySafetyReason, syncActiveThreadBranch, ]); const runMergeRef = useCallback(() => { - if (!pendingMergeRef || repositorySafetyReason) { + if (!pendingMergeRef) { + return; + } + if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const refName = pendingMergeRef.name; @@ -2088,7 +2158,14 @@ function SourceControlBranchMenu({ }), }); void promise.then(refreshPanel, () => refreshPanel()); - }, [currentBranch, mergeMutation, pendingMergeRef, refreshPanel, repositorySafetyReason]); + }, [ + currentBranch, + mergeMutation, + notifyRepositorySafetyBlocked, + pendingMergeRef, + refreshPanel, + repositorySafetyReason, + ]); return ( <> @@ -4855,12 +4932,17 @@ export function SourceControlPanel({ ) : changedFiles.length === 0 ? ( // Nothing to show is a line, not a box. The left sidebar's empty - // states are flat text and this one is no different. + // states are flat text and this one is no different. While the + // parent-repository gate is up the list is empty because queries + // are paused, not because the tree is clean — saying "no changes" + // there sends people hunting for a different bug.
- No working tree changes + {isParentRepositoryConfirmationRequired + ? "Changes are hidden until you confirm the parent repository above." + : "No working tree changes"}
) : (
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index e60e06ee..20a2766c 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -6,6 +6,7 @@ import { EventId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, TurnId, @@ -23,12 +24,18 @@ import { selectThreadByRef, selectThreadExistsByRef, selectSidebarThreadsForProjectRef, + restoreThreadCheckout, setThreadBranch, selectThreadsAcrossEnvironments, type AppState, type EnvironmentState, } from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type Thread, + type ThreadSession, +} from "./types"; const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -457,6 +464,41 @@ describe("setThreadBranch", () => { environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, ).toBe("/tmp/remote-worktree"); }); + + it("restoreThreadCheckout brings back the session an optimistic switch cleared", () => { + const session: ThreadSession = { + provider: ProviderDriverKind.make("codex"), + status: "running", + orchestrationStatus: "running", + checkoutCwd: "/tmp/worktree-a", + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + }; + const thread = makeThread({ + branch: "feature-a", + worktreePath: "/tmp/worktree-a", + session, + }); + const state = makeState(thread); + const threadRef = scopeThreadRef(localEnvironmentId, thread.id); + + const afterOptimistic = setThreadBranch(state, threadRef, "main", null); + expect( + environmentStateOf(afterOptimistic, localEnvironmentId).threadSessionById[thread.id], + ).toBeNull(); + + const restored = restoreThreadCheckout(afterOptimistic, threadRef, { + branch: "feature-a", + worktreePath: "/tmp/worktree-a", + session, + }); + const shell = environmentStateOf(restored, localEnvironmentId).threadShellById[thread.id]; + expect(shell?.branch).toBe("feature-a"); + expect(shell?.worktreePath).toBe("/tmp/worktree-a"); + expect(environmentStateOf(restored, localEnvironmentId).threadSessionById[thread.id]).toEqual( + session, + ); + }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 537b8c99..ab27a8ef 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2370,6 +2370,34 @@ export function setThreadBranch( return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); } +/** + * Restores a checkout snapshot captured before an optimistic + * `setThreadBranch`, including the session that call clears when the cwd + * changes. Used to roll back a thread.meta.update dispatch that never + * reached the server; plain `setThreadBranch` cannot bring the session back. + */ +export function restoreThreadCheckout( + state: AppState, + threadRef: ScopedThreadRef, + snapshot: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly session: ThreadSession | null; + }, +): AppState { + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, threadRef.environmentId), + threadRef.threadId, + (thread) => ({ + ...thread, + branch: snapshot.branch, + worktreePath: snapshot.worktreePath, + session: snapshot.session, + }), + ); + return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); +} + interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; removeEnvironmentState: (environmentId: EnvironmentId) => void; @@ -2390,6 +2418,14 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; + restoreThreadCheckout: ( + threadRef: ScopedThreadRef, + snapshot: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly session: ThreadSession | null; + }, + ) => void; } export const useStore = create((set) => ({ @@ -2411,4 +2447,6 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), + restoreThreadCheckout: (threadRef, snapshot) => + set((state) => restoreThreadCheckout(state, threadRef, snapshot)), }));