Merge pull request #13598 from SivanCola/codex/workspace-flow

Complete workspace preparation and branch switching / 补齐工作目录准备与分支切换流程
This commit is contained in:
YHH authored and GitHub committed 2026-10-17 04:47:59 -07:00
commit 75a4e7e2fe
51 files changed
+2207 -335

No files matched your search

+6
View File
@@ -205,6 +205,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
+12
View File
@@ -84,3 +84,15 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
+26 -17
View File
@@ -1,5 +1,5 @@
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing:
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
// Workspace selection must stay reachable before and after a task, with no
// legacy hover card stacked over the open palette.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
@@ -9,22 +9,31 @@ const check = (name, ok, detail = "") => {
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
const chip = page.locator('.studio-branch-pop .studio-branch').first();
await chip.waitFor();
const card = page.locator(".studio-branch-card").first();
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
await chip.hover();
await page.waitForTimeout(400);
check("the hover card shows over a closed chip", await shown());
await chip.click();
await page.waitForTimeout(500);
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
check("the hover card is gone while the menu is open", !(await shown()));
for (const turns of [0, 2]) {
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.getByRole("button", { name: "配置工作目录", exact: true });
await chip.hover();
await chip.click();
const palette = page.getByRole("dialog", { name: "配置工作目录", exact: true });
await palette.waitFor();
check(`${turns} turns: the workspace palette opened`, await chip.getAttribute("aria-expanded") === "true");
const stacked = await page.locator(".studio-branch-card").evaluateAll((cards) => cards.some((card) => getComputedStyle(card).visibility === "visible" && Number(getComputedStyle(card).opacity) > 0.5));
check(`${turns} turns: no legacy hover card overlaps the palette`, !stacked);
await palette.getByRole("option", { name: "main", exact: true }).waitFor();
check(`${turns} turns: the current branch is selected`, await palette.getByRole("option", { name: "main", exact: true }).getAttribute("aria-selected") === "true");
const search = palette.getByRole("searchbox", { name: "搜索分支", exact: true });
await search.fill("studio");
check(`${turns} turns: search filters the branch choices`, await palette.getByRole("option").count() === 1 && await palette.getByRole("option", { name: "studio", exact: true }).isVisible());
await search.press("Escape");
await palette.waitFor({ state: "detached" });
check(`${turns} turns: closing returns focus to the workspace entry`, await chip.evaluate((element) => element === document.activeElement));
await page.close();
}
} finally {
await browser.close();
}
+3
View File
@@ -69,6 +69,9 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.prepare-details", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-edit", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
+78 -1
View File
@@ -1,10 +1,74 @@
// The turn starts here: drafting, attachments, run posture, and the two actions
// that remain available while an agent is live.
export const EN_COMPOSER: Record<string, string> = {
"工作目录准备": "Preparing workspace",
"正在提交创建请求": "Submitting creation request",
"正在检查起始分支": "Checking source branch",
"正在创建工作目录": "Creating workspace",
"正在检出已提交文件": "Checking out committed files",
"正在复制任务附件": "Copying task attachments",
"正在绑定工作目录": "Binding workspace",
"正在清理未完成目录": "Cleaning up incomplete workspace",
"正在中止并清理": "Stopping and cleaning up",
"工作目录已绑定": "Workspace bound",
"worktree 创建失败": "Could not create worktree",
"已取消创建": "Creation cancelled",
"工作目录已就绪": "Workspace ready",
"需要确认创建结果": "Confirm creation outcome",
"正在取消创建…": "Cancelling creation…",
"正在创建 worktree…": "Creating worktree…",
"任务尚未发送,输入和附件已为你保留。": "The task has not been sent. Your draft and attachments are retained.",
"任务尚未发送,可以继续编辑。": "The task has not been sent. You can continue editing.",
"目录已经创建成功,取消未能撤回目录切换。任务尚未发送。": "The workspace was already created and bound. Cancellation could not reverse it. The task has not been sent.",
"即将进入对话,自动开始任务。": "Opening the conversation and starting your task.",
"暂不能确认操作或清理结果,请先查看详情并核对。": "The operation or cleanup outcome is unconfirmed. Check the details before proceeding.",
"请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。": "Save your task and attachments, then reconcile the retained folder and task branch in a terminal. Once reconciled, close this conversation from its menu and start a new one in the source folder to retry.",
"正在中止操作并清理未完成的目录。": "Stopping the operation and cleaning up the incomplete workspace.",
"创建耗时较长,尚未收到完成确认。": "Creation is taking longer. Completion has not been confirmed.",
"准备完成后将自动开始任务。": "Your task will start automatically when the workspace is ready.",
"正在等待状态确认": "Waiting for confirmation",
"清理未完成": "Cleanup did not finish",
"创建未完成": "Creation did not finish",
"创建已中止,目录已清理": "Creation stopped and workspace cleaned up",
"已等待 {n} 秒": "Waiting {n}s",
"用户任务": "Your task",
"请查看附件": "See the attachments",
"附件({n})": "Attachments ({n})",
"待交接": "Pending handoff",
"已保留": "Retained",
"查看详情": "View details",
"收起详情": "Hide details",
"仍在等待创建完成。你可以继续等待、查看详情,或取消创建。": "Still waiting for creation. You can keep waiting, view details, or cancel.",
"操作编号": "Operation ID",
"当前状态": "Current state",
"再次核对": "Check again",
"重试创建": "Retry creation",
"返回编辑": "Back to editing",
"任务和附件正在交接…": "Handing over the task and attachments…",
"取消创建": "Cancel creation",
"正在取消…": "Cancelling…",
"准备工作目录": "Preparing workspace",
"创建失败": "Creation failed",
"创建已取消": "Creation cancelled",
"切换当前分支": "Switch current branch",
"创建请求不完整或与原操作不一致,请返回编辑后重试": "The creation request is incomplete or differs from the original. Return to editing and try again.",
"无法确认此前创建的结果,请先检查工作目录,避免重复创建": "The previous outcome is unconfirmed. Check the workspace before creating another.",
"已有工作目录正在准备或等待清理,请先确认该操作的结果": "A workspace is being prepared or awaiting cleanup. Reconcile that operation first.",
"工作目录创建失败,任务和附件已保留": "Workspace creation failed. The task and attachments are retained.",
"未完成目录尚未清理,暂不能重试创建,请查看详情": "The incomplete workspace could not be cleaned up. Check the details before retrying.",
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "New worktree · {branch}",
"独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"独立 worktree": "Isolated worktree",
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
"使用当前目录": "Use current folder",
"关闭工作目录配置": "Close workspace configuration",
"发送消息时开始创建。": " Creation starts when you send the message.",
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
"为此会话创建独立的代码目录": "Create a separate code folder for this session",
"起始分支": "Source branch",
"任务分支": "Task branch",
@@ -14,6 +78,19 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
"任务分支自动创建": "A task branch will be created automatically",
"搜索起始分支…": "Search source branches…",
"搜索本地分支…": "Search local branches…",
"分支读取失败": "Could not read branches",
"正在读取分支…": "Reading branches…",
"其他 worktree 占用": "Used by another worktree",
"起点": "Source",
"从已提交版本创建,任务和附件将在就绪后发送": "Create from the committed version; the task and attachments are sent when ready",
"选择后立即切换,同目录会话也会受影响": "Switches immediately; other sessions in this folder are affected",
"远程分支": "Remote branches",
"当前分支": "Current branch",
"没有匹配的分支": "No matching branches",
+7
View File
@@ -170,6 +170,13 @@ const SAID: Record<string, string> = {
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
"workspace.worktree_request_invalid": "创建请求不完整或与原操作不一致,请返回编辑后重试",
"workspace.worktree_operation_unknown": "无法确认此前创建的结果,请先检查工作目录,避免重复创建",
"workspace.worktree_busy": "已有工作目录正在准备或等待清理,请先确认该操作的结果",
"workspace.worktree_failed": "工作目录创建失败,任务和附件已保留",
"workspace.worktree_cleanup_failed": "未完成目录尚未清理,暂不能重试创建,请查看详情",
"workspace.worktree_cancelled": "创建已取消,任务尚未发送",
"workspace.worktree_timeout": "创建等待超时,任务和附件已保留,可查看详情后重试",
"settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
+6 -1
View File
@@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -238,6 +238,11 @@ export class MockPort extends MockWorkspace implements AgentPort {
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
}
private preparations = new MockPreparations();
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) { return this.preparations.prepare(id, selection, async () => { await this.isolateWorkspace(selection); return (await this.status()).workspaceRoot ?? ""; }); }
worktreePreparation(id: string) { return this.preparations.read(id); }
cancelWorktreePreparation(id: string) { return this.preparations.read(id); }
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo";
@@ -1,3 +1,4 @@
import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error";
import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
@@ -118,3 +119,28 @@ export class MockWorkspace extends MockFeedback {
}
}
export class MockPreparations {
private items = new Map<string, import("./workspace").WorktreePreparation>();
async prepare(id: string, selection: import("./workspace").WorktreeSelection, create: () => Promise<string>) {
const found = this.items.get(id);
if (found) return found;
const value: import("./workspace").WorktreePreparation = { id, phase: "validating", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() };
try {
value.workspaceRoot = await create();
value.phase = "ready";
value.branch = "reasonix/demo";
} catch (error) {
value.phase = "failed";
value.code = "workspace.worktree_failed";
value.detail = reason(error);
}
this.items.set(id, value);
return value;
}
async read(id: string) {
const value = this.items.get(id);
if (!value) throw new Error("unknown worktree operation");
return value;
}
}
+3
View File
@@ -451,6 +451,9 @@ export interface AgentPort {
setWorkspace(path: string): Promise<void>;
isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>;
worktreeSources(): Promise<import("./workspace").WorktreeSources>;
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection): Promise<import("./workspace").WorktreePreparation>;
worktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
cancelWorktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
// The native folder picker, or "" where there is none (a browser tab) or
// when the user cancelled. Only the shell can open one.
// The native folder picker: the chosen path, "" when the user cancelled, and
+12
View File
@@ -407,6 +407,18 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<import("./workspace").WorktreeSources>("/workspace/worktree-sources");
}
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) {
return this.post0<import("./workspace").WorktreePreparation>("/workspace/prepare", { id, selection });
}
worktreePreparation(id: string) {
return this.get<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}`);
}
cancelWorktreePreparation(id: string) {
return this.post0<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}/cancel`);
}
async openInEditor(): Promise<{ editor: string; root: string }> {
return this.post0<{ editor: string; root: string }>("/workspace/editor");
}
@@ -132,6 +132,18 @@ export interface WorktreeSelection {
attachments?: string[];
}
export interface WorktreePreparation {
id: string;
phase: string;
sourceRoot: string;
ref: string;
startedAt: number;
branch?: string;
workspaceRoot?: string;
code?: string;
detail?: string;
}
export interface CommitFile {
path: string;
// git raw status letter: "A", "M", "D", "T".
@@ -0,0 +1,16 @@
.workspace-path { display: flex; align-items: flex-start; gap: 6px; min-width: 0; color: var(--muted); font: var(--fs-meta)/1.5 var(--ui) }
.workspace-path details { flex: 1; min-width: 0 }
.workspace-path summary { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 6px 4px; border-radius: 6px; list-style: none; cursor: pointer }
.workspace-path summary::-webkit-details-marker { display: none }
.workspace-path summary > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis }
.workspace-path summary > .studio-icon { width: 17px; height: 17px; flex-shrink: 0 }
.workspace-path details[open] summary > .studio-icon:last-child { transform: rotate(180deg) }
.workspace-path summary:hover { color: var(--text); background: var(--float-hi) }
.workspace-path code { display: block; padding: 6px 4px 10px; overflow-wrap: anywhere; user-select: text; color: var(--text); font: var(--fs-micro)/1.7 var(--mono) }
.workspace-path .workspace-path-copy { display: grid; place-items: center; width: 36px; min-width: 36px; height: 38px; margin: 0; padding: 6px; color: var(--muted); background: none; border: 0; border-radius: 6px; cursor: pointer }
.workspace-path .workspace-path-copy:hover { color: var(--text); background: var(--float-hi) }
.workspace-path .workspace-path-copy .studio-icon { width: 18px; height: 18px }
.workspace-path :is(summary, button):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
.workspace-branch-value { display: flex; min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap }
.workspace-branch-value > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis }
.workspace-branch-value > span:last-child { flex-shrink: 0 }
@@ -0,0 +1,50 @@
.worktree-stage-host { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain }
.worktree-setup { width: min(580px, calc(100% - 48px)); margin: 0 auto; padding: clamp(24px, 7vh, 78px) 0 28px; color: var(--text); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px }
.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 }
.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) }
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) }
.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em }
.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 }
.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) }
.worktree-setup-phase > span:first-child { display: flex; align-items: center; gap: 10px; min-width: 0 }
.worktree-setup-elapsed { white-space: nowrap; color: var(--muted); font-size: var(--fs-micro) }
.worktree-setup-phase .studio-icon { width: 19px; height: 19px; flex-shrink: 0 }
.worktree-spinner { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; border: 2px solid var(--hair); border-top-color: var(--brand); border-right-color: var(--brand); border-radius: 50%; animation: worktree-spin 1.2s linear infinite }
.worktree-setup-branches { padding: 20px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup dl { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin: 0; font-size: var(--fs-meta) }
.worktree-setup dt { color: var(--muted) }
.worktree-setup dd { display: flex; align-items: center; gap: 8px; margin: 0; overflow-wrap: anywhere; min-width: 0 }
.worktree-setup dd > .studio-icon { width: 16px; height: 16px; color: var(--muted); flex-shrink: 0 }
.worktree-setup-draft { padding: 20px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup-label { color: var(--muted); font-size: var(--fs-meta) }
.worktree-setup-task { margin: 7px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui); max-height: 150px; overflow: auto }
.worktree-setup-files { margin-top: 18px }
.worktree-setup-files > div { display: flex; align-items: center; gap: 9px; padding: 8px 0; font-size: var(--fs-meta) }
.worktree-setup-files .studio-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--muted) }
.worktree-setup-files > div > span { overflow-wrap: anywhere; min-width: 0 }
.worktree-setup-files small { margin-left: auto; color: var(--muted); font-size: var(--fs-micro); white-space: nowrap }
.worktree-setup-location { padding: 8px 0; border-bottom: 1px solid var(--hair) }
.worktree-setup-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; position: sticky; bottom: 0; z-index: 1; padding: 16px 0 4px; background: var(--surface) }
.worktree-setup-actions > div { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 9px }
.worktree-setup-actions .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 8px 14px; border-radius: 8px; font-size: var(--fs-ui) }
.worktree-setup-actions .studio-icon { width: 17px; height: 17px; flex-shrink: 0 }
.worktree-setup-detail-button { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: var(--fs-meta); cursor: pointer }
.worktree-setup-detail-button:hover { color: var(--text) }
.worktree-setup-handoff { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: var(--fs-meta) }
.worktree-setup-slow { margin: 10px 0 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.7 }
.worktree-setup-details { padding: 16px 0 0; font-size: var(--fs-micro) }
.worktree-setup-details dd { font-family: var(--mono); font-size: var(--fs-micro) }
.worktree-setup-details .copy { padding: 3px; flex-shrink: 0 }
.worktree-setup-details .copy .studio-icon { width: 14px; height: 14px }
.worktree-setup-details pre { padding: 12px; background: var(--raised); border-radius: 7px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fs-micro)/1.7 var(--mono) }
.worktree-setup[data-phase="failed"] .worktree-setup-phase, .worktree-setup[data-phase="cleanup_failed"] .worktree-setup-phase { background: color-mix(in srgb, var(--err) 8%, transparent); color: var(--err) }
.worktree-setup[data-phase="ready"] .worktree-setup-phase, .worktree-setup[data-phase="retained"] .worktree-setup-phase { background: color-mix(in srgb, var(--ok) 8%, transparent); color: var(--ok) }
.worktree-setup[data-phase="cancelled"] .worktree-setup-phase { background: var(--raised); color: var(--muted) }
.worktree-setup :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.dropzone[data-worktree-setup] .fmain, .dropzone[data-worktree-setup] .shots, .dropzone[data-worktree-setup] .composeguide { display: none }
.dropzone[data-worktree-setup] { opacity: .65 }
@keyframes worktree-spin { to { transform: rotate(360deg) } }
@media (max-height: 800px) { .worktree-setup { padding-top: 20px } .worktree-setup-context { margin-bottom: 15px } .worktree-setup-description { margin-bottom: 16px } .worktree-setup-branches, .worktree-setup-draft { padding-block: 15px } }
@media (max-width: 600px) { .worktree-setup { width: calc(100% - 28px); padding-top: 22px } .worktree-setup-description { font-size: var(--fs-ui) } .worktree-setup-phase { flex-wrap: wrap; gap: 8px; font-size: var(--fs-meta) } .worktree-setup dl { grid-template-columns: 70px minmax(0, 1fr); gap: 10px } .worktree-setup-actions { flex-wrap: wrap } .worktree-setup-actions > div { margin-left: auto } }
@media (prefers-reduced-motion: reduce) { .worktree-spinner { animation: none } }
+37 -25
View File
@@ -1,28 +1,40 @@
.worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) }
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) }
.worktree-panel { position: fixed; z-index: 1000; width: min(370px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 18px; border: 1px solid var(--hair); border-radius: 20px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: calc(14px * var(--fs-scale, 1))/1.5 var(--ui) }
.worktree-heading { color: var(--muted); margin-bottom: 16px; font-size: calc(13px * var(--fs-scale, 1)) }
.worktree-toggle { display: flex; align-items: center; gap: 12px; padding: 4px 0 18px; border-bottom: 1px solid var(--hair); margin-bottom: 8px }
.worktree-toggle > div { flex: 1; min-width: 0 }
.worktree-toggle span { font-size: calc(16px * var(--fs-scale, 1)) }
.worktree-toggle small { display: block; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)); margin-top: 4px }
.worktree-panel .studio-icon { width: 20px; height: 20px; flex-shrink: 0 }
.worktree-panel .sw { flex-shrink: 0; width: 42px; height: 24px; border-radius: 14px }
.worktree-panel .sw i { width: 18px; height: 18px; margin-left: 3px }
.worktree-panel .sw[aria-checked="true"] { background: var(--text) }
.worktree-panel .sw[aria-checked="true"] i { transform: translateX(18px) }
.worktree-row { display: flex; align-items: center; gap: 12px; min-height: 48px; width: 100%; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; text-align: left }
button.worktree-row:hover { background: var(--float-hi) }
.worktree-value { color: var(--muted); margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 170px }
.worktree-note { border-top: 1px solid var(--hair); padding-top: 14px; margin: 6px 0 0; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)) }
.worktree-panel input { width: 100%; border: 1px solid var(--hair); border-radius: 8px; padding: 9px 12px; background: var(--raised); color: inherit; font: inherit }
.worktree-sources { max-height: 260px; overflow: auto; margin: 10px 0 }
.worktree-sources > div > small { display: block; padding: 8px; color: var(--muted); font-size: calc(12px * var(--fs-scale, 1)) }
.worktree-sources button { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 38px; border: 0; border-radius: 6px; background: none; color: inherit; padding: 8px; text-align: left; font: inherit }
.worktree-sources button span { overflow-wrap: anywhere; flex: 1 }
.worktree-sources button small { color: var(--muted); flex-shrink: 0 }
.worktree-sources button:hover { background: var(--float-hi) }
.worktree-sources button[aria-selected="true"] { color: var(--brand); background: var(--brand-wash) }
.worktree-back { border: 0; background: none; color: var(--muted); padding: 6px; font: inherit }
.worktree-panel :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) }
.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 }
.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) }
.workspace-palette-close { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--muted); background: none; cursor: pointer }
.workspace-palette-close:hover { background: var(--float-hi); color: var(--text) }
.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px }
.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) }
.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) }
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) }
.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px }
.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer }
.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) }
.workspace-palette-base { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin-bottom: 12px; font-size: var(--fs-meta) }
.workspace-palette-base > span, .workspace-palette-base > small { color: var(--muted) }
.workspace-palette-base > strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: var(--w-med) }
.workspace-palette-base > small { grid-column: 1 / -1; font-size: var(--fs-micro) }
.workspace-palette-search { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 11px; margin-bottom: 10px; border: 1px solid var(--hair); border-radius: 9px; color: var(--muted); background: var(--raised) }
.workspace-palette-search:focus-within { border-color: var(--focus) }
.workspace-palette-search input { flex: 1; min-width: 0; width: 100%; border: 0; padding: 0; border-radius: 0; background: transparent; color: var(--text); font: inherit; outline: none }
.worktree-sources { max-height: 250px; overflow: auto; overscroll-behavior: contain }
.workspace-palette-group { display: block; padding: 8px; color: var(--muted); font-size: var(--fs-micro) }
.worktree-sources button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; background: none; color: var(--text); padding: 10px 8px; text-align: left; font: inherit; cursor: pointer }
.worktree-sources > div > button:last-child { border-bottom-color: transparent }
.worktree-sources button .workspace-palette-branch { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-ui) }
.worktree-sources button small { color: var(--muted); font-size: var(--fs-micro); white-space: nowrap }
.worktree-sources button > .studio-icon:last-child { width: 14px; height: 14px; color: var(--muted) }
.worktree-sources button:not(:disabled):hover { background: var(--float-hi); border-radius: 7px }
.worktree-sources button[aria-selected="true"] { color: var(--brand); background: var(--brand-wash); border-radius: 8px; border-bottom-color: transparent }
.worktree-sources button[aria-selected="true"] > .studio-icon:last-child { color: var(--brand) }
.worktree-sources button[aria-selected="true"] small { color: var(--brand); background: var(--brand-wash); padding: 2px 5px; border-radius: 4px }
.worktree-sources button:disabled { color: var(--muted); cursor: not-allowed }
.workspace-palette-note { margin: 12px 0 14px; color: var(--muted); font-size: var(--fs-micro); line-height: 1.6 }
.workspace-palette-footer { padding-top: 8px; border-top: 1px solid var(--hair) }
.workspace-palette-empty { padding: 18px 8px; text-align: center; color: var(--muted); font-size: var(--fs-meta) }
.workspace-palette-empty p { margin: 0 0 10px }
.workspace-palette :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
@media (max-width: 480px) { .workspace-palette { padding: 12px } .workspace-palette-identity { gap: 7px } .worktree-sources button { gap: 7px; padding-inline: 5px } .worktree-sources button small { max-width: 92px; white-space: normal; font-size: var(--fs-micro) } }
+4 -15
View File
@@ -1,8 +1,9 @@
import { useCallback, useId, useState, type CSSProperties } from "react";
import { useId, type CSSProperties } from "react";
import { t } from "../i18n";
import type { AgentPort, WorkspaceBranch, WorkspaceGit } from "../port/port";
import type { AgentPort, WorkspaceGit } from "../port/port";
import { Picker, type MenuItem } from "./Menu";
import { StudioIcon } from "./StudioIcon";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
interface Props {
port: AgentPort;
@@ -51,15 +52,11 @@ function fitCard(anchor: HTMLDivElement) {
// the pane's tree refresh; this component owns only the choosing.
export function BranchChip({ port, git, changeCount, countElsewhere = false, onChanged, onSwitched, onError }: Props) {
const branchTipId = useId();
const [moving, setMoving] = useState<string | null>(null);
const { locals, moving, loadBranches, switchTo } = useWorkspaceBranches(port, onChanged, onSwitched, onError);
const busy = moving !== null;
const [locals, setLocals] = useState<WorkspaceBranch[]>([]);
// The list re-reads on open: a terminal or another window can move HEAD
// while this pane is not looking, and a stale list would offer a switch
// the repository no longer means.
const loadBranches = useCallback(() => {
void port.branches().then((r) => setLocals(r.branches)).catch(() => setLocals([]));
}, [port]);
// A branch another worktree holds is listed but closed: git would refuse
// the switch, so the row says where the branch lives instead of failing.
const branchItems: MenuItem[] = [
@@ -71,14 +68,6 @@ export function BranchChip({ port, git, changeCount, countElsewhere = false, onC
desc: b.worktree ? t("已由其他工作树检出") : undefined,
})),
];
const switchTo = (name: string) => {
if (busy) return;
setMoving(name);
void port.switchBranch(name)
.then((git) => { onChanged(); onSwitched?.(git); })
.catch(onError)
.finally(() => setMoving(null));
};
if (git && !git.repo && git.absent) {
const refused = git.absent === "refused";
+42 -73
View File
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { UNCONFIGURED_REF } from "./ConnectNote";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment, WorkspaceGit } from "../port/port";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup } from "./useWorktreeSetup";
import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
@@ -32,8 +34,11 @@ import { useDraft } from "./useDraft";
import { touchKeyboard } from "./touchKeyboard";
import { ImagePreview } from "./ImagePreview";
import { SideAskButton } from "./SideAskButton";
import { chipId, chipName, isPicture, quoteBlock, releaseChip, type Chip } from "./composerChips";
interface Props {
worktreeTarget?: HTMLElement | null;
onWorktreePhase?: (phase: string | null) => void;
fresh?: boolean;
port: AgentPort;
status: SessionStatus | null;
@@ -73,62 +78,15 @@ interface Props {
draftKey?: string;
}
// What is riding along with this turn. An attachment travels as bytes or a path
// the kernel already saved; a long paste is held out of the box so eight hundred
// lines of log do not become the composer.
type Chip =
| {
k: "attachment";
id: string;
name: string;
state: "adding" | "ready" | "failed";
a?: Attachment;
blob?: File;
url?: string;
error?: string;
}
| PasteChip
| { k: "quote"; id: string; body: string; turn?: number; source?: string; lines: number };
function chipName(c: Chip): string {
if (c.k === "quote" && c.source) return t("引用 {name}", { name: c.source.split("/").at(-1) ?? c.source });
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
}
// What the model reads instead of a bare blockquote. Which reply this is about
// is the kernel's turn number, so it is stated rather than left to be inferred
// from the words; a rebuilt transcript with no turn says nothing instead of
// guessing one. English like the kernel's other host-authored prefixes: the
// line is addressed to the model, not to the reader.
function quoteBlock(c: Extract<Chip, { k: "quote" }>): string {
// The path is a file name the workspace chose, so it cannot end the line or close the bracket.
const where = c.source?.replace(/[\u0000-\u001f\u007f\u2028\u2029[\]]/g, " ").trim();
const head = where
? `[Quoting text selected in the workspace file ${where}.]`
: c.turn === undefined
? "[Quoting an earlier assistant reply in this conversation.]"
: `[Quoting the assistant reply from turn ${c.turn}.]`;
return [head, ...c.body.split("\n").map((line) => `> ${line}`)].join("\n");
}
function isPicture(c: Chip): boolean {
return c.k === "attachment" && (c.a?.image === true || (c.a?.image !== false && c.blob?.type.startsWith("image/") === true));
}
function releaseChip(c: Chip) {
if (c.k === "attachment" && c.url) URL.revokeObjectURL(c.url);
}
let chipSeq = 0;
const chipId = () => `c${++chipSeq}`;
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false }: Props) {
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
const [submitting, setSubmitting] = useState(false);
const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged);
const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged, status?.sessionPath ?? "");
const [setupDraft, setSetupDraft] = useState<{ text: string; files: string[] }>({ text: "", files: [] });
const setupPhase = worktree.stage?.phase ?? null;
useEffect(() => { onWorktreePhase?.(setupPhase); }, [onWorktreePhase, setupPhase]);
const { text, setText, beginSubmit, finishSubmit } = useDraft(draftKey, submitting);
// The caret decides which token is being completed, so it is state here
// rather than something read off the element when a menu happens to open.
@@ -143,6 +101,11 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const chosenModelGroups = chosenGroups(menuItems, modelGroups);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const box = useRef<HTMLTextAreaElement>(null);
const hadSetup = useRef(false);
useEffect(() => {
if (hadSetup.current && !setupPhase) box.current?.focus();
hadSetup.current = !!setupPhase;
}, [setupPhase]);
const refine = usePromptRefine(port, text, (next) => {
setText(next);
setCaret(next.length);
@@ -262,6 +225,15 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const line = compose(v);
const call = chips.call(compose);
const draft = { text, shots, caret: caretRef.current, chips: chips.held() };
if (worktree.selection || worktree.stage) setSetupDraft({ text: v, files: shots.map(chipName) });
const restoreDraft = () => {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
if (!worktree.selection && !worktree.stage) queueMicrotask(() => box.current?.focus());
};
beginSubmit(text);
submittingRef.current = true;
setSubmitting(true);
@@ -269,28 +241,23 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
setShots([]);
void (async () => {
try {
if (worktree.selection) await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : []));
if (worktree.selection || worktree.stage) {
const prepared = await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : []));
if (!prepared) { restoreDraft(); return; }
}
const sent = await (call ? onSubmit(line, call) : onSubmit(line));
if (sent) {
finishSubmit(true);
worktree.dismiss();
draft.shots.forEach(releaseChip);
}
else {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
queueMicrotask(() => box.current?.focus());
restoreDraft();
worktree.dismiss();
}
} catch (e) {
finishSubmit(false);
pending.current = draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
queueMicrotask(() => box.current?.focus());
onError(e);
restoreDraft();
if (!(e instanceof WorktreeSetupFailure)) onError(e);
} finally {
submittingRef.current = false;
setSubmitting(false);
@@ -421,11 +388,13 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const hasDraft = text.trim().length > 0 || shots.some((c) => c.k !== "attachment" || c.state === "ready");
const lines = countLines(text);
const showCount = text.length >= 240 || lines > 3;
const sendDisabled = submitting || stopping || adding || failed || !hasDraft;
const sendDisabled = submitting || stopping || adding || failed || !hasDraft || !!worktree.stage;
const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c));
return (
<div className="dropzone" ref={dropzone}>
const setupPage = worktree.stage && <WorktreeSetupPage stage={worktree.stage} task={setupDraft.text} files={setupDraft.files} busy={submitting} onRetry={send} onCancel={() => void worktree.cancel()} onEdit={worktree.dismiss}/>;
return (<>
{setupPage && (worktreeTarget ? createPortal(setupPage, worktreeTarget) : setupPage)}
<div className="dropzone" ref={dropzone} data-worktree-setup={worktree.stage ? "" : undefined} inert={!!worktree.stage}>
{menu.open && (
<CompletionMenu
id={completionId}
@@ -694,7 +663,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{fresh && worktree.sources?.available ? <WorktreeChip changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources} selection={worktree.selection} branch={git?.branch ?? "HEAD"} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
kernel turns off the moment a plan is approved. The lifecycle is a
separate reading — an approved plan is still running, and saying so
@@ -795,6 +764,6 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
</button>}
</span>
</div>
</div>
</div></>
);
}
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { Composer } from "./Composer";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
import type { WorktreePreparation } from "../port/workspace";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
it.each(["failed", "cancelled"])("restores composer focus after dismissing %s preparation", async (phase) => {
const port = new MockPort();
vi.spyOn(port, "worktreeSources").mockResolvedValue({ available: true, sourceRoot: "/source", sources: [{ ref: "refs/heads/main", name: "main", current: true, remote: false }] });
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(port, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
const onSubmit = vi.fn(async () => true);
const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus;
render(<Composer port={port} status={st} running={false} fresh git={{ repo: true, branch: "main", name: "project", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}/>);
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(screen.getByRole("button", { name: "新建 worktree" }));
fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" }));
const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement;
box.focus();
// jsdom lacks inert focus suppression; mirror the browser boundary locally.
const focus = box.focus.bind(box);
vi.spyOn(box, "focus").mockImplementation((options) => { if (!box.closest("[inert]")) focus(options); });
fireEvent.change(box, { target: { value: "保留这份任务", selectionStart: 3 } });
box.blur();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(start).toHaveBeenCalledOnce());
await act(async () => resolve({ id: start.mock.calls[0][0], phase, sourceRoot: "/source", ref: "refs/heads/main", startedAt: Date.now() }));
fireEvent.click(await screen.findByRole("button", { name: "返回编辑" }));
await waitFor(() => expect(document.activeElement).toBe(box));
expect(box.closest("[inert]")).toBeNull();
expect(box.value).toBe("保留这份任务");
expect(onSubmit).not.toHaveBeenCalled();
});
+6 -3
View File
@@ -94,6 +94,8 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const ready = sessionRead.kind === "settled";
const [tab, showView] = useShowView(rt.id, onManualBrowser);
const [sideAsk, setSideAsk] = useState(false);
const [worktreeTarget, setWorktreeTarget] = useState<HTMLDivElement | null>(null);
const [worktreePhase, setWorktreePhase] = useState<string | null>(null);
const [mcp, setMcp] = useState<McpEntry[]>([]);
const [askFocus, setAskFocus] = useState(0);
const [tree, setTree] = useState<WorkspaceChanges | null>(null);
@@ -473,6 +475,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<div className="pbody rewind-notice-host" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
<div className="pviews">
<div className="worktree-stage-host" ref={setWorktreeTarget} hidden={tab !== "flow" || !worktreePhase}/>
<ReceiptPrefContext.Provider value={receiptPref}>
<PaneShown.Provider value={shown}>
@@ -490,7 +493,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
takeovers={s.takeovers}
waiting={s.waiting}
scroll={flow}
hidden={tab !== "flow"}
hidden={tab !== "flow" || !!worktreePhase}
focus={null}
find={find.at}
query={find.query}
@@ -565,7 +568,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{/* Everything stacked above the input box shares one ceiling, so no
child of this region may grow without bound. */}
<PlanFold plan={s.plan} goal={status?.goal} shown={tab === "flow"} paused={blocked} />
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0} />}
{tab === "flow" && <RunLine label={worktreePhase ? t(["failed", "cleanup_failed"].includes(worktreePhase) ? "创建失败" : worktreePhase === "cancelled" ? "创建已取消" : "准备工作目录") : chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0} />}
<div className="composeaux">
<Queue
queue={queue}
@@ -601,7 +604,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
</div>
{alert && <div className="cmpalert">{alert}</div>}
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer port={port} fresh={ready && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
@@ -0,0 +1,32 @@
import { t } from "../i18n";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
import "../styles/workspace-path.css";
export function compactWorkspaceValue(value: string, length = 38): string {
const chars = Array.from(value);
if (chars.length <= length) return value;
const tail = Math.min(16, Math.floor(length / 2));
return chars.slice(0, length - tail - 1).join("") + "…" + chars.slice(-tail).join("");
}
export function workspaceName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) || path;
}
export function WorkspaceBranchName({ value }: { value: string }) {
const chars = Array.from(value);
return <span className="workspace-branch-value" title={value}>{chars.length > 32 ? <><span>{chars.slice(0, -12).join("")}</span><span>{chars.slice(-12).join("")}</span></> : value}</span>;
}
export function WorkspacePath({ path, onResize }: { path: string; onResize?: () => void }) {
const parts = path.split(/[\\/]/).filter(Boolean);
const short = parts.length > 2 ? "…/" + parts.slice(-2).join("/") : path;
return <div className="workspace-path">
<details data-action="workspace.configure" onToggle={onResize}>
<summary aria-label={t("查看完整路径")} title={path}><StudioIcon name="folder"/><span>{short}</span><StudioIcon name="down"/></summary>
<code>{path}</code>
</details>
<CopyButton text={path} iconOnly className="workspace-path-copy" label={t("复制完整路径")}/>
</div>;
}
+71 -62
View File
@@ -1,15 +1,22 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import type { WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { AgentPort, WorkspaceGit } from "../port/port";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
import { StudioIcon } from "./StudioIcon";
import { Switch } from "./Switch";
import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
import { listenAction } from "./listen";
import { beside, pinToViewport, useFollow, zoom } from "./place";
import "../styles/worktree.css";
interface Props {
port: AgentPort;
onChanged: () => void;
onSwitched?: (git: WorkspaceGit) => void;
onError: (error: unknown) => void;
sources: WorktreeSources;
canCreate: boolean;
selection: WorktreeSelection | null;
branch: string;
busy: boolean;
@@ -18,95 +25,97 @@ interface Props {
refresh: () => Promise<WorktreeSources>;
}
export function WorktreeChip({ sources, selection, branch, busy, changeCount, onSelect, refresh }: Props) {
export function WorktreeChip({ sources, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
const [open, setOpen] = useState(false);
const [choosing, setChoosing] = useState(false);
const [query, setQuery] = useState("");
const [error, setError] = useState(false);
const [sourceError, setSourceError] = useState(false);
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
const title = useId();
const returnFocus = useRef(false);
const close = () => { returnFocus.current = true; setOpen(false); setQuery(""); };
const { locals, moving, reading, loadBranches, switchTo } = useWorkspaceBranches(port, onChanged, (git) => { onSwitched?.(git); close(); }, onError, sources.sourceRoot);
useEffect(() => {
if (!open && moving === null && returnFocus.current) { returnFocus.current = false; trigger.current?.focus(); }
}, [open, moving]);
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
const label = chosen?.name ?? selection?.ref ?? branch;
const close = () => { setOpen(false); setChoosing(false); setQuery(""); trigger.current?.focus(); };
useEffect(() => {
let live = true;
returnFocus.current = false; setWorkspace(null); setOpen(false); setQuery("");
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
return () => { live = false; };
}, [port, sources.sourceRoot]);
const put = useCallback(() => {
if (!trigger.current || !panel.current) return;
const anchor = trigger.current.getBoundingClientRect();
const height = panel.current.offsetHeight * zoom();
pinToViewport(panel.current, anchor.left, beside(anchor, height, innerHeight, "above", 10), 12);
}, []);
useFollow(open, put, `${choosing}:${query}:${error}:${!!selection}`);
useFollow(open, put, `${query}:${reading}:${sourceError}:${!!selection}:${locals.length}`);
useEffect(() => {
if (!open) return;
if (choosing) search.current?.focus();
else panel.current?.querySelector<HTMLButtonElement>("button")?.focus();
search.current?.focus();
const outside = (event: MouseEvent) => {
if (!panel.current?.contains(event.target as Node) && !trigger.current?.contains(event.target as Node)) close();
};
const key = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
if (choosing) { setChoosing(false); setQuery(""); } else close();
}
if (event.key === "Escape") { event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input") ?? []);
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input, summary") ?? []);
const edge = event.shiftKey ? stops[0] : stops.at(-1);
if (document.activeElement === edge) {
event.preventDefault();
(event.shiftKey ? stops.at(-1) : stops[0])?.focus();
}
if (document.activeElement === edge) { event.preventDefault(); (event.shiftKey ? stops.at(-1) : stops[0])?.focus(); }
};
const stopOutside = listenAction(window, "mousedown", { action: "workspace.configure", listener: outside as EventListener });
const stopKey = listenAction(window, "keydown", { action: "workspace.configure", listener: key as EventListener }, true);
return () => { stopOutside(); stopKey(); };
}, [open, choosing]);
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(put);
if (panel.current) observer?.observe(panel.current);
return () => { stopOutside(); stopKey(); observer?.disconnect(); };
}, [open, put]);
useEffect(() => { if (busy) setOpen(false); }, [busy]);
const show = () => {
if (open) { close(); return; }
setOpen(true);
setError(false);
void refresh().catch(() => setError(true));
const reload = () => {
setSourceError(false); loadBranches();
if (canCreate) void refresh().catch(() => setSourceError(true));
};
const toggle = () => onSelect(selection ? null : {
sourceRoot: sources.sourceRoot,
ref: sources.sources.find((source) => source.current)?.ref ?? "HEAD",
});
const shown = sources.sources.filter((source) => source.name.toLowerCase().includes(query.toLowerCase()));
const show = () => { if (open) { close(); return; } setOpen(true); setQuery(""); reload(); };
const selectWorktree = () => {
setQuery("");
onSelect({ sourceRoot: sources.sourceRoot, ref: (sources.sources.find((source) => !source.remote && source.name === branch) ?? sources.sources.find((source) => source.current))?.ref ?? "HEAD" });
};
const choices = selection ? sources.sources.map((source) => ({ ...source, worktree: "" })) : locals.map((source) => ({ ...source, ref: source.name, remote: false }));
const selected = (source: typeof choices[number]) => selection ? selection.ref === source.ref : !!source.current;
const shown = choices.filter((source) => source.name.toLowerCase().includes(query.toLowerCase())).sort((a, b) => Number(selected(b)) - Number(selected(a)));
const failed = selection ? sourceError : reading === "failed";
const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0;
return <div className="studio-branch-pop">
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} aria-haspopup="dialog" aria-expanded={open} disabled={busy} onClick={show}>
<StudioIcon name="branch" /><span className="lb">{selection ? t("独立 worktree · {branch}", { branch: label }) : t("当前目录 · {branch}", { branch })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down" />
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={selection ? label : branch} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立 worktree · {branch}", { branch: compactWorkspaceValue(label) }) : t("当前目录 · {branch}", { branch: compactWorkspaceValue(branch) })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
</button>
{open && createPortal(<div ref={panel} className="worktree-panel composer-menu" role="dialog" aria-labelledby={title}>
<div className="worktree-heading" id={title}>{choosing ? t("选择起始分支") : t("配置工作目录")}</div>
{error ? <p role="alert">{t("分支读取失败,请关闭后重试")}</p> : choosing ? <>
<input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t("搜索分支…")} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]')?.focus(); }
}} />
<div data-action="workspace.source-navigate" className="worktree-sources" role="listbox" aria-label={t("起始分支")} onKeyDown={(event) => {
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button"));
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "ArrowDown" ? at + 1 : event.key === "ArrowUp" ? at - 1 : at;
if (next !== at) { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); }
}}>
{[false, true].map((remote) => <div key={String(remote)}>
{shown.some((source) => source.remote === remote) && <small>{remote ? t("远程分支") : t("本地分支")}</small>}
{shown.filter((source) => source.remote === remote).map((source) => <button data-action="workspace.source-select" key={source.ref} type="button" role="option" aria-selected={selection?.ref === source.ref} onClick={() => {
onSelect({ ref: source.ref, sourceRoot: sources.sourceRoot }); setChoosing(false); setQuery("");
}}><span>{source.name}</span>{source.current && <small>{t("当前分支")}</small>}{selection?.ref === source.ref && <StudioIcon name="check" />}</button>)}
</div>)}
{shown.length === 0 && <p>{t("没有匹配的分支")}</p>}
</div>
<button data-action="workspace.source-open" className="worktree-back" onClick={() => setChoosing(false)}>{t("返回")}</button>
</> : <>
<div className="worktree-toggle"><StudioIcon name="expand" /><div><span>{t("新建 worktree")}</span><small>{t("为此会话创建独立的代码目录")}</small></div><Switch data-action="workspace.worktree-toggle" on={!!selection} busy={busy || !sources.available} label={t("新建 worktree")} onClick={toggle} /></div>
{selection ? <>
<button data-action="workspace.source-open" className="worktree-row" onClick={() => setChoosing(true)}><StudioIcon name="branch" /><span>{t("起始分支")}</span><span className="worktree-value">{label}</span><StudioIcon name="chevron" /></button>
<div className="worktree-row"><StudioIcon name="branch" /><span>{t("任务分支")}</span><span className="worktree-value">{t("自动创建")}</span></div>
<p className="worktree-note">{t("基于所选分支的已提交版本创建,不包含未提交修改")}</p>
</> : <p className="worktree-note">{t("使用当前目录,与该目录中的其他会话共享文件")}</p>}
</>}
{open && createPortal(<div ref={panel} className="workspace-palette" role="dialog" aria-labelledby={title}>
<div className="workspace-palette-heading"><span id={title}>{t("配置工作目录")}</span><button data-action="workspace.configure" className="workspace-palette-close" aria-label={t("关闭工作目录配置")} onClick={close}><StudioIcon name="close"/></button></div>
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong><span>{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}</span></div>
{canCreate && <div className="worktree-modes"><button data-action="workspace.worktree-toggle" aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("使用当前目录")}</button><button data-action="workspace.worktree-toggle" aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("新建 worktree")}</button></div>}
{selection && <div className="workspace-palette-base"><span>{t("起始分支")}</span><strong title={label}>{compactWorkspaceValue(label)}</strong><small>{t("任务分支自动创建")}</small></div>}
<div className="workspace-palette-search"><StudioIcon name="search"/><input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t(selection ? "搜索起始分支…" : "搜索本地分支…")} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]:not(:disabled)')?.focus(); }
}}/></div>
{failed ? <div className="workspace-palette-empty" role="alert"><p>{t("分支读取失败")}</p><button data-action="workspace.source-open" className="btn" onClick={reload}>{t("重新读取")}</button></div> : loading ? <div className="workspace-palette-empty" role="status">{t("正在读取分支…")}</div> : <div className="worktree-sources" role="listbox" aria-label={t(selection ? "起始分支" : "本地分支")} data-action="workspace.source-navigate" onKeyDown={(event) => {
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "ArrowDown" ? at + 1 : event.key === "ArrowUp" ? at - 1 : at;
if (next !== at) { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); }
if (event.key === "Home" || event.key === "End") { event.preventDefault(); (event.key === "Home" ? items[0] : items.at(-1))?.focus(); }
}}>
{[false, true].map((remote) => <div key={String(remote)}>{remote && shown.some((source) => source.remote) && <small className="workspace-palette-group">{t("远程分支")}</small>}{shown.filter((source) => source.remote === remote).map((source) => <button data-action={selection ? "workspace.source-select" : "git.branch"} key={source.ref} type="button" role="option" aria-label={source.name} aria-selected={selected(source)} disabled={!selection && (!!source.worktree || moving !== null)} title={source.worktree ? `${source.name}\n${source.worktree}` : source.name} onClick={() => {
if (selection) onSelect({ ref: source.ref, sourceRoot: sources.sourceRoot }); else switchTo(source.name);
}}><StudioIcon name="branch"/><span className="workspace-palette-branch"><WorkspaceBranchName value={source.name}/></span>{source.worktree ? <small>{t("其他 worktree 占用")}</small> : selected(source) && <small>{t(selection ? "起点" : "当前")}</small>}<StudioIcon name={selected(source) ? "check" : "chevron"}/></button>)}</div>)}
{shown.length === 0 && <p className="workspace-palette-empty">{t("没有匹配的分支")}</p>}
</div>}
<p className="workspace-palette-note">{t(selection ? "从已提交版本创建,任务和附件将在就绪后发送" : "选择后立即切换,同目录会话也会受影响")}</p>
<div className="workspace-palette-footer"><WorkspacePath path={sources.sourceRoot} onResize={put}/></div>
</div>, document.body)}
</div>;
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import type { WorktreePreparation } from "../port/workspace";
afterEach(() => { cleanup(); vi.useRealTimers(); });
const status = (phase: string): WorktreePreparation => ({ id: "operation", phase, ref: "refs/heads/main", sourceRoot: "/source/project", workspaceRoot: "/worktrees/project", branch: "reasonix/task", startedAt: Date.now() });
const draw = (phase: string, code?: string) => {
const retry = vi.fn(), cancel = vi.fn(), edit = vi.fn();
const view = render(<WorktreeSetupPage stage={{ ...status(phase), code, detail: "diagnostic" }} task="保留的任务" files={["input.txt"]} busy={false} onRetry={retry} onCancel={cancel} onEdit={edit}/>);
return { ...view, retry, cancel, edit };
};
it("retains task and attachments during a long wait and lets the user cancel", () => {
vi.useFakeTimers();
const { cancel } = draw("checking_out");
expect(screen.getByText("保留的任务")).toBeTruthy();
expect(screen.getByText("input.txt")).toBeTruthy();
act(() => vi.advanceTimersByTime(16000));
expect(screen.getByRole("status").textContent).toContain("仍在等待");
fireEvent.click(screen.getByRole("button", { name: "取消创建" }));
expect(cancel).toHaveBeenCalledOnce();
});
it("keeps failure details behind disclosure and retries without losing the task", () => {
const { retry } = draw("failed", "workspace.worktree_timeout");
expect(screen.queryByText("diagnostic")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "查看详情" }));
expect(screen.getByText("diagnostic")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重试创建" }));
expect(retry).toHaveBeenCalledOnce();
expect(screen.getByText("保留的任务")).toBeTruthy();
});
it("does not offer another creation while cleanup is uncertain", () => {
draw("cleanup_failed", "workspace.worktree_cleanup_failed");
expect(screen.queryByRole("button", { name: "重试创建" })).toBeNull();
const cancel = screen.queryByRole("button", { name: "取消创建" }) as HTMLButtonElement | null;
expect(cancel === null || cancel.disabled).toBe(true);
expect(screen.getByRole("note").textContent).toContain("会话菜单");
expect(screen.getByRole("note").textContent).toContain("源目录");
expect(screen.getByText("已保留")).toBeTruthy();
});
it("restores editing after confirmed cancellation and exposes the retained destination", () => {
const { edit } = draw("cancelled");
fireEvent.click(screen.getByRole("button", { name: "返回编辑" }));
expect(edit).toHaveBeenCalledOnce();
expect(screen.getByText("input.txt")).toBeTruthy();
});
it("does not claim automatic submission when a late cancellation retained the workspace", () => {
draw("retained");
expect(screen.getByText("目录已经创建成功,取消未能撤回目录切换。任务尚未发送。")).toBeTruthy();
expect(screen.queryByText("即将进入对话,自动开始任务。")).toBeNull();
});
it("moves keyboard focus to the preparation heading on entry", () => {
draw("checking_out");
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
it("recovers focus when cancellation removes its button without stealing focus from details", () => {
const { rerender } = draw("checking_out");
const props = { task: "task", files: [], busy: false, onRetry: vi.fn(), onCancel: vi.fn(), onEdit: vi.fn() };
screen.getByRole("button", { name: "查看详情" }).focus();
rerender(<WorktreeSetupPage {...props} stage={status("creating")}/>);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "查看详情" }));
screen.getByRole("button", { name: "取消创建" }).focus();
rerender(<WorktreeSetupPage {...props} stage={status("cancelled")}/>);
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
it.each(["failed", "cancelled", "retained", "ready", "unreachable", "cleanup_failed"])("does not describe %s as still waiting", (phase) => {
draw(phase);
expect(screen.queryByText(/已等待 \d+ 秒/)).toBeNull();
});
it("names retained cleanup failure directly even without an error code", () => {
draw("cleanup_failed");
expect(screen.getByText("清理未完成")).toBeTruthy();
expect(screen.queryByText("正在等待状态确认")).toBeNull();
});
it("identifies details disclosure separately from workspace configuration", () => {
draw("failed");
expect(screen.getByRole("button", { name: "查看详情" }).getAttribute("data-action")).toBe("workspace.prepare-details");
expect(screen.getByRole("button", { name: "返回编辑" }).getAttribute("data-action")).toBe("workspace.prepare-edit");
});
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
import type { WorktreePreparation } from "../port/workspace";
import { StudioIcon } from "./StudioIcon";
import { CopyButton } from "./CopyButton";
import { WorkspacePath, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
import "../styles/worktree-setup.css";
const phases: Record<string, string> = {
starting: "正在提交创建请求", validating: "正在检查起始分支", creating: "正在创建工作目录",
checking_out: "正在检出已提交文件", copying_attachments: "正在复制任务附件", binding: "正在绑定工作目录",
cleaning: "正在清理未完成目录", cancelling: "正在中止并清理", ready: "工作目录已绑定",
cleanup_failed: "清理未完成", failed: "创建未完成", cancelled: "创建已中止,目录已清理",
retained: "工作目录已绑定", unreachable: "正在等待状态确认",
};
interface Props {
stage: WorktreePreparation;
task: string;
files: string[];
busy: boolean;
onRetry: () => void;
onCancel: () => void;
onEdit: () => void;
}
export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel, onEdit }: Props) {
const [now, setNow] = useState(Date.now);
const [details, setDetails] = useState(false);
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => { heading.current?.focus(); }, [stage.id]);
useEffect(() => { if (document.activeElement === document.body) heading.current?.focus(); }, [stage.phase]);
const failed = stage.phase === "failed";
const cancelled = stage.phase === "cancelled";
const retained = stage.phase === "retained";
const uncertain = stage.phase === "unreachable" || stage.phase === "cleanup_failed";
const ready = stage.phase === "ready";
const cancelling = stage.phase === "cancelling";
const active = !failed && !cancelled && !retained && !ready && !uncertain;
useEffect(() => {
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active]);
useEffect(() => { setDetails(false); setNow(Date.now()); }, [stage.id]);
const elapsed = Math.max(0, Math.floor((now - stage.startedAt) / 1000));
const slow = active && !cancelling && elapsed >= 15;
const headline = failed ? "worktree 创建失败" : cancelled ? "已取消创建" : retained || ready ? "工作目录已就绪" : uncertain ? "需要确认创建结果" : cancelling ? "正在取消创建…" : "正在创建 worktree…";
const description = failed ? "任务尚未发送,输入和附件已为你保留。" : cancelled ? "任务尚未发送,可以继续编辑。" : retained ? "目录已经创建成功,取消未能撤回目录切换。任务尚未发送。" : ready ? "即将进入对话,自动开始任务。" : uncertain ? "暂不能确认操作或清理结果,请先查看详情并核对。" : cancelling ? "正在中止操作并清理未完成的目录。" : slow ? "创建耗时较长,尚未收到完成确认。" : "准备完成后将自动开始任务。";
const error = stage.code ? reason(new HttpError(409, stage.detail || stage.code, { code: stage.code })) : "";
const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock";
const phase = error || t(phases[stage.phase] || "正在等待状态确认");
return <section className="worktree-setup" data-phase={stage.phase} aria-label={t("工作目录准备")}>
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立 worktree")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
<div className="worktree-setup-phase"><span>{active ? <span className="worktree-spinner"/> : <StudioIcon name={icon}/>}<span>{phase}</span></span>{active && <span className="worktree-setup-elapsed">{t("已等待 {n} 秒", { n: elapsed })}</span>}</div>
{slow && <p className="worktree-setup-slow" role="status">{t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}</p>}
{stage.phase === "cleanup_failed" && <p className="worktree-setup-slow" role="note">{t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}</p>}
<div className="worktree-setup-branches"><dl><dt>{t("起始分支")}</dt><dd title={stage.ref}><StudioIcon name="branch"/>{compactWorkspaceValue(stage.ref.replace(/^refs\/(heads|remotes)\//, ""), 48)}</dd><dt>{t("任务分支")}</dt><dd title={stage.branch}><StudioIcon name="branch"/>{stage.branch ? compactWorkspaceValue(stage.branch, 48) : t("自动创建")}</dd></dl></div>
<div className="worktree-setup-draft"><span className="worktree-setup-label">{t("用户任务")}</span><p className="worktree-setup-task">{task || t("请查看附件")}</p>{files.length > 0 && <div className="worktree-setup-files"><span className="worktree-setup-label">{t("附件({n})", { n: files.length })}</span>{files.map((file, index) => <div key={`${index}:${file}`}><StudioIcon name="file"/><span>{file}</span><small>{t(ready ? "待交接" : failed || cancelled || retained || uncertain ? "已保留" : "待发送")}</small></div>)}</div>}</div>
<div className="worktree-setup-location"><WorkspacePath path={failed || cancelled ? stage.sourceRoot : stage.workspaceRoot || stage.sourceRoot}/></div>
{details && <div className="worktree-setup-details"><dl><dt>{t("操作编号")}</dt><dd>{stage.id}<CopyButton text={stage.id} iconOnly label={t("复制操作编号")}/></dd><dt>{t("当前状态")}</dt><dd>{stage.phase}</dd></dl>{stage.detail && <pre>{stage.detail}</pre>}</div>}
<footer className="worktree-setup-actions"><button className="worktree-setup-detail-button" data-action="workspace.prepare-details" onClick={() => setDetails(!details)} aria-expanded={details}><StudioIcon name="file"/>{t(details ? "收起详情" : "查看详情")}</button><div>{cancelled || retained || failed ? <button className="btn" data-action="workspace.prepare-edit" disabled={busy} onClick={onEdit}>{t("返回编辑")}</button> : null}{failed || (uncertain && stage.phase !== "cleanup_failed") ? <button className="btn" data-primary="" data-action="workspace.isolate" disabled={busy} onClick={onRetry}><StudioIcon name="refresh"/>{t(failed ? "重试创建" : "再次核对")}</button> : null}{ready ? <span className="worktree-setup-handoff"><StudioIcon name="check"/>{t("任务和附件正在交接…")}</span> : active || stage.phase === "unreachable" ? <button className="btn" data-action="workspace.prepare-cancel" disabled={cancelling} onClick={onCancel}>{t(cancelling ? "正在取消…" : "取消创建")}</button> : null}</div></footer>
</section>;
}
@@ -0,0 +1,49 @@
import { t } from "../i18n";
import type { Attachment } from "../port/port";
import type { PasteChip } from "./pastekeep";
// What is riding along with this turn. An attachment travels as bytes or a path
// the kernel already saved; a long paste is held out of the box so eight hundred
// lines of log do not become the composer.
export type Chip =
| {
k: "attachment";
id: string;
name: string;
state: "adding" | "ready" | "failed";
a?: Attachment;
blob?: File;
url?: string;
error?: string;
}
| PasteChip
| { k: "quote"; id: string; body: string; turn?: number; source?: string; lines: number };
export function chipName(c: Chip): string {
if (c.k === "quote" && c.source) return t("引用 {name}", { name: c.source.split("/").at(-1) ?? c.source });
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
}
// Provider-visible quotes use the kernel's turn identity when available.
export function quoteBlock(c: Extract<Chip, { k: "quote" }>): string {
// The path is a file name the workspace chose, so it cannot end the line or close the bracket.
const where = c.source?.replace(/[\u0000-\u001f\u007f\u2028\u2029[\]]/g, " ").trim();
const head = where
? `[Quoting text selected in the workspace file ${where}.]`
: c.turn === undefined
? "[Quoting an earlier assistant reply in this conversation.]"
: `[Quoting the assistant reply from turn ${c.turn}.]`;
return [head, ...c.body.split("\n").map((line) => `> ${line}`)].join("\n");
}
export function isPicture(c: Chip): boolean {
return c.k === "attachment" && (c.a?.image === true || (c.a?.image !== false && c.blob?.type.startsWith("image/") === true));
}
export function releaseChip(c: Chip) {
if (c.k === "attachment" && c.url) URL.revokeObjectURL(c.url);
}
let chipSeq = 0;
export const chipId = () => `c${++chipSeq}`;
@@ -5,7 +5,7 @@ import "./testkit";
import { Pane } from "./Pane";
import { ToastStack } from "./ToastStack";
import { MockPort } from "../port/mock";
import { boot, STORAGE } from "../i18n";
import { boot, STORAGE, t } from "../i18n";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
import type { AgentPort } from "../port/port";
@@ -61,13 +61,14 @@ describe("the explorer during a turn", () => {
// not live.
it("updates the composer's branch chip after a turn ends", async () => {
const pane = open();
expect(await screen.findByText("main")).toBeTruthy();
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
expect(chip.textContent).toContain("main");
pane.send({ kind: "turn_started" });
await waitFor(() => expect(pane.changes).toHaveBeenCalled());
pane.port.branchState = { ...pane.port.branchState, branch: "studio" };
pane.send({ kind: "turn_done" });
await waitFor(() => expect(screen.getByText("studio")).toBeTruthy());
await waitFor(() => expect(chip.textContent).toContain("studio"));
});
});
@@ -76,7 +77,7 @@ describe("branch switch results in the pane", () => {
for (const changesFail of [false, true]) {
it(`uses the switch response while the change list ${changesFail ? "fails" : "is pending"}`, async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
const reading = vi.spyOn(pane.port, "workspaceGit").mockImplementation(() => new Promise(() => {}));
pane.changes.mockClear();
pane.changes.mockImplementation(() => changesFail
@@ -85,9 +86,9 @@ describe("branch switch results in the pane", () => {
const change = vi.spyOn(pane.port, "switchBranch");
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.getAttribute("aria-label")).toContain("studio"));
await waitFor(() => expect(chip.textContent).toContain("studio"));
expect(change).toHaveBeenCalledExactlyOnceWith("studio");
expect(pane.changes).toHaveBeenCalledTimes(1);
expect(reading).not.toHaveBeenCalled();
@@ -103,16 +104,16 @@ describe("branch switch results in the pane", () => {
describe("branch switch in flight", () => {
it("says which branch it is moving to until the switch answers, then names the branch", async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
let land: (git: Awaited<ReturnType<typeof pane.port.switchBranch>>) => void = () => {};
const done = new Promise<Awaited<ReturnType<typeof pane.port.switchBranch>>>((resolve) => { land = resolve; });
vi.spyOn(pane.port, "switchBranch").mockImplementation(() => done);
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.textContent).toContain("正在切换到 studio…"));
expect(chip.getAttribute("aria-label")).toContain("main");
expect(chip.title).toContain("main");
expect(chip.hasAttribute("data-pending")).toBe(true);
land({ ...pane.port.branchState, branch: "studio" });
@@ -122,10 +123,10 @@ describe("branch switch in flight", () => {
it("goes back to the current branch when the switch is refused", async () => {
const pane = open();
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
vi.spyOn(pane.port, "switchBranch").mockRejectedValue(new HttpError(409, "busy", { code: "branch.turn_running", error: "busy" }));
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(chip.textContent).not.toContain("正在切换"));
expect(chip.textContent).toContain("main");
});
@@ -142,16 +143,15 @@ describe("branch switch refusals in the pane", () => {
const refusal = reason(error);
expect(refusal).not.toBe("fixture fallback");
const change = vi.spyOn(pane.port, "switchBranch").mockRejectedValue(error);
const chip = await screen.findByRole("button", { name: /main/ });
const chip = await screen.findByRole("button", { name: t("配置工作目录") });
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
expect(await screen.findByText(refusal)).toBeTruthy();
expect(change).toHaveBeenCalledWith("studio");
expect(screen.queryByText("fixture fallback")).toBeNull();
await waitFor(() => expect(chip.hasAttribute("disabled")).toBe(false));
expect(chip.textContent).toContain("main");
fireEvent.click(chip);
fireEvent.click(await screen.findByRole("menuitem", { name: "studio" }));
fireEvent.click(await screen.findByRole("option", { name: "studio" }));
await waitFor(() => expect(change).toHaveBeenCalledTimes(2));
});
}
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { useWorkspaceBranches } from "./useWorkspaceBranches";
import type { WorkspaceGit } from "../port/port";
afterEach(cleanup);
it("releases the switch state on port change and ignores the old completion", async () => {
const old = new MockPort(), next = new MockPort();
let resolve!: (value: WorkspaceGit) => void;
vi.spyOn(old, "switchBranch").mockImplementation(() => new Promise((done) => { resolve = done; }));
const changed = vi.fn(), switched = vi.fn(), error = vi.fn();
const { result, rerender } = renderHook(({ port }) => useWorkspaceBranches(port, changed, switched, error), { initialProps: { port: old } });
act(() => result.current.switchTo("studio"));
expect(result.current.moving).toBe("studio");
rerender({ port: next });
expect(result.current.moving).toBeNull();
await act(async () => { resolve(await next.workspaceGit()); });
expect(changed).not.toHaveBeenCalled();
expect(switched).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceBranch, WorkspaceGit } from "../port/port";
export function useWorkspaceBranches(port: AgentPort, onChanged: () => void, onSwitched: ((git: WorkspaceGit) => void) | undefined, onError: (error: unknown) => void, root = "") {
const [locals, setLocals] = useState<WorkspaceBranch[]>([]);
const [moving, setMoving] = useState<string | null>(null);
const [reading, setReading] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const generation = useRef(0);
useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); return () => { generation.current++; }; }, [port, root]);
const loadBranches = useCallback(() => {
const current = generation.current;
setReading("loading");
void port.branches().then((result) => { if (current === generation.current) { setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { setReading("failed"); onError(error); } });
}, [port, onError, root]);
const switchTo = (name: string) => {
if (moving !== null) return;
const current = generation.current;
setMoving(name);
void port.switchBranch(name).then((git) => {
if (current !== generation.current) return;
onChanged(); onSwitched?.(git);
}).catch((error) => { if (current === generation.current) onError(error); })
.finally(() => { if (current === generation.current) setMoving(null); });
};
return { locals, moving, reading, loadBranches, switchTo };
}
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { useWorktreeSetup } from "./useWorktreeSetup";
import type { WorktreePreparation, WorktreeSources } from "../port/workspace";
afterEach(cleanup);
const selection = { sourceRoot: "/source", ref: "refs/heads/main" };
const state = (id: string, phase: string): WorktreePreparation => ({ id, phase, ...selection, startedAt: Date.now(), workspaceRoot: "/target", branch: "task" });
const sources = (name: string, root = "/source"): WorktreeSources => ({ available: true, sourceRoot: root, sources: [{ ref: `refs/heads/${name}`, name, remote: false, current: true }] });
it.each(["root", "port", "session"])("does not publish an old explicit source refresh after %s changes", async (change) => {
const old = new MockPort(), next = new MockPort();
const nextRoot = change === "root" ? "/target" : "/source";
const read = vi.spyOn(old, "worktreeSources").mockResolvedValue(sources("before"));
vi.spyOn(next, "worktreeSources").mockResolvedValue(sources("after", nextRoot));
const { result, rerender } = renderHook(({ port, root, session }) => useWorktreeSetup(port, true, root, vi.fn(), undefined, session),
{ initialProps: { port: old, root: "/source", session: "before" } });
await waitFor(() => expect(result.current.sources?.sources[0].name).toBe("before"));
let resolve!: (value: WorktreeSources) => void;
read.mockImplementationOnce(() => new Promise((done) => { resolve = done; })).mockResolvedValue(sources("after", nextRoot));
let refresh!: Promise<WorktreeSources>;
act(() => { refresh = result.current.refresh(); });
rerender({ port: change === "port" ? next : old, root: nextRoot, session: change === "session" ? "after" : "before" });
await waitFor(() => expect(result.current.sources?.sources[0].name).toBe("after"));
await act(async () => { resolve(sources("stale")); await refresh; });
expect(result.current.sources?.sources[0].name).toBe("after");
});
it("keeps a manual source refresh when an older initial read completes later", async () => {
const port = new MockPort();
let resolve!: (value: WorktreeSources) => void;
vi.spyOn(port, "worktreeSources").mockImplementationOnce(() => new Promise((done) => { resolve = done; })).mockResolvedValue(sources("current"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { await result.current.refresh(); });
await act(async () => { resolve(sources("stale")); });
expect(result.current.sources?.sources[0].name).toBe("current");
});
it("cancels an old port and never hands its completed workspace to the new pane", async () => {
const old = new MockPort(), next = new MockPort();
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(old, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
const cancel = vi.spyOn(old, "cancelWorktreePreparation").mockImplementation(async (id) => state(id, "cancelled"));
const changed = vi.fn();
const { result, rerender } = renderHook(({ port }) => useWorktreeSetup(port, true, "/source", changed), { initialProps: { port: old } });
await act(async () => { result.current.setSelection(selection); });
let preparation!: Promise<boolean>;
act(() => { preparation = result.current.prepare([]); });
rerender({ port: next });
await act(async () => { resolve(state(start.mock.calls[0][0], "ready")); expect(await preparation).toBe(false); });
expect(cancel).toHaveBeenCalledWith(start.mock.calls[0][0]);
expect(changed).not.toHaveBeenCalled();
expect(result.current.stage).toBeNull();
});
it("keeps a published workspace but does not send when cancellation loses publication", async () => {
const port = new MockPort();
let resolve!: (value: WorktreePreparation) => void;
const start = vi.spyOn(port, "prepareWorktree").mockImplementation(() => new Promise((done) => { resolve = done; }));
vi.spyOn(port, "cancelWorktreePreparation").mockImplementation(async (id) => state(id, "ready"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { result.current.setSelection(selection); });
let preparation!: Promise<boolean>;
act(() => { preparation = result.current.prepare([]); });
await act(async () => { await result.current.cancel(); });
await act(async () => { resolve(state(start.mock.calls[0][0], "ready")); expect(await preparation).toBe(false); });
expect(result.current.stage?.phase).toBe("retained");
});
it("does not create a replacement when the first operation cannot be reconciled", async () => {
const port = new MockPort();
const start = vi.spyOn(port, "prepareWorktree").mockRejectedValue(new Error("lost acknowledgement"));
const read = vi.spyOn(port, "worktreePreparation").mockRejectedValue(new Error("offline"));
const { result } = renderHook(() => useWorktreeSetup(port, true, "/source", vi.fn()));
await act(async () => { result.current.setSelection(selection); });
await act(async () => { await expect(result.current.prepare([])).rejects.toThrow(); });
await waitFor(() => expect(result.current.stage?.phase).toBe("unreachable"));
await act(async () => { await expect(result.current.prepare([])).rejects.toThrow(); });
expect(start).toHaveBeenCalledOnce();
expect(read.mock.calls.every(([id]) => id === start.mock.calls[0][0])).toBe(true);
});
+122 -31
View File
@@ -1,47 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { reason } from "../i18n/kernel";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceGit } from "../port/port";
import type { WorktreeSelection, WorktreeSources } from "../port/workspace";
import type { WorktreePreparation, WorktreeSelection, WorktreeSources } from "../port/workspace";
export function useWorktreeSetup(port: AgentPort, fresh: boolean, root: string, onChanged: () => void, onTreeChanged?: (git: WorkspaceGit) => void) {
export class WorktreeSetupFailure extends Error {}
interface Attempt {
id: string;
port: AgentPort;
selection: WorktreeSelection;
session: string;
status: WorktreePreparation;
valid: boolean;
cancelRequested: boolean;
}
const terminal = (phase: string) => ["ready", "failed", "cancelled", "cleanup_failed"].includes(phase);
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export function useWorktreeSetup(port: AgentPort, fresh: boolean, root: string, onChanged: () => void, onTreeChanged?: (git: WorkspaceGit) => void, session = "") {
const [sources, setSources] = useState<WorktreeSources | null>(null);
const [selection, setSelection] = useState<WorktreeSelection | null>(null);
const prepared = useRef(false);
useEffect(() => {
let live = true;
setSources(null);
setSelection(null);
prepared.current = false;
if (fresh) void port.worktreeSources().then((value) => {
if (live) setSources(value);
}).catch(() => { if (live) setSources(null); });
return () => { live = false; };
}, [port, fresh, root]);
const refresh = async () => {
const [stage, setStage] = useState<WorktreePreparation | null>(null);
const attempt = useRef<Attempt | null>(null);
const prepared = useRef("");
const mounted = useRef(true);
const sourceRead = useRef(0);
const refresh = useCallback(async () => {
const read = ++sourceRead.current;
const value = await port.worktreeSources();
setSources(value);
if (mounted.current && read === sourceRead.current) setSources(value);
return value;
}, [port]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
const current = attempt.current;
if (current) {
current.valid = false;
if (!terminal(current.status.phase)) void current.port.cancelWorktreePreparation(current.id).catch(() => {});
}
};
}, [port]);
useEffect(() => {
sourceRead.current++;
const current = attempt.current;
const ownMove = current && root !== current.selection.sourceRoot && root === current.status.workspaceRoot;
const same = current?.port === port && ((root === current.selection.sourceRoot && session === current.session) || ownMove);
if (!same) {
if (current) {
current.valid = false;
if (!terminal(current.status.phase)) void current.port.cancelWorktreePreparation(current.id).catch(() => {});
}
attempt.current = null;
setStage(null);
prepared.current = "";
setSelection(null);
setSources(null);
}
if (fresh && !same) void refresh().catch(() => {});
return () => { sourceRead.current++; };
}, [port, fresh, root, session, refresh]);
const show = (current: Attempt, value: WorktreePreparation) => {
current.status = value;
if (mounted.current && current.valid && attempt.current === current) setStage(value);
};
const prepare = async (attachments: string[]) => {
if (!selection || prepared.current) return;
if (prepared.current === root && root) return true;
if (!selection && !attempt.current) return true;
let current = attempt.current;
const reconcile = current && !["failed", "cancelled"].includes(current.status.phase);
if (!reconcile) {
if (!selection) throw new WorktreeSetupFailure("worktree source is unavailable");
const id = crypto.randomUUID();
current = { id, port, selection: attachments.length ? { ...selection, attachments } : selection, session, valid: true, cancelRequested: false,
status: { id, phase: "starting", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() } };
attempt.current = current;
}
if (!current) return false;
const active = current;
show(active, active.status);
try {
await port.isolateWorkspace(attachments.length ? { ...selection, attachments } : selection);
} catch (error) {
const current = await port.status().catch(() => null);
if (current?.workspaceRoot && current.workspaceRoot !== selection.sourceRoot) {
prepared.current = true;
let value: WorktreePreparation;
try {
value = reconcile ? await active.port.worktreePreparation(active.id) : await active.port.prepareWorktree(active.id, active.selection);
} catch {
value = await active.port.worktreePreparation(active.id);
}
while (active.valid && mounted.current) {
show(active, value);
if (terminal(value.phase)) break;
await pause(400);
if (!active.valid || !mounted.current) return false;
value = await active.port.worktreePreparation(active.id);
}
if (!active.valid || !mounted.current) return false;
if (value.phase === "ready" && value.workspaceRoot) {
prepared.current = value.workspaceRoot;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
void active.port.workspaceGit().then(onTreeChanged).catch(() => {});
if (active.cancelRequested) { show(active, { ...value, phase: "retained" }); return false; }
await pause(650);
return active.valid && mounted.current && !active.cancelRequested;
}
throw error;
if (value.phase === "cancelled") return false;
throw new WorktreeSetupFailure(value.detail || value.code || "worktree creation failed");
} catch (error) {
if (active.valid && mounted.current && !(error instanceof WorktreeSetupFailure)) {
show(active, { ...active.status, phase: "unreachable", detail: reason(error) });
}
throw error instanceof WorktreeSetupFailure ? error : new WorktreeSetupFailure("worktree status could not be reconciled");
}
prepared.current = true;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
};
return { sources, selection, setSelection, refresh, prepare };
const cancel = async () => {
const current = attempt.current;
if (!current) return;
current.cancelRequested = true;
show(current, { ...current.status, phase: "cancelling" });
try {
const value = await current.port.cancelWorktreePreparation(current.id);
show(current, value.phase === "ready" ? { ...value, phase: "retained" } : value);
} catch (error) {
show(current, { ...current.status, phase: "unreachable", detail: reason(error) });
}
};
const dismiss = () => {
const current = attempt.current;
if (current && !["ready", "retained", "failed", "cancelled"].includes(current.status.phase)) return;
setStage(null);
if (current?.status.phase === "failed" || current?.status.phase === "cancelled") attempt.current = null;
if (fresh && prepared.current !== root) void refresh().catch(() => {});
};
return { sources, selection, setSelection, refresh, prepare, stage, cancel, dismiss };
}
@@ -5,23 +5,25 @@ import { Composer } from "./Composer";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function draw(fresh = true) {
function draw(fresh = true, branch = "main", isolated = false) {
const port = new MockPort();
const readSources = port.worktreeSources.bind(port);
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await readSources(), sourceRoot: "/work/demo" }));
vi.spyOn(port, "status").mockResolvedValue({ workspaceRoot: "~/projects/DeepSeek-Reasonix" } as SessionStatus);
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated });
const isolate = vi.spyOn(port, "isolateWorkspace").mockResolvedValue();
const submit = vi.fn(async () => true);
const error = vi.fn();
const status = { workspaceRoot: "/work/demo", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", toolApprovalMode: "ask", plan: false } as SessionStatus;
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch: "main", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
return { port, isolate, submit, error };
}
async function choose() {
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(screen.getByRole("switch", { name: "新建 worktree" }));
fireEvent.click(screen.getByRole("button", { name: /起始分支/ }));
fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "studio" } });
fireEvent.click(screen.getByRole("option", { name: /studio/ }));
fireEvent.keyDown(window, { key: "Escape" });
@@ -37,7 +39,7 @@ it("selects a source without switching the checkout and creates only when sendin
expect(switchBranch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledWith("添加搜索功能"));
expect(isolate).toHaveBeenCalledExactlyOnceWith({ ref: "refs/heads/studio", sourceRoot: "~/projects/DeepSeek-Reasonix" });
expect(isolate).toHaveBeenCalledExactlyOnceWith({ ref: "refs/heads/studio", sourceRoot: "/work/demo" });
expect(isolate.mock.invocationCallOrder[0]).toBeLessThan(submit.mock.invocationCallOrder[0]);
});
@@ -46,9 +48,9 @@ it("restores the draft on creation failure and retries without submitting in the
isolate.mockRejectedValueOnce(new Error("disk full"));
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("添加搜索功能"));
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
expect(submit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
fireEvent.click(screen.getByRole("button", { name: "重试创建" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(isolate).toHaveBeenCalledTimes(2);
});
@@ -58,15 +60,134 @@ it("reuses the prepared worktree when the first submission is refused", async ()
submit.mockResolvedValueOnce(false);
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("添加搜索功能"));
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledTimes(2));
expect(isolate).toHaveBeenCalledOnce();
});
it("retains the branch switcher for existing conversations", async () => {
it("keeps workspace configuration and branch switching in existing conversations", async () => {
const { port } = draw(false);
expect(screen.queryByRole("button", { name: "配置工作目录" })).toBeNull();
expect(screen.getByRole("button", { name: /当前 Git 分支/ }).closest(".turntools")).not.toBeNull();
expect(vi.spyOn(port, "worktreeSources")).not.toHaveBeenCalled();
const move = vi.spyOn(port, "switchBranch");
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
expect(trigger.closest(".turntools")).not.toBeNull();
fireEvent.click(trigger);
expect(screen.getByLabelText("查看完整路径")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull();
fireEvent.click(await screen.findByRole("option", { name: /studio/ }));
await waitFor(() => expect(move).toHaveBeenCalledWith("studio"));
});
it("shows a completed worktree's actual identity, full branch and path", async () => {
const branch = "reasonix/delivery-20261017-170557-387acbfc21";
draw(false, branch, true);
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
await waitFor(() => expect(trigger.textContent).toContain("独立 worktree"));
expect(trigger.title).toBe(branch);
fireEvent.click(trigger);
expect(screen.getByLabelText("查看完整路径")).toBeTruthy();
expect(screen.getByRole("searchbox", { name: "搜索分支" })).toBeTruthy();
expect(screen.getByText("选择后立即切换,同目录会话也会受影响")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull();
});
it("shows real preparation state and restores the draft only after confirmed cancellation", async () => {
const { port, submit } = draw();
let phase = "checking_out";
const reading = { id: "", phase, sourceRoot: "~/projects/DeepSeek-Reasonix", ref: "refs/heads/studio", startedAt: Date.now(), branch: "reasonix/task", workspaceRoot: "/isolated/project" };
vi.spyOn(port, "prepareWorktree").mockImplementation(async (id) => ({ ...reading, id }));
vi.spyOn(port, "worktreePreparation").mockImplementation(async (id) => ({ ...reading, id, phase }));
const cancel = vi.spyOn(port, "cancelWorktreePreparation").mockImplementation(async (id) => { phase = "cancelled"; return { ...reading, id, phase }; });
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await screen.findByRole("heading", { name: "正在创建 worktree…" });
expect(screen.getByText("正在检出已提交文件")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "取消创建" }));
await screen.findByRole("heading", { name: "已取消创建" });
await waitFor(() => expect((screen.getByLabelText("任务输入") as HTMLTextAreaElement).value).toBe("添加搜索功能"));
expect(cancel).toHaveBeenCalledOnce();
expect(submit).not.toHaveBeenCalled();
});
it("queries a lost acknowledgement before retrying and submits once in the prepared workspace", async () => {
const { port, submit } = draw();
const start = vi.spyOn(port, "prepareWorktree").mockRejectedValue(new Error("connection lost"));
const read = vi.spyOn(port, "worktreePreparation").mockImplementation(async (id) => ({ id, phase: "ready", sourceRoot: "~/projects/DeepSeek-Reasonix", ref: "refs/heads/studio", startedAt: Date.now(), branch: "reasonix/task", workspaceRoot: "/isolated/project" }));
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(start).toHaveBeenCalledOnce();
expect(read).toHaveBeenCalledWith(start.mock.calls[0][0]);
});
it("offers local branch switching while worktree mode is off", async () => {
const { port } = draw();
const move = vi.spyOn(port, "switchBranch");
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(await screen.findByRole("option", { name: /studio/ }));
await waitFor(() => expect(move).toHaveBeenCalledWith("studio"));
});
it("defaults a new worktree to the actual branch after an earlier branch switch", async () => {
draw(true, "studio");
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" }));
expect(screen.getByRole("option", { name: "studio" }).getAttribute("aria-selected")).toBe("true");
});
it("searches branches immediately and prevents switching an occupied branch", async () => {
const { port } = draw(false);
const move = vi.spyOn(port, "switchBranch");
vi.spyOn(port, "branches").mockResolvedValue({ repo: true, branches: [{ name: "main", current: true }, { name: "studio", current: false, worktree: "/other/tree" }] });
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
const occupied = await screen.findByRole("option", { name: "studio" });
expect((occupied as HTMLButtonElement).disabled).toBe(true);
expect(occupied.title).toContain("/other/tree");
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "studio" } });
expect(screen.queryByRole("option", { name: "main" })).toBeNull();
fireEvent.click(occupied);
expect(move).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "missing" } });
expect(screen.getByText("没有匹配的分支")).toBeTruthy();
});
it("reveals and copies the full path without expanding it by default", async () => {
draw(false);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
const disclosure = screen.getByLabelText("查看完整路径").closest("details")!;
expect(disclosure.open).toBe(false);
fireEvent.click(screen.getByLabelText("查看完整路径"));
expect(disclosure.open).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "复制完整路径" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("/work/demo"));
vi.unstubAllGlobals();
});
it("recovers a failed branch read without treating it as an empty search", async () => {
const { port } = draw(false);
vi.spyOn(port, "branches").mockRejectedValueOnce(new Error("offline"));
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
await screen.findByRole("alert");
expect(screen.queryByText("没有匹配的分支")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
await screen.findByRole("option", { name: "studio" });
});
it("skips occupied branches with the keyboard and returns focus on Escape", async () => {
const { port } = draw(false);
vi.spyOn(port, "branches").mockResolvedValue({ repo: true, branches: [{ name: "main", current: true }, { name: "blocked", current: false, worktree: "/other" }, { name: "studio", current: false }] });
const trigger = await screen.findByRole("button", { name: "配置工作目录" });
fireEvent.click(trigger);
await screen.findByRole("option", { name: "main" });
const input = screen.getByRole("searchbox");
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByRole("option", { name: "main" }));
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByRole("option", { name: "studio" }));
fireEvent.keyDown(window, { key: "Escape" });
expect(document.activeElement).toBe(trigger);
expect(screen.queryByRole("dialog")).toBeNull();
});
+142
View File
@@ -0,0 +1,142 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-17
---
# Workspace preparation / 工作目录准备
## User flow / 用户流程
In a new conversation, **Configure workspace** offers **Use current folder**
and **New worktree**. The current-folder branch picker switches local branches
immediately through the existing guarded branch API. Branches checked out in
another worktree remain visible with their occupied path and cannot be selected.
新会话中的「配置工作目录」提供「使用当前目录」和「新建 worktree」。当前目录的
分支选择器通过既有受保护接口立即切换本地分支;其他 worktree 占用的分支显示
占用路径,并禁止切换。
The same entry stays available after the first message and inside an existing
worktree. Its panel shows the full branch and folder path, and keeps branch
switching available. Creating another worktree starts from a new source session.
首条消息发送后和已有 worktree 内仍保留同一入口。面板显示完整分支名、目录路径,
并继续支持切换分支。需要另建 worktree 时,从源目录新会话开始。
The palette exposes search and branch choices immediately. Full paths are
collapsed by default and can be revealed or copied. Typography uses the existing
UI, metadata and helper tokens, shared with the preparation states.
面板直接展示搜索与分支列表,完整路径默认折叠,可展开或复制。创建、成功、失败和
取消页统一使用现有正文、元信息、辅助说明字号;完整诊断按需展开。
Worktree mode selects a local or remote-tracking starting ref without switching
the source checkout. Sending starts preparation from its committed version.
The preparation screen retains the task and attachment names, shows actual
host phases, and exposes details and cancellation.
Only a ready workspace can
receive the original task. Failure and cancellation preserve the draft.
worktree 模式可以选择本地或远程跟踪分支作为起点,不会切换原目录。发送消息时
基于起点的已提交版本创建目录。准备页面保留任务和附件名称,显示后端真实阶段,
提供详情和取消。目录就绪后才发送原任务;失败或取消时保留草稿。
## Operation contract / 操作协议
| Endpoint | Behavior / 行为 |
| --- | --- |
| `POST /workspace/prepare` | `{id, selection:{sourceRoot, ref, attachments?}}`; return current operation / 返回当前操作 |
| `GET /workspace/prepare/{id}` | Reconcile the same operation / 核对同一操作 |
| `POST /workspace/prepare/{id}/cancel` | Request cancellation; return current state / 请求取消并返回当前状态 |
The operation ID is generated before sending. An identical repeated POST returns
the same result; a different payload with the same ID is rejected.
A lost POST
acknowledgement is reconciled by ID before any new creation is allowed. The
per-runtime registry retains the latest 32 completed attempts in memory.
Unknown
IDs after restart or eviction require explicit reconciliation; the client never
silently creates a replacement.
发送前生成操作 ID。同一 ID 和相同参数重复提交返回相同结果;同一 ID 的不同参数
会被拒绝。创建请求响应丢失时先按 ID 核对,不会静默重复创建。每个运行时在内存中
保留最近 32 次已完成操作;重启或淘汰后未知的 ID 需要人工核对。
Host phases include `validating`, `creating`, `checking_out`,
`copying_attachments`, `binding`, `cleaning`, and `cancelling`. Terminal phases
are `ready`, `failed`, `cancelled`, and `cleanup_failed`.
Error identities use
typed `workspace.*` codes. The frontend additionally represents unreachable
status and a published workspace retained after a late cancellation.
后端阶段包括校验、创建、检出、复制附件、绑定、清理和取消中;终态包括成功、失败、
已取消和清理失败。错误通过 `workspace.*` 类型码传递。前端另行展示状态不可达,
以及取消晚于目录绑定时保留已创建目录的状态。
## Ownership and bounds / 所有权与时限
Cancellation and runtime publication share one commit boundary. Cancellation
before publication stops construction and rolls back only the unpublished
worktree and its unchanged task branch. `cancelled` is acknowledged only after
cleanup.
If publication wins, the workspace stays ready; a user cancellation
still prevents automatic task submission. Cleanup uncertainty blocks replacement
creation and exposes diagnostics instead of claiming success.
取消与运行时发布共用一个提交边界。发布前取消会停止构建,只回收尚未发布的
worktree 及其未变化的任务分支;清理完成后才确认「已取消」。
发布先完成时保留
已绑定目录,但用户取消仍会阻止自动发送任务。无法确认清理结果时禁止重复创建,
并展示诊断信息。
The UI shows a long-wait notice after 15 seconds without inventing a percentage.
The host operation is bounded to 10 minutes; checkout commands to 5 minutes;
rollback to 1 minute. A timed-out operation becomes retryable only after cleanup.
Git cancellation terminates the checkout process group. Runtime assembly follows
request cancellation until successful construction, then transfers its lifetime
to the controller. Changing panes or workspaces invalidates old client attempts.
等待超过 15 秒展示长等待提示,不伪造百分比。后端操作最长 10 分钟,Git 检出最长
5 分钟,回收最长 1 分钟;超时清理完成后才允许重试。取消 Git 操作会终止检出进程组。
运行时构建阶段响应请求取消,成功后生命周期转交给控制器。切换页面或工作区会
使旧前端操作失效。
## Cleanup recovery / 清理失败后的恢复
`cleanup_failed` deliberately blocks another preparation in the same runtime.
1. Save the task and attachments, and reveal or copy the retained path.
2. Reconcile that directory and task branch using Git in a terminal. Preserve
any external changes.
3. Once reconciled, choose **Close conversation** from its menu and start a new
conversation in the source folder to retry. Closing retires the old runtime
and its operation registry.
`cleanup_failed` 会阻止同一运行时再次创建目录。先保存任务与附件,展开或复制保留
目录的路径,再在终端使用 Git 核对该目录与任务分支,保留外部产生的修改。确认处理
完成后,在会话菜单中选择「关闭会话」,再从源目录新建会话重试;关闭会话会退役旧
运行时及其操作记录。
## Regression evidence / 回归验证
Regression coverage lives in `workspace_preparation_test.go`,
`workspace_prepare_test.go`, `build_lifetime_test.go`, worktree `cancel_test.go`,
and the frontend worktree composer and hook tests.
These cover publication versus
cancellation, cleanup before acknowledgement, retry after failure, lost
acknowledgements, real checkout descendant termination, and stale pane results.
上述测试覆盖发布与取消的先后顺序、清理后确认、失败重试、响应丢失、真实 Git 检出
子进程终止,以及旧页面结果失效。真实浏览器验证使用临时仓库与独立 Reasonix
配置目录,模型回复由本地测试服务提供;不代表真实模型或发布安装包已通过验证。
+16 -2
View File
@@ -40,6 +40,7 @@ import (
// what the earlier ones settled and adds its own; none reaches back.
type builder struct {
ctx context.Context
closeLifetime context.CancelFunc
opts Options
timer *phaseTimer
owner *extension.RuntimeOwner
@@ -110,9 +111,16 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
b.timer.observe = opts.OnPhase
// The runtime outlives the request that built it (Studio opens a pane with
// one), and its MCP servers and sidecars start on that context later.
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(context.WithoutCancel(ctx), opts)
life, detach, closeLifetime := constructionLifetime(ctx)
b.closeLifetime = closeLifetime
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(life, opts)
defer b.retireUnownedSidecars()
delivered := false
defer func() {
if !delivered {
closeLifetime()
}
}()
if b.owner.Notices != nil && b.opts.Sink != nil {
window := b.owner.Notices.Begin(b.opts.Sink)
b.opts.Sink = window
@@ -131,6 +139,12 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
b.tools.candidates.bind(ctrl)
b.tools.isolation.bind(ctrl)
res, err := b.freeze(ctrl)
if err == nil {
if cancelErr := detach(); cancelErr != nil {
ctrl.Close()
return nil, cancelErr
}
}
delivered = err == nil
return res, err
}
@@ -562,7 +576,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
Hooks: t.hookRunner,
Memory: b.prompt.memory,
// Read at Close time: freeze chains the extension runtime set onto it.
Cleanup: func() { b.cleanup() },
Cleanup: func() { defer b.closeLifetime(); b.cleanup() },
Balance: opts.BalanceStore.Cache(b.keyedClient, entry.BalanceURL, entry.APIKey()),
Feedback: control.FeedbackOptions{Service: feedbackService(b.roots.Home(), b.proxy, opts.AccountsURL), ProviderKind: feedbackProviderKind(entry), Surface: opts.FeedbackSurface},
Jobs: b.session.jobs,
+18
View File
@@ -0,0 +1,18 @@
package boot
import "context"
// constructionLifetime relays request cancellation until successful assembly;
// the controller's cleanup owns the detached lifetime afterward.
func constructionLifetime(request context.Context) (context.Context, func() error, context.CancelFunc) {
life, cancel := context.WithCancel(context.WithoutCancel(request))
stop := context.AfterFunc(request, cancel)
detach := func() error {
if !stop() || request.Err() != nil {
cancel()
return request.Err()
}
return nil
}
return life, detach, func() { stop(); cancel() }
}
@@ -0,0 +1,46 @@
package boot
import (
"context"
"errors"
"testing"
"time"
)
func TestBuildCancellationBeforePublication(t *testing.T) {
_, sink := buildNoticeFixture(t, "")
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctrl, err := Build(ctx, Options{Sink: sink, OnPhase: func(phase Phase) {
if phase.Name == "config" {
cancel()
}
}})
if ctrl != nil {
ctrl.Close()
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled construction returned controller=%v err=%v", ctrl != nil, err)
}
}
func TestConstructionLifetimeDetachesOnlyAfterSuccess(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
life, detach, closeLifetime := constructionLifetime(ctx)
defer closeLifetime()
if err := detach(); err != nil {
t.Fatal(err)
}
cancel()
select {
case <-life.Done():
t.Fatal("published lifetime died with its request")
default:
}
closeLifetime()
select {
case <-life.Done():
case <-time.After(time.Second):
t.Fatal("runtime close did not retire its lifetime")
}
}
+11 -3
View File
@@ -147,9 +147,17 @@ func (s *lazySpawn) run() {
s.broadcastReady()
// Save cache outside the critical path of tool dispatch. Register the write
// before this deferred spawn ends so Host.Close observes and drains it.
s.host.queueBackgroundWrite(func() {
saveLazyCachedSchema(s.spec, cacheTools, s.host.serverInstructions(s.spec.Name), s.host.cachedCapabilities(s.spec.Name, len(cacheTools) > 0))
})
s.host.queueBackgroundWrite(s.cacheWrite(cacheTools))
}
func (s *lazySpawn) cacheWrite(real []tool.Tool) func() {
// Capture the live handshake before this spawn releases deferredWG. Close
// may clear the clients before the queued persistence callback can run.
instructions := s.host.serverInstructions(s.spec.Name)
capabilities := s.host.cachedCapabilities(s.spec.Name, len(real) > 0)
return func() {
saveLazyCachedSchema(s.spec, real, instructions, capabilities)
}
}
func saveLazyCachedSchema(spec Spec, real []tool.Tool, instructions string, capabilities map[string]bool) {
@@ -0,0 +1,20 @@
package plugin
import "testing"
func TestLazyCacheWriteRetainsHandshakeAfterHostClose(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
spec := Spec{Name: "docs", Type: "http", URL: "https://example.test/mcp"}
host := resourceHost(&resourceTransport{}, true)
host.clients[0].hasTools = true
host.clients[0].hasPrompts = true
host.clients[0].instructions = "Declared instructions"
spawn := &lazySpawn{host: host, spec: spec}
write := spawn.cacheWrite(nil)
host.Close()
write()
cache, ok := LoadCachedSchemaForSpec(spec)
if !ok || !cache.Capabilities["resources"] || !cache.Capabilities["prompts"] || !cache.Capabilities["tools"] || cache.Instructions != "Declared instructions" {
t.Fatalf("shutdown changed the cached handshake: %+v", cache)
}
}
-57
View File
@@ -429,63 +429,6 @@ func (h *Hub) Open(ctx context.Context, req OpenRequest) (*Runtime, error) {
return rt, nil
}
// errRuntimeGone is Close's answer for an id the hub does not hold.
var errRuntimeGone = errors.New("no such runtime")
// Close retires a runtime: the conversation is persisted, the assembly torn
// down, and the session file released for another window to open.
func (h *Hub) Close(id string) error {
h.mu.Lock()
rt := h.runtimes[id]
if rt == nil {
h.mu.Unlock()
return fmt.Errorf("%w: %s", errRuntimeGone, id)
}
delete(h.runtimes, id)
h.order = removeString(h.order, id)
h.mu.Unlock()
defer h.treeChanged()
defer h.muxes.paneClosed(id)
if rt.Local() {
rt.Server.bindMu.Lock()
defer rt.Server.bindMu.Unlock()
}
if h.opts.OnClose != nil {
h.opts.OnClose(rt)
}
if rt.stop != nil {
rt.stop()
}
if rt.remote != nil {
// Retired before the forward that reaches it goes away: releasing first
// would strand a session lease over there, with nothing left that could
// reach the kernel holding it.
rt.closeFarRuntime()
if rt.remote.release != nil {
rt.remote.release()
}
return nil
}
if err := rt.Server.Controller().Snapshot(); err != nil {
slog.Warn("serve: snapshot before closing runtime", "id", id, "err", err)
}
rt.Server.Controller().Close()
if rt.leases != nil {
rt.leases.Release()
}
return nil
}
// Shutdown closes every runtime, newest first.
func (h *Hub) Shutdown() {
for _, view := range h.List() {
if err := h.Close(view.ID); err != nil {
slog.Warn("serve: close runtime", "id", view.ID, "err", err)
}
}
}
// Get returns a runtime by ID, or nil.
func (h *Hub) Get(id string) *Runtime {
h.mu.RLock()
+63
View File
@@ -0,0 +1,63 @@
package serve
import (
"errors"
"fmt"
"log/slog"
)
// errRuntimeGone is Close's answer for an id the hub does not hold.
var errRuntimeGone = errors.New("no such runtime")
// Close retires a runtime: the conversation is persisted, the assembly torn
// down, and the session file released for another window to open.
func (h *Hub) Close(id string) error {
h.mu.Lock()
rt := h.runtimes[id]
if rt == nil {
h.mu.Unlock()
return fmt.Errorf("%w: %s", errRuntimeGone, id)
}
delete(h.runtimes, id)
h.order = removeString(h.order, id)
h.mu.Unlock()
defer h.treeChanged()
defer h.muxes.paneClosed(id)
if rt.Local() {
rt.Server.preparations.close()
rt.Server.bindMu.Lock()
defer rt.Server.bindMu.Unlock()
}
if h.opts.OnClose != nil {
h.opts.OnClose(rt)
}
if rt.stop != nil {
rt.stop()
}
if rt.remote != nil {
// Releasing the forward first would strand the far session's write lease.
rt.closeFarRuntime()
if rt.remote.release != nil {
rt.remote.release()
}
return nil
}
if err := rt.Server.Controller().Snapshot(); err != nil {
slog.Warn("serve: snapshot before closing runtime", "id", id, "err", err)
}
rt.Server.Controller().Close()
if rt.leases != nil {
rt.leases.Release()
}
return nil
}
// Shutdown closes every runtime, newest first.
func (h *Hub) Shutdown() {
for _, view := range h.List() {
if err := h.Close(view.ID); err != nil {
slog.Warn("serve: close runtime", "id", view.ID, "err", err)
}
}
}
+3
View File
@@ -102,6 +102,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /account/logout", s.accountLogout)
mux.HandleFunc("GET /workspaces", s.workspaces)
mux.HandleFunc("POST /workspace", s.workspace)
mux.HandleFunc("POST /workspace/prepare", s.startWorkspacePreparation)
mux.HandleFunc("GET /workspace/prepare/{id}", s.readWorkspacePreparation)
mux.HandleFunc("POST /workspace/prepare/{id}/cancel", s.readWorkspacePreparation)
mux.HandleFunc("GET /todos", s.todos)
mux.HandleFunc("GET /workspace/files", s.workspaceFiles)
mux.HandleFunc("GET /workspace/file", s.workspaceFileRead)
+1
View File
@@ -67,6 +67,7 @@ type Server struct {
lastBuild *boot.BuildResult // serving generation, guarded by bindMu; see reuseFromLastBuild
grants hostGrants // what the embedding host has opened up
moves moveTracker // the one storage relocation this server may be running
preparations workspacePreparations
titleModel titleProviderState
titles *titleCache
fill *titleFiller
+16 -7
View File
@@ -99,7 +99,8 @@ func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
if body.Worktree != nil {
ref = body.Worktree.Ref
}
res, err := worktree.CreateFrom(r.Context(), workspaceRepo(s.ctl()), config.DeliveryWorktreeDir(), ref)
managedRoot := config.DeliveryWorktreeDir()
res, err := worktree.CreateFrom(r.Context(), workspaceRepo(s.ctl()), managedRoot, ref)
if err != nil {
if errors.Is(err, worktree.ErrSourceUnknown) {
refuse(w, http.StatusConflict, "workspace.worktree_source_missing", "the source branch is no longer available; choose another branch", nil)
@@ -111,7 +112,7 @@ func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
repo := workspaceRepo(s.ctl())
defer func() {
if !published {
if err := worktree.DiscardUnpublished(context.WithoutCancel(r.Context()), repo, res); err != nil {
if err := worktree.DiscardUnpublished(context.WithoutCancel(r.Context()), repo, managedRoot, res); err != nil {
slog.Warn("retain unpublished worktree after rollback failure", "path", res.WorkspaceRoot, "err", err)
}
}
@@ -161,6 +162,10 @@ func resolveWorkspaceDir(dir string) (string, error) {
// it. bindMu must be held. The outgoing controller keeps serving until the
// replacement is fully built, so a failure leaves the session untouched.
func (s *Server) switchWorkspaceLocked(ctx context.Context, dir string) error {
return s.switchWorkspaceCommit(ctx, dir, func(publish func() error) error { return publish() })
}
func (s *Server) switchWorkspaceCommit(ctx context.Context, dir string, commit func(func() error) error) error {
cur := s.ctl()
if cur.WorkspaceRoot() == dir {
return nil
@@ -193,11 +198,15 @@ func (s *Server) switchWorkspaceLocked(ctx context.Context, dir string) error {
}
newCtrl.SetOnSessionRecovered(sessionLeaseRecoveryHandler(s.leases))
if err := addRememberedWorkspace(ctx, dir); err != nil {
newCtrl.Close()
return err
}
if err := s.publishController(cur, newCtrl); err != nil {
if err := commit(func() error {
if err := ctx.Err(); err != nil {
return err
}
if err := addRememberedWorkspace(ctx, dir); err != nil {
return err
}
return s.publishController(cur, newCtrl)
}); err != nil {
newCtrl.Close()
return err
}
@@ -0,0 +1,166 @@
package serve
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"sync"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/platform/worktree"
"reasonix/internal/session/control"
)
type workspacePreparations struct {
mu sync.Mutex
state preparationRegistry
}
type preparationRegistry struct {
closed bool
items map[string]preparationEntry
order []string
}
type preparationEntry struct {
selection worktreeSelection
op *control.WorkspacePreparation
}
func (s *Server) startWorkspacePreparation(w http.ResponseWriter, r *http.Request) {
if !s.workspacePreparationAllowed(w, r) {
return
}
var body struct {
ID string `json:"id"`
Selection worktreeSelection `json:"selection"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
badBody(w)
return
}
if body.ID == "" || len(body.ID) > 80 || strings.ContainsAny(body.ID, "/\\\x00\r\n") || body.Selection.Ref == "" || body.Selection.SourceRoot == "" {
refuse(w, http.StatusBadRequest, "workspace.worktree_request_invalid", "an operation id and source selection are required", nil)
return
}
p := &s.preparations
p.mu.Lock()
if old, ok := p.state.items[body.ID]; ok {
p.mu.Unlock()
if !reflect.DeepEqual(old.selection, body.Selection) {
refuse(w, http.StatusConflict, "workspace.worktree_request_invalid", "operation id belongs to another selection", nil)
return
}
writeJSON(w, old.op.Status())
return
}
if p.state.closed {
p.mu.Unlock()
refuse(w, http.StatusConflict, "workspace.worktree_stale", "the runtime has closed", nil)
return
}
for _, entry := range p.state.items {
switch entry.op.Status().Phase {
case "ready", "failed", "cancelled":
default:
p.mu.Unlock()
refuse(w, http.StatusConflict, "workspace.worktree_busy", "reconcile the existing preparation before starting another", nil)
return
}
}
if p.state.items == nil {
p.state.items = make(map[string]preparationEntry)
}
for len(p.state.order) >= 32 {
delete(p.state.items, p.state.order[0])
p.state.order = p.state.order[1:]
}
ctx, stop := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Minute)
op := control.NewWorkspacePreparation(ctx, body.ID, body.Selection.SourceRoot, body.Selection.Ref)
p.state.items[body.ID] = preparationEntry{selection: body.Selection, op: op}
p.state.order = append(p.state.order, body.ID)
previous := s.ctl()
p.mu.Unlock()
go func() {
defer stop()
s.runWorkspacePreparation(op, previous, body.Selection)
}()
writeJSON(w, op.Status())
}
func (s *Server) workspacePreparationAllowed(w http.ResponseWriter, r *http.Request) bool {
if s.grants.at(r).workspaceSwitch {
return true
}
refuse(w, http.StatusForbidden, "workspace.changing_disabled", "this server may not change its workspace", nil)
return false
}
func (s *Server) readWorkspacePreparation(w http.ResponseWriter, r *http.Request) {
if !s.workspacePreparationAllowed(w, r) {
return
}
s.preparations.mu.Lock()
entry, ok := s.preparations.state.items[r.PathValue("id")]
s.preparations.mu.Unlock()
if !ok {
refuse(w, http.StatusNotFound, "workspace.worktree_operation_unknown", "the operation cannot be reconciled; do not create a replacement automatically", nil)
return
}
if r.Method == http.MethodPost {
entry.op.Cancel()
}
writeJSON(w, entry.op.Status())
}
func (p *workspacePreparations) close() {
p.mu.Lock()
defer p.mu.Unlock()
p.state.closed = true
for _, entry := range p.state.items {
entry.op.Cancel()
}
}
func (s *Server) runWorkspacePreparation(op *control.WorkspacePreparation, previous control.SessionAPI, selection worktreeSelection) {
s.bindMu.Lock()
defer s.bindMu.Unlock()
ctx := op.Context()
if ctx.Err() != nil {
op.Finish(ctx.Err(), false)
return
}
if s.ctl() != previous || previous.WorkspaceRoot() != selection.SourceRoot || !emptyConversation(previous) || controllerHasActiveRuntimeWork(previous) {
op.Finish(errors.New("the source workspace or session has changed"), false)
return
}
repo := workspaceRepo(previous)
managedRoot := config.DeliveryWorktreeDir()
result, err := worktree.CreateFromProgress(ctx, repo, managedRoot, selection.Ref, op.Update)
if err == nil {
op.Update(worktree.Progress{Phase: "copying_attachments", Branch: result.Branch, WorkspaceRoot: result.WorkspaceRoot})
err = control.CarryDraftAttachments(selection.SourceRoot, result.WorkspaceRoot, selection.Attachments)
}
if err == nil {
op.Update(worktree.Progress{Phase: "binding"})
err = s.switchWorkspaceCommit(ctx, result.WorkspaceRoot, op.Publish)
}
cleanupFailed := false
if op.Status().Phase != "ready" && result.WorktreeRoot != "" {
op.Update(worktree.Progress{Phase: "cleaning"})
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
cleanupErr := worktree.DiscardUnpublished(cleanupCtx, repo, managedRoot, result)
cancel()
cleanupFailed = cleanupErr != nil
if cleanupFailed {
err = errors.Join(err, fmt.Errorf("worktree cleanup: %w", cleanupErr))
}
}
op.Finish(err, cleanupFailed)
}
@@ -0,0 +1,165 @@
package serve
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os/exec"
"strings"
"testing"
"time"
"reasonix/internal/session/control"
)
func startPreparation(t *testing.T, url, root, id string) control.WorkspacePreparationStatus {
t.Helper()
body, _ := json.Marshal(map[string]any{"id": id, "selection": worktreeSelection{Ref: "refs/heads/studio", SourceRoot: root}})
resp, err := http.Post(url+"/workspace/prepare", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("start: %d %s", resp.StatusCode, data)
}
var status control.WorkspacePreparationStatus
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
t.Fatal(err)
}
return status
}
func awaitPreparation(t *testing.T, url, id, phase string) control.WorkspacePreparationStatus {
t.Helper()
deadline := time.After(10 * time.Second)
for {
var status control.WorkspacePreparationStatus
fetchInto(t, url+"/workspace/prepare/"+id, &status)
if status.Phase == phase {
return status
}
select {
case <-deadline:
t.Fatalf("want %s, got %+v", phase, status)
case <-time.After(10 * time.Millisecond):
}
}
}
func TestWorkspacePreparationCancelRollsBackBeforeAcknowledgement(t *testing.T) {
s, url, root := worktreeServer(t)
old := s.Controller()
entered := make(chan struct{})
s.buildWorkspaceController = func(ctx context.Context, _, _ string) (*control.Controller, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
}
startPreparation(t, url, root, "cancel-test")
select {
case <-entered:
case <-time.After(10 * time.Second):
t.Fatal("build did not start")
}
resp, err := http.Post(url+"/workspace/prepare/cancel-test/cancel", "application/json", nil)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("cancel: %d", resp.StatusCode)
}
status := awaitPreparation(t, url, "cancel-test", "cancelled")
if s.Controller() != old {
t.Fatal("cancel replaced the source controller")
}
if status.WorkspaceRoot == "" || status.Branch == "" {
t.Fatalf("missing worktree identity: %+v", status)
}
out, err := exec.Command("git", "-C", root, "worktree", "list", "--porcelain").Output()
if err != nil || strings.Count(string(out), "worktree ") != 1 {
t.Fatalf("worktree leaked: %s %v", out, err)
}
}
func TestWorkspacePreparationRetryIDReconcilesAlreadyPublishedResult(t *testing.T) {
s, url, root := worktreeServer(t)
startPreparation(t, url, root, "ready-test")
status := awaitPreparation(t, url, "ready-test", "ready")
current := s.Controller()
repeated := startPreparation(t, url, root, "ready-test")
if repeated.WorkspaceRoot != status.WorkspaceRoot || s.Controller() != current {
t.Fatal("retry created another workspace")
}
resp, err := http.Post(url+"/workspace/prepare/ready-test/cancel", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var cancelled control.WorkspacePreparationStatus
if err := json.NewDecoder(resp.Body).Decode(&cancelled); err != nil {
t.Fatal(err)
}
if cancelled.Phase != "ready" || s.Controller() != current {
t.Fatal("late cancel rolled back a published runtime")
}
}
func TestWorkspacePreparationFailureCanRetryAfterCleanup(t *testing.T) {
s, url, root := worktreeServer(t)
old, build := s.Controller(), s.buildWorkspaceController
s.buildWorkspaceController = func(context.Context, string, string) (*control.Controller, error) {
return nil, errors.New("fixture build failure")
}
startPreparation(t, url, root, "failed-test")
awaitPreparation(t, url, "failed-test", "failed")
if s.Controller() != old {
t.Fatal("failed build replaced the source controller")
}
s.bindMu.Lock()
s.buildWorkspaceController = build
s.bindMu.Unlock()
startPreparation(t, url, root, "retry-test")
awaitPreparation(t, url, "retry-test", "ready")
out, err := exec.Command("git", "-C", root, "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/").Output()
if err != nil || len(strings.Fields(string(out))) != 1 {
t.Fatalf("failed attempt left another branch: %s %v", out, err)
}
}
func TestWorkspacePreparationCleanupFailureBlocksReplacement(t *testing.T) {
s, url, root := worktreeServer(t)
s.buildWorkspaceController = func(_ context.Context, target, _ string) (*control.Controller, error) {
cmd := exec.Command("git", "-C", target, "-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "external change")
if out, err := cmd.CombinedOutput(); err != nil {
return nil, errors.New(string(out))
}
return nil, errors.New("fixture build failure after external change")
}
startPreparation(t, url, root, "retained-test")
retained := awaitPreparation(t, url, "retained-test", "cleanup_failed")
if s.Controller().WorkspaceRoot() != root {
t.Fatal("failed build replaced the source controller")
}
body, _ := json.Marshal(map[string]any{"id": "replacement", "selection": worktreeSelection{Ref: "refs/heads/studio", SourceRoot: root}})
resp, err := http.Post(url+"/workspace/prepare", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var refusal struct{ Code string }
if err := json.NewDecoder(resp.Body).Decode(&refusal); err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusConflict || refusal.Code != "workspace.worktree_busy" {
t.Fatalf("replacement = %d %s", resp.StatusCode, refusal.Code)
}
out, err := exec.Command("git", "-C", retained.WorkspaceRoot, "log", "-1", "--format=%s").Output()
if err != nil || strings.TrimSpace(string(out)) != "external change" {
t.Fatalf("external work was not retained: %s %v", out, err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package worktree
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestCreateCancellationStopsCheckoutDescendantsAndAllowsRollback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX shell; process cancellation has platform-specific tests")
}
requireGit(t)
root := initRepo(t)
if err := os.WriteFile(filepath.Join(root, ".gitattributes"), []byte("README.md filter=wait\n"), 0o600); err != nil {
t.Fatal(err)
}
repo := opened(t, root)
for _, args := range [][]string{{"add", "."}, {"-c", "user.name=Test", "-c", "[email protected]", "commit", "-m", "filter"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git: %v %s", err, out)
}
}
fixture := t.TempDir()
started, survived := filepath.Join(fixture, "started"), filepath.Join(fixture, "survived")
script := filepath.Join(fixture, "smudge.sh")
if err := os.WriteFile(script, []byte("printf ready > \"$1\"\nsleep 3\nprintf survived > \"$2\"\ncat\n"), 0o600); err != nil {
t.Fatal(err)
}
global := filepath.Join(fixture, "gitconfig")
for key, value := range map[string]string{"filter.wait.clean": "cat", "filter.wait.smudge": fmt.Sprintf("sh %q %q %q", script, started, survived), "filter.wait.required": "true"} {
if out, err := exec.Command("git", "config", "--file", global, key, value).CombinedOutput(); err != nil {
t.Fatalf("config: %v %s", err, out)
}
}
t.Setenv("GIT_CONFIG_GLOBAL", global)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
type outcome struct {
result Result
err error
}
done := make(chan outcome, 1)
managedRoot := t.TempDir()
go func() {
result, err := CreateFromProgress(ctx, repo, managedRoot, "HEAD", nil)
done <- outcome{result, err}
}()
for {
if _, err := os.Stat(started); err == nil {
break
}
select {
case got := <-done:
t.Fatalf("creation exited before filter: %+v", got)
case <-ctx.Done():
t.Fatal("filter never started")
case <-time.After(10 * time.Millisecond):
}
}
cancel()
got := <-done
if !errors.Is(got.err, context.Canceled) || got.result.WorktreeRoot == "" {
t.Fatalf("cancelled checkout did not return cleanup ownership: %+v", got)
}
if _, err := os.Stat(survived); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("checkout descendant survived cancellation: %v", err)
}
if err := DiscardUnpublished(t.Context(), repo, managedRoot, got.result); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(got.result.WorktreeRoot); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("partial checkout retained: %v", err)
}
}
+37 -2
View File
@@ -2,7 +2,11 @@ package worktree
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"reasonix/internal/platform/gitcmd"
@@ -10,12 +14,43 @@ import (
// DiscardUnpublished rolls back a fresh worktree before any session can use it.
// Published worktrees are durable and must never be passed here.
func DiscardUnpublished(ctx context.Context, repo gitcmd.Repo, result Result) error {
// managedRoot must be the same trusted root supplied when creating result.
func DiscardUnpublished(ctx context.Context, repo gitcmd.Repo, managedRoot string, result Result) error {
relative, err := filepath.Rel(managedRoot, result.WorktreeRoot)
if err != nil || !filepath.IsLocal(relative) || relative == "." {
return fmt.Errorf("unpublished worktree is outside its managed root: %s", result.WorktreeRoot)
}
candidate := filepath.Join(managedRoot, relative)
registered, _, err := runGit(ctx, repo, "worktree", "list", "--porcelain", "-z")
if err != nil {
return err
}
root := candidate
if parent, err := filepath.EvalSymlinks(filepath.Dir(root)); err == nil {
root = filepath.Join(parent, filepath.Base(root))
}
found := false
for entry := range strings.SplitSeq(registered, "\x00") {
entry = filepath.ToSlash(entry)
if entry == "worktree "+filepath.ToSlash(root) || entry == "worktree "+filepath.ToSlash(candidate) {
found = true
}
}
if !found {
_, _, branchErr := runGit(ctx, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+result.Branch)
var exit *exec.ExitError
if errors.As(branchErr, &exit) && exit.ExitCode() == 1 {
if _, statErr := os.Lstat(candidate); errors.Is(statErr, os.ErrNotExist) {
return nil
}
}
return fmt.Errorf("unregistered partial worktree retained: %s", result.WorktreeRoot)
}
head, _, err := runGit(ctx, repo, "rev-parse", "--verify", "refs/heads/"+result.Branch)
if err != nil || strings.TrimSpace(head) != result.Head {
return fmt.Errorf("unpublished worktree branch changed; retained %s", result.WorktreeRoot)
}
if _, _, err := runGit(ctx, repo, "worktree", "remove", "--force", result.WorktreeRoot); err != nil {
if _, _, err := runGit(ctx, repo, "worktree", "remove", "--force", candidate); err != nil {
return err
}
_, _, err = runGit(ctx, repo, "update-ref", "-d", "refs/heads/"+result.Branch, result.Head)
@@ -0,0 +1,37 @@
package worktree
import (
"os"
"path/filepath"
"testing"
)
func TestDiscardUnpublishedRejectsWrongManagedBoundary(t *testing.T) {
requireGit(t)
for _, name := range []string{"outside", "root itself", "shared prefix"} {
t.Run(name, func(t *testing.T) {
repo := opened(t, initRepo(t))
managed := filepath.Join(t.TempDir(), "managed-extra")
result, err := Create(t.Context(), repo, managed)
if err != nil {
t.Fatal(err)
}
wrong := t.TempDir()
switch name {
case "root itself":
wrong = result.WorktreeRoot
case "shared prefix":
wrong = filepath.Join(filepath.Dir(managed), "managed")
}
if err := DiscardUnpublished(t.Context(), repo, wrong, result); err == nil {
t.Fatal("cleanup accepted a result outside its managed boundary")
}
if _, err := os.Stat(filepath.Join(result.WorktreeRoot, "README.md")); err != nil {
t.Fatalf("wrong-boundary cleanup touched the workspace: %v", err)
}
if err := DiscardUnpublished(t.Context(), repo, managed, result); err != nil {
t.Fatalf("owning boundary could not clean its workspace: %v", err)
}
})
}
}
+3 -2
View File
@@ -95,11 +95,12 @@ func TestCreateFromDetachedHEADAndOccupiedBranch(t *testing.T) {
if _, _, err := runGit(ctx, repo, "checkout", "--detach"); err != nil {
t.Fatal(err)
}
result, err := CreateFrom(ctx, repo, t.TempDir(), "")
managedRoot := t.TempDir()
result, err := CreateFrom(ctx, repo, managedRoot, "")
if err != nil {
t.Fatal(err)
}
if err := DiscardUnpublished(ctx, repo, result); err != nil {
if err := DiscardUnpublished(ctx, repo, managedRoot, result); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(result.WorktreeRoot); !os.IsNotExist(err) {
+30 -6
View File
@@ -1,6 +1,6 @@
// Package worktree creates durable, Git-backed workspaces for parallel
// Delivery sessions. Worktrees live under Reasonix-managed state, never inside
// the source repository, and are never deleted automatically.
// the source repository. Published worktrees are never deleted automatically.
package worktree
import (
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"reasonix/internal/base/proc"
"reasonix/internal/platform/gitcmd"
)
@@ -71,6 +72,27 @@ func Create(ctx context.Context, repo gitcmd.Repo, managedRoot string) (Result,
// CreateFrom starts an independent branch at a local or remote-tracking ref.
// An empty ref retains Create's committed-HEAD behavior.
func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, ref string) (Result, error) {
result, err := CreateFromProgress(ctx, repo, managedRoot, ref, nil)
if err != nil && result.WorktreeRoot != "" {
err = errors.Join(err, DiscardUnpublished(context.WithoutCancel(ctx), repo, managedRoot, result))
return Result{}, err
}
return result, err
}
// Progress reports only stages the worktree owner has actually entered.
type Progress struct {
Phase string
Branch string
WorkspaceRoot string
}
func CreateFromProgress(ctx context.Context, repo gitcmd.Repo, managedRoot, ref string, report func(Progress)) (Result, error) {
update := func(progress Progress) {
if report != nil {
report(progress)
}
}
info, err := inspectFrom(ctx, repo, ref)
if err != nil {
return Result{}, err
@@ -97,6 +119,8 @@ func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, ref string)
}
branch := fmt.Sprintf("reasonix/delivery-%s-%s", time.Now().Format("20060102-150405"), id)
worktreeRoot := filepath.Join(managedRoot, repoKey, id, repoBase)
result := Result{WorkspaceRoot: worktreeRoot, WorktreeRoot: worktreeRoot, SourceRoot: info.RepoRoot, Branch: branch, Head: info.head, SourceDirty: info.SourceDirty}
update(Progress{Phase: "creating", Branch: branch, WorkspaceRoot: worktreeRoot})
if _, statErr := os.Stat(worktreeRoot); statErr == nil {
continue
} else if !os.IsNotExist(statErr) {
@@ -117,12 +141,11 @@ func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, ref string)
if strings.Contains(strings.ToLower(stderr), "already exists") {
continue
}
return Result{}, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
return result, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
}
update(Progress{Phase: "checking_out", Branch: branch, WorkspaceRoot: worktreeRoot})
if resetStderr, resetErr := populate(ctx, worktreeRoot, info.head); resetErr != nil {
_, _, _ = runGit(ctx, info.repo, "worktree", "remove", "--force", worktreeRoot)
_, _, _ = runGit(ctx, info.repo, "update-ref", "-d", "refs/heads/"+branch, info.head)
return Result{}, fmt.Errorf("populate Git worktree: %w%s", resetErr, stderrSuffix(resetStderr))
return result, fmt.Errorf("populate Git worktree: %w%s", resetErr, stderrSuffix(resetStderr))
}
selectedRoot := worktreeRoot
@@ -130,7 +153,7 @@ func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, ref string)
selectedRoot = filepath.Join(worktreeRoot, prefix)
st, statErr := os.Stat(selectedRoot)
if statErr != nil || !st.IsDir() {
return Result{}, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
return result, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
}
}
return Result{
@@ -262,6 +285,7 @@ func runGit(parent context.Context, repo gitcmd.Repo, args ...string) (stdout, s
ctx, cancel := context.WithTimeout(parent, gitTimeout(args))
defer cancel()
cmd := repo.Command(ctx, args...)
proc.SetCancelKillsTree(cmd)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
@@ -56,3 +56,55 @@ func TestCreateChecksOutLongRepositoryPathOnWindows(t *testing.T) {
t.Fatalf("long-path worktree file = %q", got)
}
}
func TestDiscardUnpublishedMatchesGitPathsOnWindows(t *testing.T) {
requireGit(t)
root := initRepo(t)
repo := opened(t, root)
managedRoot := testenv.TempDir(t)
result, err := Create(t.Context(), repo, managedRoot)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.WorktreeRoot, `\`) {
t.Fatalf("fixture needs a native Windows path: %q", result.WorktreeRoot)
}
if err := DiscardUnpublished(t.Context(), repo, managedRoot, result); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(result.WorktreeRoot); !os.IsNotExist(err) {
t.Fatalf("unpublished directory retained: %v", err)
}
branches, _, err := runGit(t.Context(), repo, "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/")
if err != nil || strings.TrimSpace(branches) != "" {
t.Fatalf("task branch retained: %q %v", branches, err)
}
registered, _, err := runGit(t.Context(), repo, "worktree", "list", "--porcelain", "-z")
if err != nil || strings.Count(registered, "worktree ") != 1 {
t.Fatalf("partial registration retained: %q %v", registered, err)
}
if err := DiscardUnpublished(t.Context(), repo, managedRoot, result); err != nil {
t.Fatalf("repeated cleanup: %v", err)
}
}
func TestDiscardUnpublishedRetainsChangedBranchOnWindows(t *testing.T) {
requireGit(t)
root := initRepo(t)
repo := opened(t, root)
managedRoot := testenv.TempDir(t)
result, err := Create(t.Context(), repo, managedRoot)
if err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "-C", result.WorktreeRoot, "-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "external change")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("advance task branch: %v %s", err, out)
}
if err := DiscardUnpublished(t.Context(), repo, managedRoot, result); err == nil {
t.Fatal("cleanup accepted a changed task branch")
}
if _, err := os.Stat(filepath.Join(result.WorktreeRoot, "README.md")); err != nil {
t.Fatalf("changed workspace was removed: %v", err)
}
}
@@ -0,0 +1,117 @@
package control
import (
"context"
"errors"
"sync"
"time"
"reasonix/internal/platform/worktree"
)
// WorkspacePreparationStatus is the host's answer for one worktree attempt.
type WorkspacePreparationStatus struct {
ID string `json:"id"`
Phase string `json:"phase"`
SourceRoot string `json:"sourceRoot"`
Ref string `json:"ref"`
StartedAt int64 `json:"startedAt"`
Branch string `json:"branch,omitempty"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
Code string `json:"code,omitempty"`
Detail string `json:"detail,omitempty"`
}
// WorkspacePreparation serializes cancellation with runtime publication.
// A ready operation has transferred ownership and cannot be rolled back.
type WorkspacePreparation struct {
mu sync.Mutex
state WorkspacePreparationStatus
ctx context.Context
cancel context.CancelFunc
}
func NewWorkspacePreparation(parent context.Context, id, root, ref string) *WorkspacePreparation {
ctx, cancel := context.WithCancel(parent)
return &WorkspacePreparation{ctx: ctx, cancel: cancel, state: WorkspacePreparationStatus{
ID: id, Phase: "validating", SourceRoot: root, Ref: ref, StartedAt: time.Now().UnixMilli(),
}}
}
func (p *WorkspacePreparation) Context() context.Context { return p.ctx }
func (p *WorkspacePreparation) Status() WorkspacePreparationStatus {
p.mu.Lock()
defer p.mu.Unlock()
return p.state
}
func (p *WorkspacePreparation) Update(progress worktree.Progress) {
p.mu.Lock()
defer p.mu.Unlock()
if preparationTerminal(p.state.Phase) {
return
}
if p.ctx.Err() == nil {
p.state.Phase = progress.Phase
}
if progress.Branch != "" {
p.state.Branch = progress.Branch
}
if progress.WorkspaceRoot != "" {
p.state.WorkspaceRoot = progress.WorkspaceRoot
}
}
func (p *WorkspacePreparation) Cancel() {
p.mu.Lock()
defer p.mu.Unlock()
if !preparationTerminal(p.state.Phase) {
p.state.Phase = "cancelling"
p.cancel()
}
}
// Publish is the commit boundary. Cancellation that loses this lock observes
// ready; cancellation that wins prevents the host from replacing its runtime.
func (p *WorkspacePreparation) Publish(publish func() error) error {
p.mu.Lock()
defer p.mu.Unlock()
if err := p.ctx.Err(); err != nil {
return err
}
if err := publish(); err != nil {
return err
}
p.state.Phase = "ready"
return nil
}
func (p *WorkspacePreparation) Finish(err error, cleanupFailed bool) {
p.mu.Lock()
defer p.mu.Unlock()
if preparationTerminal(p.state.Phase) {
return
}
p.state.Phase, p.state.Code = "failed", "workspace.worktree_failed"
if cleanupFailed {
p.state.Phase, p.state.Code = "cleanup_failed", "workspace.worktree_cleanup_failed"
} else if errors.Is(p.ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) {
p.state.Phase, p.state.Code = "cancelled", "workspace.worktree_cancelled"
} else if errors.Is(p.ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
p.state.Code = "workspace.worktree_timeout"
} else if errors.Is(err, worktree.ErrSourceUnknown) {
p.state.Code = "workspace.worktree_source_missing"
}
if err != nil {
p.state.Detail = err.Error()
}
}
func preparationTerminal(phase string) bool {
switch phase {
case "ready", "failed", "cancelled", "cleanup_failed":
return true
}
return false
}
@@ -0,0 +1,85 @@
package control
import (
"context"
"errors"
"testing"
"time"
"reasonix/internal/platform/worktree"
)
func TestWorkspacePreparationCancelWinsBeforePublication(t *testing.T) {
op := NewWorkspacePreparation(t.Context(), "test", "/source", "refs/heads/main")
op.Cancel()
called := false
if err := op.Publish(func() error { called = true; return nil }); !errors.Is(err, context.Canceled) || called {
t.Fatalf("publish after cancellation: %v called=%v", err, called)
}
op.Finish(context.Canceled, false)
if got := op.Status(); got.Phase != "cancelled" {
t.Fatalf("status=%+v", got)
}
}
func TestWorkspacePreparationPublicationWinsBeforeCancel(t *testing.T) {
op := NewWorkspacePreparation(t.Context(), "test", "/source", "refs/heads/main")
if err := op.Publish(func() error { return nil }); err != nil {
t.Fatal(err)
}
op.Cancel()
op.Finish(nil, false)
if got := op.Status(); got.Phase != "ready" || op.Context().Err() != nil {
t.Fatalf("committed operation was cancelled: %+v", got)
}
}
func TestWorkspacePreparationCleanupFailureCannotBecomeCancelled(t *testing.T) {
op := NewWorkspacePreparation(t.Context(), "test", "/source", "refs/heads/main")
op.Cancel()
op.Finish(errors.New("cleanup unavailable"), true)
if got := op.Status(); got.Phase != "cleanup_failed" || got.Code != "workspace.worktree_cleanup_failed" {
t.Fatalf("uncertain cleanup must stay visible: %+v", got)
}
}
func TestWorkspacePreparationPublicationExcludesConcurrentCancel(t *testing.T) {
op := NewWorkspacePreparation(t.Context(), "test", "/source", "HEAD")
entered, release, published, cancelled := make(chan struct{}), make(chan struct{}), make(chan error, 1), make(chan struct{})
go func() {
published <- op.Publish(func() error { close(entered); <-release; return nil })
}()
<-entered
go func() { op.Cancel(); close(cancelled) }()
close(release)
if err := <-published; err != nil {
t.Fatal(err)
}
<-cancelled
if got := op.Status(); got.Phase != "ready" || op.Context().Err() != nil {
t.Fatalf("publication lost ownership: %+v", got)
}
}
func TestWorkspacePreparationTerminalIdentityCannotChange(t *testing.T) {
op := NewWorkspacePreparation(t.Context(), "test", "/source", "HEAD")
op.Update(worktree.Progress{Phase: "binding", Branch: "task", WorkspaceRoot: "/target"})
if err := op.Publish(func() error { return nil }); err != nil {
t.Fatal(err)
}
before := op.Status()
op.Update(worktree.Progress{Phase: "checking_out", Branch: "other", WorkspaceRoot: "/other"})
if got := op.Status(); got != before {
t.Fatalf("late progress changed a published identity: %+v", got)
}
}
func TestWorkspacePreparationDeadlineHasTypedRetryableOutcome(t *testing.T) {
ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second))
defer cancel()
op := NewWorkspacePreparation(ctx, "test", "/source", "HEAD")
op.Finish(ctx.Err(), false)
if got := op.Status(); got.Phase != "failed" || got.Code != "workspace.worktree_timeout" {
t.Fatalf("deadline identity was lost: %+v", got)
}
}
+1
View File
@@ -43,6 +43,7 @@ var mirroredWireTypes = []wireMirror{
{"internal/platform/worktree/source.go", "Source", tsWorkspaceFile, "WorktreeSource"},
{"internal/frontend/serve/workspace_source.go", "worktreeSourcesView", tsWorkspaceFile, "WorktreeSources"},
{"internal/frontend/serve/workspace_source.go", "worktreeSelection", tsWorkspaceFile, "WorktreeSelection"},
{"internal/session/control/workspace_preparation.go", "WorkspacePreparationStatus", tsWorkspaceFile, "WorktreePreparation"},
{"internal/contract/surfacespec/action_message.go", "Action", "desktop/frontend-next/src/port/surface.ts", "SurfaceAction"},
{"internal/session/control/surface_actions.go", "SurfaceActionRequest", "desktop/frontend-next/src/port/surface.ts", "SurfaceActionRequest"},
{"internal/session/control/surface_actions.go", "SurfaceActionRecord", "desktop/frontend-next/src/port/surface.ts", "SurfaceActionRecord"},