mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #13598 from SivanCola/codex/workspace-flow
Complete workspace preparation and branch switching / 补齐工作目录准备与分支切换流程
This commit is contained in:
51 files changed
+2207
-335
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 验收单独记录。
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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) } }
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
配置目录,模型回复由本地测试服务提供;不代表真实模型或发布安装包已通过验证。
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user