Merge pull request #13273 from SivanCola/codex/13264-session-worktree

feat(studio): 会话 worktree 配置与统一聊天区菜单 / session workspace setup
This commit is contained in:
YHH authored and GitHub committed 2026-10-16 08:00:39 -07:00
commit c3c19e0748
43 files changed
+1109 -118

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
import "../src/styles/toasts.css";
import "../src/styles/modelmenu.css";
import "../src/styles/composer-menu.css";
import "../src/styles/runsignals.css";
import "../src/styles/overview.css";
import "../src/styles/themes.css";
+16 -6
View File
@@ -14,10 +14,15 @@ const CONTROLS = [".studio-mode-picker", ".studio-branch", ".model-picker", ".co
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const scheme of ["light", "dark"]) for (const turns of [2, 0]) {
const context = `${scheme}, ${turns ? "existing conversation" : "new conversation"}`;
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1100, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose .studio-branch");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.waitForSelector(turns ? ".compose .studio-branch" : ".compose .worktree-trigger");
const inline = await page.locator(".compose .studio-branch").first().evaluate((e) => !!e.closest(".turntools"));
check(`${context}: workspace control stays in the toolbar`, inline);
const seen = [];
for (const sel of CONTROLS) {
const el = page.locator(`.compose ${sel}`.replace(".compose .compose", ".compose")).first();
@@ -26,11 +31,16 @@ try {
await page.waitForTimeout(300);
seen.push([sel, await el.evaluate((e) => ({ radius: getComputedStyle(e).borderRadius, bg: getComputedStyle(e).backgroundColor }))]);
}
check(`${scheme}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
check(`${context}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
const radii = new Set(seen.map(([, v]) => v.radius));
const grounds = new Set(seen.map(([, v]) => v.bg));
check(`${scheme}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${scheme}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
check(`${context}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
await chip.click();
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
await page.close();
}
} finally {
+3 -1
View File
@@ -57,6 +57,7 @@ const probe = (page) =>
sideAsk: (() => { const el = tools.querySelector('[data-action="side.toggle"]'); const r = el && live(el); return r ? { w: r.width, h: r.height } : null; })(),
toolsOverlaps: flatOverlaps([...tools.children]),
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
toolsOverlap: flatOverlaps([...tools.querySelectorAll("button")]),
titleShown: t.width,
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
crumbWidth: clip.width,
@@ -130,7 +131,7 @@ const browser = await chromium.launch();
await page.close();
}
for (const scheme of ["light", "dark"]) {
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [320, 1], [1280, 1]]) {
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [390, 1], [320, 1.8], [320, 1], [1280, 1]]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
@@ -147,6 +148,7 @@ for (const scheme of ["light", "dark"]) {
check(`${tag}: the side question icon is drawn in the toolbar`, !!m.sideAsk && m.sideAsk.w > 0 && m.sideAsk.h > 0, JSON.stringify(m.sideAsk));
check(`${tag}: composer toolbar controls do not overlap`, m.toolsOverlaps.length === 0, m.toolsOverlaps.join(", "));
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
check(`${tag}: composer toolbar buttons do not overlap`, m.toolsOverlap.length === 0, m.toolsOverlap.join(", "));
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
const hint = await page.evaluate(() => {
+6
View File
@@ -67,6 +67,12 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ 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.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" },
{ id: "workspace.source-open", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
@@ -1,6 +1,23 @@
// 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> = {
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "New worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"为此会话创建独立的代码目录": "Create a separate code folder for this session",
"起始分支": "Source branch",
"任务分支": "Task branch",
"自动创建": "Create automatically",
"基于所选分支的已提交版本创建,不包含未提交修改": "Start from the selected branch's committed version, without uncommitted changes",
"使用当前目录,与该目录中的其他会话共享文件": "Use the current folder, sharing files with other sessions in it",
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"远程分支": "Remote branches",
"当前分支": "Current branch",
"没有匹配的分支": "No matching branches",
"分支读取失败,请关闭后重试": "Could not read branches. Close and try again",
"不思考": "No reasoning",
"最轻量": "Minimal",
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
+2 -1
View File
@@ -3,6 +3,8 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
"未配置文件访问策略,无法执行此文件操作。": "File access policy is not configured; this file operation cannot run.",
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
@@ -399,7 +401,6 @@ export const EN_KERNEL: Record<string, string> = {
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"没有找到这个分支,没有创建隔离副本": "That branch was not found, so no isolated copy was created",
"不存在该预设": "There is no such preset",
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
"这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置": "This pane's default model is decided by the machine its models come from; set it from a pane on this machine",
+2 -1
View File
@@ -165,7 +165,8 @@ const SAID: Record<string, string> = {
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.isolate_base_unknown": "没有找到这个分支,没有创建隔离副本",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
"settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
+1
View File
@@ -41,6 +41,7 @@ import "./styles/treerows.css";
import "./styles/workbench-changes.css";
import "./styles/gitgraph.css";
import "./styles/modelmenu.css";
import "./styles/composer-menu.css";
import "./styles/runsignals.css";
import "./styles/toasts.css";
import "./styles/overview.css";
+8 -13
View File
@@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace } from "./mock_workspace";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -12,8 +12,6 @@ import { MEMORIES } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
export class MockPort extends MockWorkspace implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
private log: WireEvent[] = [];
@@ -227,15 +225,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
async accountLogout(): Promise<void> {}
async workspaces(): Promise<WorkspaceInfo> {
return {
current: this.state.workspaceRoot ?? "",
canSwitch: true,
canIsolate: true,
recents: [
{ path: "~/projects/reasonix-site", name: "reasonix-site" },
{ path: "~/work/notes", name: "notes" },
],
};
return mockWorkspaces(this.state.workspaceRoot ?? "");
}
async setWorkspace(path: string) {
@@ -244,8 +234,13 @@ export class MockPort extends MockWorkspace implements AgentPort {
await this.newSession();
}
async isolateWorkspace(_base?: string) {
async worktreeSources(): Promise<import("./workspace").WorktreeSources> {
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
}
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo";
}
async openInEditor() {
@@ -3,6 +3,21 @@ import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import { MockFeedback } from "./mock_feedback";
export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo {
return { current: root, canSwitch: true, canIsolate: true, recents: [
{ path: "~/projects/reasonix-site", name: "reasonix-site" },
{ path: "~/work/notes", name: "notes" },
] };
}
export function mockWorktreeSources(root: string, branch: string): import("./workspace").WorktreeSources {
return { available: branch !== "reasonix/delivery-demo", sourceRoot: root, sources: [
{ ref: "refs/heads/main", name: "main", remote: false, current: branch === "main" },
{ ref: "refs/heads/studio", name: "studio", remote: false, current: branch === "studio" },
{ ref: "refs/remotes/origin/main", name: "origin/main", remote: true, current: false },
] };
}
export class MockWorkspace extends MockFeedback {
opensFiles() { return false; }
async openFile() {}
+2 -1
View File
@@ -446,7 +446,8 @@ export interface AgentPort {
// Rebuilds the whole runtime against another folder. The conversation does
// not come along, so the caller has to reload the transcript afterwards.
setWorkspace(path: string): Promise<void>;
isolateWorkspace(base?: string): Promise<void>;
isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>;
worktreeSources(): Promise<import("./workspace").WorktreeSources>;
// 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
+6 -2
View File
@@ -399,8 +399,12 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post("/workspace", { path });
}
isolateWorkspace(base?: string) {
return this.post("/workspace", base ? { isolate: true, base } : { isolate: true });
isolateWorkspace(selection?: import("./workspace").WorktreeSelection) {
return this.post("/workspace", { isolate: true, worktree: selection });
}
worktreeSources() {
return this.get<import("./workspace").WorktreeSources>("/workspace/worktree-sources");
}
async openInEditor(): Promise<{ editor: string; root: string }> {
@@ -109,6 +109,25 @@ export interface WorkspaceInfo {
isolated?: boolean;
}
export interface WorktreeSource {
ref: string;
name: string;
remote: boolean;
current: boolean;
}
export interface WorktreeSources {
available: boolean;
sourceRoot: string;
sources: WorktreeSource[];
}
export interface WorktreeSelection {
ref: string;
sourceRoot: string;
attachments?: string[];
}
export interface CommitFile {
path: string;
// git raw status letter: "A", "M", "D", "T".
@@ -251,6 +251,20 @@ pack states a shape, never a number the layout has to agree with.
## Type
- Composer menus share `composer-menu.css`: 280–340px widths capped by the
viewport, 12px corners and 8px insets. Labels use 13px medium type,
descriptions 11px and headings 12px. Selection uses a neutral fill and check;
permission warnings retain semantic colour.
- The composer scope covers work mode, workspace, permissions, model and effort.
Search results wrap long names; large lists scroll within the viewport. Other
pickers retain their own density.
- Workspace controls share the branch slot in the composer's bottom toolbar,
including its pill radius, hover surface and narrow-pane label folding.
Runtime statistics remain below the input card.
- Empty phone toolbars keep their compact geometry when a control receives
focus, so a pointer press and release target the same button. Draft content,
attachments and open menus expand the toolbar.
- The scale carried 24 sizes, 442 uses of them inside the four steps between 10
and 11.5px. Half a pixel is not a level; it reads as nothing quite matching.
Six steps now, widening as they go. The floor is 11px: dense Han strokes lose
@@ -0,0 +1,66 @@
:root :is(.portmenu, .polmenu, .worktree-panel).composer-menu {
--choice-size: 13px;
--choice-note: 11px;
--choice-width: 280px;
width: min(var(--choice-width), calc(100vw / var(--zoom, 1) - 24px));
min-width: 0;
max-width: calc(100vw / var(--zoom, 1) - 24px);
max-height: min(640px, calc(100vh / var(--zoom, 1) - 40px));
overflow: auto;
gap: 0;
padding: 8px;
border: 1px solid var(--hair);
border-radius: 12px;
background: var(--float);
color: var(--text);
box-shadow: var(--shadow-float);
font: 400 var(--choice-size)/1.45 var(--ui);
}
:root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; }
:root :is(.worktree-panel, .studio-effort-menu).composer-menu { --choice-width: 320px; }
:root .composer-menu :is(.studio-picker-title, .studio-policy-head, .worktree-heading), :root .composer-menu > .mi.head:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: auto; min-height: 30px; margin: 0 0 4px; padding: 2px 8px 8px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; color: var(--muted); font: 500 12px/1.4 var(--ui); }
:root .composer-menu :is(.studio-picker-title b, .studio-policy-head b), :root .composer-menu > .mi.head:first-child .lb { color: var(--muted); font: inherit; letter-spacing: 0; }
:root .composer-menu :is(.studio-picker-title small, .studio-policy-head small), :root .composer-menu > .mi.head:first-child .rt { margin-left: auto; color: var(--muted); font: 400 11px/1.4 var(--ui); text-align: right; }
:root .composer-menu :is(button.mi, .studio-permission-option, .worktree-row, .worktree-sources button) { min-height: 36px; padding: 8px 10px; gap: 8px; border-radius: var(--r-sm); color: var(--text); font: 500 var(--choice-size)/1.4 var(--ui); }
:root .composer-menu :is(.mi .lb, .studio-permission-option b, .studio-policy-boundary b, .worktree-toggle span) { color: inherit; font: 500 var(--choice-size)/1.4 var(--ui); }
:root .composer-menu :is(.mi .ds, .studio-permission-option small, .studio-policy-boundary small, .worktree-toggle small) { display: block; margin-top: 2px; color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); }
:root .composer-menu .studio-permission-option > span:first-child { gap: 0; }
:root .composer-menu :is(button.mi, .studio-permission-option, .worktree-sources button):is([data-on], [aria-selected="true"]) { background: var(--overlay); color: var(--text); }
:root .composer-menu :is(button.mi, .studio-permission-option, button.worktree-row, .worktree-sources button):not(:disabled):hover { background: var(--float-hi); }
:root .composer-menu .mi[data-on]::after, :root .composer-menu .studio-permission-check { flex-shrink: 0; color: var(--text); font-size: 13px; }
:root .composer-menu :is(button, input):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
:root .composer-menu :is(.studio-policy-note, .worktree-note) { margin: 4px 0 0; padding: 8px; border-top: 1px solid var(--hair); color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); }
:root .composer-menu .studio-policy-primary { padding: 0; }
:root .composer-menu .studio-policy-risk { padding: 8px 10px 2px; color: var(--warn-ink); font: 500 11px/1.4 var(--ui); }
:root .composer-menu .studio-permission-option.studio-permission-yolo b { color: var(--warn-ink); }
:root .composer-menu .studio-policy-boundary { min-height: 36px; padding: 8px 10px; border-top-color: var(--hair); border-radius: 0 0 var(--r-sm) var(--r-sm); }
:root .composer-menu .studio-policy-boundary > span:first-child { gap: 0; }
:root .composer-menu .worktree-toggle { padding: 8px 10px; gap: 8px; margin: 0; border-bottom: 0; }
:root .composer-menu .worktree-toggle > .studio-icon, :root .composer-menu .worktree-row > .studio-icon { width: 16px; height: 16px; }
:root .composer-menu .worktree-value { font-size: 12px; }
:root .composer-menu .sw { width: 32px; height: 18px; }
:root .composer-menu .sw i { width: 14px; height: 14px; margin-left: 2px; }
:root .composer-menu .sw[aria-checked="true"] i { transform: translateX(14px); }
:root .composer-menu :is(.mfind, input[type="search"]) { position: static; width: calc(100% - 16px); margin: 4px 8px 6px; padding: 6px 8px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: 400 12px/1.5 var(--ui); }
:root .composer-menu .mchips { margin: 0 8px 6px; padding: 0; }
:root .composer-menu .mchip { font-size: 11px; }
:root .composer-menu.studio-model-menu .mi.head { position: static; min-height: 28px; margin: 4px 0 2px; padding: 6px 10px; border: 0; background: transparent; }
:root .composer-menu.studio-model-menu .mi.head .lb { color: var(--muted); font: 500 11px/1.4 var(--ui); }
:root .composer-menu.studio-model-menu .mi.head .rt { color: var(--muted); font: 400 11px/1.4 var(--mono); }
:root .composer-menu.studio-model-menu .mi.head .mi-mono { display: none; }
:root .composer-menu.studio-model-menu .mi .rt { color: var(--muted); font-size: 11px; }
:root .composer-menu.studio-model-menu .studio-item-line { flex-wrap: wrap; row-gap: 2px; }
:root .composer-menu.studio-model-menu .studio-item-line > .lb { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; }
:root .composer-menu.studio-effort-menu .mi:not(.head) { min-height: 36px; grid-template-columns: minmax(0, 1fr) 13px; }
:root .composer-menu .studio-effort-strength { display: none; }
:root .composer-menu .studio-item-meta { color: var(--muted); font-size: 11px; }
:root .composer-menu .studio-item-badge { font-size: 11px; }
:root .composer-menu.studio-effort-menu > .mi.head:last-child { min-height: 0; margin: 4px 0 0; padding: 8px; border: 0; border-top: 1px solid var(--hair); }
:root .composer-menu.studio-effort-menu > .mi.head:last-child :is(.lb, .rt) { color: var(--muted); font: 400 11px/1.45 var(--ui); }
:root .composer-menu .worktree-sources { max-height: 320px; margin: 0; }
:root .composer-menu .worktree-sources > div > small { padding: 6px 10px 2px; font-size: 11px; }
:root .composer-menu .worktree-sources button small { color: var(--muted); font-size: 11px; }
:root .composer-menu .worktree-back { margin: 4px 0 0; padding: 6px 10px; font-size: 12px; }
@container conversation (max-width: 470px) {
:root .composer-menu.polmenu { left: 0; right: 0; width: auto; max-width: none; }
}
+6 -2
View File
@@ -2,14 +2,18 @@
@container conversation (max-width: 720px) {
.turntools { flex-wrap: nowrap; }
.studio-model-group { flex: 1 1 0; }
.studio-model-control { flex: 0 1 auto; min-width: 64px; }
.studio-model-control { flex: 0 1 auto; min-width: 0; }
.studio-model-control .model-picker .nm { max-width: none; }
}
.chrome .r-fold { display: contents; }
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
@container conversation (max-width: 360px) { .studio-effort-picker::before { display: none; } }
@container conversation (max-width: 360px) {
.turntools { flex-wrap: wrap; }
.studio-effort-picker::before { display: none; }
.studio-model-group { flex-basis: 100%; flex-wrap: wrap; }
}
@container conversation (max-width: 200px) { .studio-meterrail .studio-meter-cache { display: none; } }
@container conversation (max-width: 520px) { .studio-deckchip > span { display: none; } }
.chrome .thbtn.r-more { color: var(--muted); }
@@ -1,6 +1,4 @@
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) textarea { min-height: 1lh; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-refine { display: none; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) :is(.studio-mode-picker > span:not(.studio-sr-label), .studio-mode-picker > .studio-icon:last-child) { display: none; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .turntools { flex-wrap: nowrap; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-model-group { flex-basis: 0; flex-wrap: nowrap; }
[data-fold~="scene"] .compose:not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-refine { display: none; }
[data-fold~="scene"] .compose:not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0; }
[data-fold~="scene"] .compose:not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) :is(.studio-mode-picker > span:not(.studio-sr-label), .studio-mode-picker > .studio-icon:last-child) { display: none; }
@@ -0,0 +1,28 @@
.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: 14px/1.5 var(--ui) }
.worktree-heading { color: var(--muted); margin-bottom: 16px; font-size: 13px }
.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: 16px }
.worktree-toggle small { display: block; color: var(--muted); font-size: 12px; 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: 12px }
.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: 12px }
.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 }
+10 -6
View File
@@ -6,6 +6,8 @@ import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment, WorkspaceGit } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup } from "./useWorktreeSetup";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
@@ -32,6 +34,7 @@ import { ImagePreview } from "./ImagePreview";
import { SideAskButton } from "./SideAskButton";
interface Props {
fresh?: boolean;
port: AgentPort;
status: SessionStatus | null;
running: boolean;
@@ -119,11 +122,12 @@ function releaseChip(c: Chip) {
let chipSeq = 0;
const chipId = () => `c${++chipSeq}`;
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "" }: Props) {
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false }: 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 { 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.
@@ -264,6 +268,7 @@ 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] : []));
const sent = await (call ? onSubmit(line, call) : onSubmit(line));
if (sent) {
finishSubmit(true);
@@ -419,8 +424,6 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c));
return (
// display:contents, so the box looks exactly as it did and the drop layer
// still has one node to ask which pane a drop landed in.
<div className="dropzone" ref={dropzone}>
{menu.open && (
<CompletionMenu
@@ -677,6 +680,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
ariaPressed={status?.plan ?? false}
place="bottom"
title={t("工作模式")}
menuClassName="composer-menu"
current={status?.plan ? "plan" : "agent"}
pending={busy["plan"]}
items={[
@@ -689,7 +693,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>
<BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />
{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} />}
{/* 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
@@ -710,7 +714,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
current={status?.modelRef}
items={narrowToGroups(menuItems, chosenModelGroups)}
head={<ModelGroupChips headings={groupHeadings(menuItems)} chosen={chosenModelGroups} onChange={writeModelFilter} />}
menuClassName="studio-model-menu"
menuClassName="studio-model-menu composer-menu"
searchAlways
searchPlaceholder={t("搜索模型或服务商…")}
menuTitle={<><b>{t("选择模型")}</b><small>{t("用于后续任务")}</small></>}
@@ -727,7 +731,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
data-action="reasoning.effort"
place="bottom"
align="end"
menuClassName="studio-effort-menu"
menuClassName="studio-effort-menu composer-menu"
onOpen={loadModels}
title={t("推理强度")}
current={declared ? status.effort || "auto" : ""}
+4 -6
View File
@@ -372,13 +372,11 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
port.context().then(setCtx).catch(() => setCtx(null));
}, [ready, port, roundTrips, folds, status?.sessionPath, running, pulse]);
// The sidebar has to hear about this pane's session twice: when the first
// turn mints the file (before that there is no row to show) and when the turn
// ends (that is when it has a generated title and a turn count). Without it a
// brand-new conversation only appeared in the tree once its pane was closed.
// Workspace preparation can move an empty pane before its first turn mints
// a transcript; the sidebar needs that root as well as later session updates.
useEffect(() => {
onSessionChanged();
}, [status?.sessionPath, running, onSessionChanged]);
}, [status?.workspaceRoot, status?.sessionPath, running, onSessionChanged]);
// /status is the only source for background jobs and for settings the run does
// not echo, so a live turn has to re-read it rather than infer from events.
@@ -594,7 +592,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} 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)} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<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)} 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}
+1 -1
View File
@@ -59,7 +59,7 @@ export function Policy({ port, status, onChanged, onBoundary }: Props) {
<span className={apv === "yolo" ? "lb polrisk" : "lb"} data-display={label}>{label}</span>
<span className="studio-control-chevron" aria-hidden="true">⌄</span>
</button>
<div className="menu modemenu polmenu" role="group" aria-label={t("执行权限")} hidden={!open}>
<div className="menu modemenu polmenu composer-menu" role="group" aria-label={t("执行权限")} hidden={!open}>
<div className="studio-policy-head"><b>{t("执行权限")}</b><small>{t("当前任务")}</small></div>
<div className="studio-policy-primary">
{APPROVALS.map(([value, name, note]) => (
@@ -0,0 +1,112 @@
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 { StudioIcon } from "./StudioIcon";
import { Switch } from "./Switch";
import { listenAction } from "./listen";
import { beside, pinToViewport, useFollow, zoom } from "./place";
import "../styles/worktree.css";
interface Props {
sources: WorktreeSources;
selection: WorktreeSelection | null;
branch: string;
busy: boolean;
changeCount: number;
onSelect: (selection: WorktreeSelection | null) => void;
refresh: () => Promise<WorktreeSources>;
}
export function WorktreeChip({ sources, selection, branch, busy, changeCount, onSelect, refresh }: Props) {
const [open, setOpen] = useState(false);
const [choosing, setChoosing] = useState(false);
const [query, setQuery] = useState("");
const [error, setError] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
const title = useId();
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(); };
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}`);
useEffect(() => {
if (!open) return;
if (choosing) search.current?.focus();
else panel.current?.querySelector<HTMLButtonElement>("button")?.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 !== "Tab") return;
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input") ?? []);
const edge = event.shiftKey ? stops[0] : stops.at(-1);
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]);
useEffect(() => { if (busy) setOpen(false); }, [busy]);
const show = () => {
if (open) { close(); return; }
setOpen(true);
setError(false);
void refresh().catch(() => setError(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()));
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>
{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>}
</>}
</div>, document.body)}
</div>;
}
@@ -135,7 +135,7 @@ it("loads deferred panels and the branch chip when the first status fails", asyn
for (const name of deferredReads) expect(pane.calls[name], name).toHaveBeenCalledTimes(1);
expect(pane.port.branchReads).toHaveBeenCalled();
expect(pane.calls.changes).toHaveBeenCalled();
await screen.findByLabelText("当前 Git 分支:rescue");
expect((await screen.findByRole("button", { name: "配置工作目录" })).textContent).toContain("rescue");
pane.send({ kind: "turn_done" });
await waitFor(() => expect(pane.calls.queue).toHaveBeenCalledTimes(2));
expect(pane.calls.models).toHaveBeenCalledTimes(1);
@@ -22,6 +22,7 @@ const rt = { id: "p1", root: "/w", name: "w" } as RuntimeView;
function open() {
const port = new MockPort();
vi.spyOn(port, "history").mockResolvedValue([{ role: "user", content: "Existing conversation" }]);
let emit: (ev: WireEvent) => void = () => {};
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, "subscribe").mockImplementation((onEvent, onGap, bootstrap) => {
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceGit } from "../port/port";
import type { WorktreeSelection, WorktreeSources } from "../port/workspace";
export function useWorktreeSetup(port: AgentPort, fresh: boolean, root: string, onChanged: () => void, onTreeChanged?: (git: WorkspaceGit) => void) {
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 value = await port.worktreeSources();
setSources(value);
return value;
};
const prepare = async (attachments: string[]) => {
if (!selection || prepared.current) return;
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;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
}
throw error;
}
prepared.current = true;
setSelection(null);
setSources(null);
onChanged();
void port.workspaceGit().then(onTreeChanged).catch(() => {});
};
return { sources, selection, setSelection, refresh, prepare };
}
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Composer } from "./Composer";
import { MockPort } from "../port/mock";
import type { SessionStatus } from "../port/port";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
function draw(fresh = true) {
const port = new MockPort();
vi.spyOn(port, "status").mockResolvedValue({ workspaceRoot: "~/projects/DeepSeek-Reasonix" } as SessionStatus);
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} />);
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.change(screen.getByRole("searchbox"), { target: { value: "studio" } });
fireEvent.click(screen.getByRole("option", { name: /studio/ }));
fireEvent.keyDown(window, { key: "Escape" });
fireEvent.change(screen.getByRole("combobox", { name: "任务输入" }), { target: { value: "添加搜索功能" } });
}
it("selects a source without switching the checkout and creates only when sending", async () => {
const { port, isolate, submit } = draw();
const switchBranch = vi.spyOn(port, "switchBranch");
expect((await screen.findByRole("button", { name: "配置工作目录" })).closest(".turntools")).not.toBeNull();
await choose();
expect(isolate).not.toHaveBeenCalled();
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.mock.invocationCallOrder[0]).toBeLessThan(submit.mock.invocationCallOrder[0]);
});
it("restores the draft on creation failure and retries without submitting in the source", async () => {
const { isolate, submit } = draw();
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("添加搜索功能"));
expect(submit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(isolate).toHaveBeenCalledTimes(2);
});
it("reuses the prepared worktree when the first submission is refused", async () => {
const { isolate, submit } = draw();
submit.mockResolvedValueOnce(false);
await choose();
fireEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect((screen.getByRole("combobox", { name: "任务输入" }) 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 () => {
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();
});
+42
View File
@@ -0,0 +1,42 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-15
---
# Session worktrees / 会话工作树
## 中文
在有提交记录的 Git 项目中新建会话,点击输入框内底部工具栏的「当前目录 · 分支」:
1. 开启「新建 worktree」。
2. 点击「起始分支」,搜索并选择本地分支或已获取的远程分支。
3. 输入任务并发送。Reasonix 此时创建独立目录和自动命名的任务分支,再在其中开始对话。
打开弹层或选择分支不会修改代码,也不会切换原目录的分支。即使起始分支已被其他工作树使用,也可以从它创建独立任务。远程分支列表来自本地 Git 记录;需要更新时先在终端运行 fetch。
工作树使用分支的已提交版本,不包含未提交代码、依赖安装或环境文件。草稿中已上传的附件会复制到新目录,保持引用可用。模型、推理强度、工作模式和显式选择的执行权限随会话保留;依赖目录信任的默认权限按新目录重新判断。
创建或初始化失败时保留草稿和原会话,并撤回尚未交付的工作树。发送请求失败后可以重试,已创建的工作树继续使用。已交付的工作树会保留在工作区列表中,关闭会话不会自动删除代码。
非 Git 目录、尚无提交的仓库、已有对话以及已受管理的工作树不显示新会话的 worktree 配置。已有对话继续使用原来的分支切换功能。此功能隔离代码目录,不隔离端口、数据库或运行环境。
## English
Start a new conversation in a Git project with a commit, then open **Current folder · branch** in the composer's bottom toolbar:
1. Enable **New worktree**.
2. Open **Base branch** and search local or fetched remote-tracking branches.
3. Send the task. Reasonix creates a separate directory and automatically named task branch before submitting the first message there.
Choosing a branch does not check it out in the source folder. A branch used by another worktree is still a valid starting point. Remote branches come from local Git refs; fetch in a terminal to refresh them.
Only committed code is checked out. Uncommitted edits, installed dependencies and environment files are not copied. Uploaded draft attachments retain their relative references in the new directory.
Model, reasoning effort, plan mode and explicitly chosen approval mode are retained; default approval based on folder trust is recalculated for the new directory.
Preparation failures preserve the original conversation and draft and roll back unpublished worktrees. Retrying a refused message reuses the prepared directory. Published worktrees remain in the workspace list and survive closing the conversation.
The control is unavailable in non-Git folders, repositories without commits, existing conversations and already managed worktrees. Existing conversations retain the branch switcher. Worktrees isolate code directories, not ports, databases or runtime environments.
+1
View File
@@ -16,6 +16,7 @@ func (s *Server) registerGitRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /workspace/git", s.workspaceGit)
mux.HandleFunc("GET /workspace/git/log", s.gitHistory)
mux.HandleFunc("GET /workspace/branches", s.workspaceBranches)
mux.HandleFunc("GET /workspace/worktree-sources", s.workspaceSources)
mux.HandleFunc("POST /workspace/branch/switch", s.workspaceSwitchBranch)
mux.HandleFunc("POST /commit/propose", s.commitPropose)
mux.HandleFunc("POST /commit", s.commitStaged)
+52 -9
View File
@@ -22,8 +22,6 @@ import (
const workspaceRecentMax = workspacelist.MaxPaths
const codeIsolateBaseUnknown = "workspace.isolate_base_unknown"
// AllowWorkspaceSwitch grants POST /workspace. It is off until a host asks for
// it, and no config file can turn it on: a server reachable over the network
// would otherwise let any client repoint the agent at any directory it can
@@ -77,9 +75,9 @@ func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Path string `json:"path"`
Isolate bool `json:"isolate"`
Base string `json:"base"`
Path string `json:"path"`
Isolate bool `json:"isolate"`
Worktree *worktreeSelection `json:"worktree,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
badBody(w)
@@ -90,17 +88,44 @@ func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
defer s.bindMu.Unlock()
dir := strings.TrimSpace(body.Path)
published := false
if controllerHasActiveRuntimeWork(s.ctl()) {
writeErr(w, http.StatusConflict, busyErr("busy.change_workspace", "cannot change the workspace while active work or background jobs are running"))
return
}
if body.Worktree != nil && (!body.Isolate || body.Worktree.Ref == "" || body.Worktree.SourceRoot != s.ctl().WorkspaceRoot() || !emptyConversation(s.ctl())) {
refuse(w, http.StatusConflict, "workspace.worktree_stale", "start a new session in the source workspace before creating a worktree", nil)
return
}
if body.Isolate {
res, err := worktree.CreateFrom(r.Context(), workspaceRepo(s.ctl()), config.DeliveryWorktreeDir(), body.Base)
if errors.Is(err, worktree.ErrBaseUnknown) {
refuse(w, http.StatusUnprocessableEntity, codeIsolateBaseUnknown, err.Error(), nil)
return
ref := ""
if body.Worktree != nil {
ref = body.Worktree.Ref
}
res, err := worktree.CreateFrom(r.Context(), workspaceRepo(s.ctl()), config.DeliveryWorktreeDir(), 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)
return
}
writeErr(w, http.StatusUnprocessableEntity, err)
return
}
repo := workspaceRepo(s.ctl())
defer func() {
if !published {
if err := worktree.DiscardUnpublished(context.WithoutCancel(r.Context()), repo, res); err != nil {
slog.Warn("retain unpublished worktree after rollback failure", "path", res.WorkspaceRoot, "err", err)
}
}
}()
dir = res.WorkspaceRoot
if body.Worktree != nil {
if err := control.CarryDraftAttachments(body.Worktree.SourceRoot, dir, body.Worktree.Attachments); err != nil {
writeErr(w, http.StatusUnprocessableEntity, err)
return
}
}
}
dir, err := resolveWorkspaceDir(dir)
if err != nil {
@@ -111,6 +136,7 @@ func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusConflict, err)
return
}
published = true
writeJSON(w, struct {
WorkspaceRoot string `json:"workspaceRoot"`
}{dir})
@@ -156,6 +182,18 @@ func (s *Server) switchWorkspaceLocked(ctx context.Context, dir string) error {
return fmt.Errorf("open %s: %w", dir, err)
}
newCtrl.EnableInteractiveApproval()
if cur.Posture().Defaulted {
newCtrl.ApplyDefaultPosture()
} else {
newCtrl.SetToolApprovalMode(cur.ToolApprovalMode())
}
newCtrl.SetPlanMode(cur.PlanMode())
if current, ok := cur.(*control.Controller); ok {
if err := newCtrl.SetModelMode(current.ModelMode()); err != nil {
newCtrl.Close()
return err
}
}
newCtrl.SetOnSessionRecovered(sessionLeaseRecoveryHandler(s.leases))
if err := addRememberedWorkspace(ctx, dir); err != nil {
@@ -311,6 +349,11 @@ func (s *Server) workspaceOptions(dir, ref string) boot.Options {
opts.WorkspaceRoot, opts.SessionDir = dir, SessionDirFor(dir)
if ctl := s.ctl(); ctl != nil {
opts.NativeSurfaces = ctl.NativeSurfaces()
opts.AgentPreset = ctl.AgentPreset()
if current, ok := ctl.(*control.Controller); ok {
effort := current.RuntimeSelection().Effort
opts.EffortOverride = &effort
}
}
return opts
}
@@ -2,6 +2,7 @@ package serve
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -65,7 +66,8 @@ func gitHead(t *testing.T, dir, ref string) string {
func TestIsolateFromTheChosenBranch(t *testing.T) {
url, repoDir, built := isolateServer(t)
resp, err := http.Post(url+"/workspace", "application/json", strings.NewReader(`{"isolate":true,"base":"topic"}`))
body, _ := json.Marshal(map[string]any{"isolate": true, "worktree": worktreeSelection{Ref: "refs/heads/topic", SourceRoot: repoDir}})
resp, err := http.Post(url+"/workspace", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
@@ -98,14 +100,15 @@ func TestIsolateWithoutBaseKeepsTheCommittedHead(t *testing.T) {
}
func TestIsolateWithUnknownBaseIsRefusedWithItsCode(t *testing.T) {
url, _, built := isolateServer(t)
resp, err := http.Post(url+"/workspace", "application/json", strings.NewReader(`{"isolate":true,"base":"nope"}`))
url, repoDir, built := isolateServer(t)
body, _ := json.Marshal(map[string]any{"isolate": true, "worktree": worktreeSelection{Ref: "refs/heads/nope", SourceRoot: repoDir}})
resp, err := http.Post(url+"/workspace", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusUnprocessableEntity || reasonOf(t, out).Code != codeIsolateBaseUnknown {
if resp.StatusCode != http.StatusConflict || reasonOf(t, out).Code != "workspace.worktree_source_missing" {
t.Fatalf("POST /workspace = %d %s", resp.StatusCode, out)
}
if *built != "" {
@@ -0,0 +1,50 @@
package serve
import (
"net/http"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
"reasonix/internal/platform/worktree"
"reasonix/internal/session/control"
)
type worktreeSelection struct {
Ref string `json:"ref"`
SourceRoot string `json:"sourceRoot"`
Attachments []string `json:"attachments,omitempty"`
}
type worktreeSourcesView struct {
Available bool `json:"available"`
SourceRoot string `json:"sourceRoot"`
Sources []worktree.Source `json:"sources"`
}
func (s *Server) workspaceSources(w http.ResponseWriter, r *http.Request) {
ctrl := s.ctl()
root := ctrl.WorkspaceRoot()
view := worktreeSourcesView{SourceRoot: root, Sources: []worktree.Source{}}
if s.grants.at(r).workspaceSwitch && emptyConversation(ctrl) && !controllerHasActiveRuntimeWork(ctrl) && !worktree.IsManagedPath(root, config.DeliveryWorktreeDir()) {
repo := workspaceRepo(ctrl)
view.Available = worktree.Inspect(r.Context(), repo).Available
if view.Available {
list, err := worktree.Sources(r.Context(), repo)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
view.Sources = list
}
}
writeJSON(w, view)
}
func emptyConversation(ctrl control.SessionAPI) bool {
for _, message := range ctrl.History() {
if message.Role != provider.RoleSystem {
return false
}
}
return true
}
@@ -0,0 +1,136 @@
package serve
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/platform/gitcmd"
"reasonix/internal/runtime/agent"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/session/control"
"reasonix/internal/state/sessionstore"
)
func worktreeServer(t *testing.T) (*Server, string, string) {
t.Helper()
t.Setenv("REASONIX_HOME", t.TempDir())
dir := t.TempDir()
for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "base", "--allow-empty"}, {"branch", "studio"}} {
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
if err != nil {
t.Fatalf("git: %v %s", err, out)
}
}
repo, err := gitcmd.Open(t.Context(), dir)
if err != nil {
t.Fatal(err)
}
exec := agent.New(testutil.NewMock("draft"), tool.NewRegistry(), sessionstore.NewSession("system"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{WorkspaceRoot: dir, WorkspaceRepo: repo, Runner: exec, Executor: exec})
ctrl.SetPlanMode(true)
ctrl.SetToolApprovalMode("auto")
s := New(ctrl, NewBroadcaster(), config.ServeConfig{})
s.AllowWorkspaceSwitch()
s.buildWorkspaceController = func(ctx context.Context, root, _ string) (*control.Controller, error) {
repo, err := gitcmd.Open(ctx, root)
if err != nil {
return nil, err
}
return control.New(control.Options{WorkspaceRoot: root, WorkspaceRepo: repo}), nil
}
t.Cleanup(func() { s.Controller().Close(); ctrl.Close() })
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
return s, srv.URL, dir
}
func prepareWorktree(t *testing.T, url, root string, attachments []string) int {
t.Helper()
body, _ := json.Marshal(map[string]any{"isolate": true, "worktree": worktreeSelection{Ref: "refs/heads/studio", SourceRoot: root, Attachments: attachments}})
resp, err := http.Post(url+"/workspace", "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.Logf("prepare: %s", data)
}
return resp.StatusCode
}
func TestSessionWorktreePreservesDraftAndRejectsDuplicatePreparation(t *testing.T) {
s, url, source := worktreeServer(t)
attachment, err := control.SaveTextAttachmentInRoot(source, "task input")
if err != nil {
t.Fatal(err)
}
var sources worktreeSourcesView
fetchInto(t, url+"/workspace/worktree-sources", &sources)
if !sources.Available || len(sources.Sources) != 2 {
t.Fatalf("sources = %+v", sources)
}
if code := prepareWorktree(t, url, source, []string{attachment}); code != http.StatusOK {
t.Fatalf("create = %d", code)
}
root := s.Controller().WorkspaceRoot()
if root == source || !s.Controller().PlanMode() || s.Controller().ToolApprovalMode() != "auto" {
t.Fatal("workspace/axes not preserved")
}
for _, dir := range []string{source, root} {
data, err := os.ReadFile(filepath.Join(dir, attachment))
if err != nil || string(data) != "task input" {
t.Fatalf("attachment in %s = %q %v", dir, data, err)
}
}
if code := prepareWorktree(t, url, source, nil); code != http.StatusConflict {
t.Fatalf("duplicate = %d", code)
}
if s.Controller().WorkspaceRoot() != root {
t.Fatal("duplicate moved the session")
}
view := (&Runtime{ID: "draft", Server: s}).view()
if view.Root != root {
t.Fatalf("runtime root = %s", view.Root)
}
}
func TestSessionWorktreeBuildFailureRollsBackAndKeepsSource(t *testing.T) {
s, url, source := worktreeServer(t)
old := s.Controller()
s.buildWorkspaceController = func(context.Context, string, string) (*control.Controller, error) {
return nil, errors.New("build failed")
}
if code := prepareWorktree(t, url, source, nil); code != http.StatusConflict {
t.Fatalf("failure = %d", code)
}
if s.Controller() != old {
t.Fatal("failure replaced source")
}
out, err := exec.Command("git", "-C", source, "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/").Output()
if err != nil || len(out) != 0 {
t.Fatalf("failed preparation left branches: %s %v", out, err)
}
}
func TestSessionWorktreeRefusesExistingConversation(t *testing.T) {
s, url, source := worktreeServer(t)
ctrl := s.Controller().(*control.Controller)
ctrl.AdoptHistory([]provider.Message{{Role: provider.RoleUser, Content: "existing task"}}, "")
if code := prepareWorktree(t, url, source, nil); code != http.StatusConflict {
t.Fatalf("existing = %d", code)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ type Candidate struct {
// TakeSnapshot records the current state of repo's workspace, resolved when
// the workspace opened, as a dangling commit.
func TakeSnapshot(ctx context.Context, repo gitcmd.Repo) (Snapshot, error) {
info, err := inspect(ctx, repo, "")
info, err := inspect(ctx, repo)
if err != nil {
return Snapshot{}, err
}
+23
View File
@@ -0,0 +1,23 @@
package worktree
import (
"context"
"fmt"
"strings"
"reasonix/internal/platform/gitcmd"
)
// 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 {
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 {
return err
}
_, _, err = runGit(ctx, repo, "update-ref", "-d", "refs/heads/"+result.Branch, result.Head)
return err
}
+44
View File
@@ -0,0 +1,44 @@
package worktree
import (
"context"
"errors"
"strings"
"reasonix/internal/platform/gitcmd"
)
// ErrSourceUnknown means the requested ref is no longer an available branch.
var ErrSourceUnknown = errors.New("worktree: source branch is unavailable")
// Source is a local branch or a remote-tracking branch, never a checkout action.
type Source struct {
Ref string `json:"ref"`
Name string `json:"name"`
Remote bool `json:"remote"`
Current bool `json:"current"`
}
// Sources includes branches checked out by other worktrees: they remain valid
// starting points for a new, independently named branch. It never fetches.
func Sources(ctx context.Context, repo gitcmd.Repo) ([]Source, error) {
list := []Source{}
if !repo.Valid() {
return list, nil
}
raw, _, err := runGit(ctx, repo.Top(), "for-each-ref", "--sort=refname",
"--format=%(refname)%00%(HEAD)%00%(symref)", "refs/heads/", "refs/remotes/")
if err != nil {
return nil, err
}
for line := range strings.SplitSeq(raw, "\n") {
fields := strings.Split(line, "\x00")
if len(fields) != 3 || fields[2] != "" {
continue
}
remote := strings.HasPrefix(fields[0], "refs/remotes/")
name := strings.TrimPrefix(strings.TrimPrefix(fields[0], "refs/heads/"), "refs/remotes/")
list = append(list, Source{Ref: fields[0], Name: name, Remote: remote, Current: fields[1] == "*"})
}
return list, nil
}
+110
View File
@@ -0,0 +1,110 @@
package worktree
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCreateFromBranchKeepsSourceAndSeparatesTasks(t *testing.T) {
requireGit(t)
dir := initRepo(t)
gitIn(t, dir, "config", "user.name", "")
gitIn(t, dir, "config", "user.email", "")
repo := opened(t, dir)
ctx := context.Background()
base, _, _ := runGit(ctx, repo, "rev-parse", "HEAD")
if _, _, err := runGit(ctx, repo, "branch", "task-base"); err != nil {
t.Fatal(err)
}
if _, _, err := runGit(ctx, repo, "update-ref", "refs/remotes/origin/main", strings.TrimSpace(base)); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("new current head\n"), 0o600); err != nil {
t.Fatal(err)
}
gitIn(t, dir, "commit", "-am", "advance current branch")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("uncommitted\n"), 0o600); err != nil {
t.Fatal(err)
}
managed := t.TempDir()
first, err := CreateFrom(ctx, repo, managed, "refs/heads/task-base")
if err != nil {
t.Fatal(err)
}
second, err := CreateFrom(ctx, repo, managed, "refs/remotes/origin/main")
if err != nil {
t.Fatal(err)
}
if first.Head != strings.TrimSpace(base) || first.Branch == second.Branch || first.WorkspaceRoot == second.WorkspaceRoot {
t.Fatalf("not independent: %+v %+v", first, second)
}
for _, root := range []string{first.WorkspaceRoot, second.WorkspaceRoot} {
data, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil || string(data) != "base\n" {
t.Fatalf("checkout = %q, %v", data, err)
}
}
data, _ := os.ReadFile(filepath.Join(dir, "README.md"))
if string(data) != "uncommitted\n" {
t.Fatal("source changed")
}
list, err := Sources(ctx, repo)
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, b := range list {
found[b.Ref] = true
}
if !found["refs/heads/task-base"] || !found["refs/remotes/origin/main"] {
t.Fatalf("sources = %+v", list)
}
}
func TestCreateFromRejectsUnknownOrNonBranchBeforeMutation(t *testing.T) {
requireGit(t)
repo := opened(t, initRepo(t))
for _, ref := range []string{"missing", "--detach", "HEAD~0", "refs/heads/missing"} {
t.Run(ref, func(t *testing.T) {
managed := filepath.Join(t.TempDir(), "not-created")
if _, err := CreateFrom(context.Background(), repo, managed, ref); err == nil {
t.Fatal("invalid source accepted")
}
if _, err := os.Stat(managed); !os.IsNotExist(err) {
t.Fatalf("created storage before validating source: %v", err)
}
})
}
}
func TestCreateFromDetachedHEADAndOccupiedBranch(t *testing.T) {
requireGit(t)
repo := opened(t, initRepo(t))
ctx := t.Context()
branch, _, err := runGit(ctx, repo, "symbolic-ref", "HEAD")
if err != nil {
t.Fatal(err)
}
if _, err := CreateFrom(ctx, repo, t.TempDir(), strings.TrimSpace(branch)); err != nil {
t.Fatalf("branch occupied by source worktree: %v", err)
}
if _, _, err := runGit(ctx, repo, "checkout", "--detach"); err != nil {
t.Fatal(err)
}
result, err := CreateFrom(ctx, repo, t.TempDir(), "")
if err != nil {
t.Fatal(err)
}
if err := DiscardUnpublished(ctx, repo, result); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(result.WorktreeRoot); !os.IsNotExist(err) {
t.Fatal("unpublished worktree retained")
}
if _, err := os.Stat(filepath.Dir(result.WorktreeRoot)); err != nil {
t.Fatalf("rollback touched the storage container outside the worktree: %v", err)
}
}
+33 -43
View File
@@ -24,10 +24,6 @@ const (
gitWorktreeAddTimeout = 5 * time.Minute
)
// ErrBaseUnknown reports a base that names no local or remote-tracking branch
// of the repository.
var ErrBaseUnknown = errors.New("worktree: base branch not found")
// Availability describes whether a project can be isolated with Git worktree.
type Availability struct {
Available bool `json:"available"`
@@ -43,7 +39,6 @@ type Result struct {
WorktreeRoot string `json:"worktreeRoot"`
SourceRoot string `json:"sourceRoot"`
Branch string `json:"branch"`
Base string `json:"base,omitempty"`
Head string `json:"head"`
SourceDirty bool `json:"sourceDirty"`
}
@@ -52,13 +47,12 @@ type inspection struct {
Availability
repo gitcmd.Repo // pinned at the work tree root
head string
base string // the branch head came from; empty for the committed HEAD
prefix string
}
// Inspect checks Git and repository prerequisites without changing state.
func Inspect(ctx context.Context, repo gitcmd.Repo) Availability {
info, err := inspect(ctx, repo, "")
info, err := inspect(ctx, repo)
if err != nil {
return Availability{Available: false, Reason: err.Error()}
}
@@ -74,12 +68,10 @@ func Create(ctx context.Context, repo gitcmd.Repo, managedRoot string) (Result,
return CreateFrom(ctx, repo, managedRoot, "")
}
// CreateFrom is Create with the new branch based on base: a local branch, or a
// remote-tracking one such as origin/main, resolved by Git to the commit it
// names. An empty base is the committed HEAD. Naming no branch is
// ErrBaseUnknown and creates nothing.
func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, base string) (Result, error) {
info, err := inspect(ctx, repo, base)
// 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) {
info, err := inspectFrom(ctx, repo, ref)
if err != nil {
return Result{}, err
}
@@ -146,7 +138,6 @@ func CreateFrom(ctx context.Context, repo gitcmd.Repo, managedRoot, base string)
WorktreeRoot: worktreeRoot,
SourceRoot: info.RepoRoot,
Branch: branch,
Base: info.base,
Head: info.head,
SourceDirty: info.SourceDirty,
}, nil
@@ -177,7 +168,12 @@ func IsManagedPath(path, managedRoot string) bool {
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func inspect(ctx context.Context, repo gitcmd.Repo, base string) (inspection, error) {
func inspect(ctx context.Context, repo gitcmd.Repo) (inspection, error) {
return inspectFrom(ctx, repo, "")
}
func inspectFrom(ctx context.Context, repo gitcmd.Repo, ref string) (inspection, error) {
ref = strings.TrimSpace(ref)
workspaceRoot := strings.TrimSpace(repo.Dir)
if workspaceRoot == "" {
return inspection{}, errors.New("project folder is required")
@@ -196,17 +192,29 @@ func inspect(ctx context.Context, repo gitcmd.Repo, base string) (inspection, er
return inspection{}, fmt.Errorf("project folder is not inside a Git repository: %w", gitcmd.ErrNotRepository)
}
top := repo.Top()
head, _, err := runGit(ctx, top, "rev-parse", "--verify", "HEAD")
if ref == "" {
ref = "HEAD"
} else if ref != "HEAD" {
sources, err := Sources(ctx, repo)
if err != nil {
return inspection{}, err
}
found := false
for _, source := range sources {
found = found || source.Ref == ref
}
if !found {
return inspection{}, fmt.Errorf("%w: %s", ErrSourceUnknown, ref)
}
}
head, _, err := runGit(ctx, top, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
if err != nil || strings.TrimSpace(head) == "" {
if ref != "HEAD" {
return inspection{}, fmt.Errorf("%w: %s", ErrSourceUnknown, ref)
}
return inspection{}, errors.New("the Git repository needs an initial commit before a worktree can be created")
}
head = strings.TrimSpace(head)
based := strings.TrimSpace(base)
if based != "" {
if head, err = resolveBase(ctx, top, based); err != nil {
return inspection{}, err
}
}
prefix, _, err := runGit(ctx, repo, "rev-parse", "--show-prefix")
if err != nil {
return inspection{}, fmt.Errorf("resolve selected project path inside repository: %w", err)
@@ -215,11 +223,10 @@ func inspect(ctx context.Context, repo gitcmd.Repo, base string) (inspection, er
if prefix != "" {
objectType, _, objectErr := runGit(ctx, top, "cat-file", "-t", head+":"+strings.TrimSuffix(prefix, "/"))
if objectErr != nil || strings.TrimSpace(objectType) != "tree" {
return inspection{}, errors.New("the selected project folder is not present in the committed HEAD; commit it before creating a worktree")
return inspection{}, errors.New("the selected project folder is not present in the source commit; choose a branch containing it")
}
}
branch, _, _ := runGit(ctx, top, "symbolic-ref", "--quiet", "--short", "HEAD")
branch = strings.TrimSpace(branch)
status, _, statusErr := runGit(ctx, top, "status", "--porcelain=v1", "--untracked-files=normal")
if statusErr != nil {
return inspection{}, fmt.Errorf("inspect Git working tree: %w", statusErr)
@@ -228,12 +235,11 @@ func inspect(ctx context.Context, repo gitcmd.Repo, base string) (inspection, er
Availability: Availability{
Available: true,
RepoRoot: top.WorkTree,
Branch: branch,
SourceDirty: strings.TrimSpace(status) != "" && (based == "" || based == branch),
Branch: strings.TrimSpace(branch),
SourceDirty: strings.TrimSpace(status) != "" && (ref == "HEAD" || ref == "refs/heads/"+strings.TrimSpace(branch)),
},
repo: top,
head: head,
base: based,
prefix: prefix,
}, nil
}
@@ -317,19 +323,3 @@ func stderrSuffix(stderr string) string {
}
return ": " + stderr
}
// resolveBase turns a branch name into the commit it points at. show-ref
// --verify accepts only an exact ref, so a name can never be read as an option
// or as a revision expression such as feature^ or HEAD~1.
func resolveBase(ctx context.Context, top gitcmd.Repo, name string) (string, error) {
if strings.ContainsAny(name, "\x00\n") {
return "", ErrBaseUnknown
}
for _, ref := range []string{"refs/heads/" + name, "refs/remotes/" + name} {
out, _, err := runGit(ctx, top, "show-ref", "--verify", "--hash", ref)
if sha := strings.TrimSpace(out); err == nil && sha != "" {
return sha, nil
}
}
return "", fmt.Errorf("%w: %q", ErrBaseUnknown, name)
}
@@ -35,7 +35,7 @@ func TestCreateFromBranchStartsFromThatBranchNotHead(t *testing.T) {
requireGit(t)
repo := twoBranches(t)
head := gitOut(t, repo, "rev-parse", "HEAD")
res, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "feature")
res, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "refs/heads/feature")
if err != nil {
t.Fatal(err)
}
@@ -43,7 +43,7 @@ func TestCreateFromBranchStartsFromThatBranchNotHead(t *testing.T) {
t.Fatalf("the worktree lacks the chosen branch's commit: %v", err)
}
want := gitOut(t, repo, "rev-parse", "feature")
if res.Head != want || res.Head == head || res.Base != "feature" {
if res.Head != want || res.Head == head {
t.Fatalf("result = %+v, want head %s from feature (HEAD was %s)", res, want, head)
}
if got := gitOut(t, res.WorktreeRoot, "rev-parse", "HEAD"); got != want {
@@ -64,9 +64,6 @@ func TestCreateFromEmptyBaseIsTheCommittedHead(t *testing.T) {
if _, err := os.Stat(filepath.Join(res.WorktreeRoot, "feature.txt")); !os.IsNotExist(err) {
t.Fatalf("an empty base must mean HEAD, stat err = %v", err)
}
if res.Base != "" {
t.Fatalf("base = %q, want none", res.Base)
}
}
func TestCreateFromUnknownBaseCreatesNothing(t *testing.T) {
@@ -74,10 +71,10 @@ func TestCreateFromUnknownBaseCreatesNothing(t *testing.T) {
repo := twoBranches(t)
managed := testenv.TempDir(t)
branches := gitOut(t, repo, "branch", "--list")
for _, name := range []string{"nope", "--help", "refs/heads/feature", "feature^", "HEAD~1", "feature\x00x", "-x", "feat*"} {
for _, name := range []string{"nope", "--help", "refs/heads/missing", "feature^", "HEAD~1", "feature\x00x", "-x", "feat*"} {
_, err := CreateFrom(context.Background(), opened(t, repo), managed, name)
if !errors.Is(err, ErrBaseUnknown) {
t.Errorf("base %q: err = %v, want ErrBaseUnknown", name, err)
if !errors.Is(err, ErrSourceUnknown) {
t.Errorf("base %q: err = %v, want ErrSourceUnknown", name, err)
}
}
if entries, _ := os.ReadDir(managed); len(entries) != 0 {
@@ -93,11 +90,11 @@ func TestCreateFromRemoteTrackingBranch(t *testing.T) {
repo := twoBranches(t)
sha := gitOut(t, repo, "rev-parse", "feature")
gitOut(t, repo, "update-ref", "refs/remotes/origin/release", sha)
res, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "origin/release")
res, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "refs/remotes/origin/release")
if err != nil {
t.Fatal(err)
}
if res.Head != sha || res.Base != "origin/release" {
if res.Head != sha {
t.Fatalf("result = %+v, want head %s", res, sha)
}
}
@@ -107,7 +104,7 @@ func TestCreateFromBranchCheckedOutElsewhereIsFine(t *testing.T) {
repo := twoBranches(t)
other := filepath.Join(testenv.TempDir(t), "other")
gitOut(t, repo, "worktree", "add", other, "feature")
if _, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "feature"); err != nil {
if _, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "refs/heads/feature"); err != nil {
t.Fatalf("a branch held by another worktree is only a starting point: %v", err)
}
}
@@ -119,11 +116,11 @@ func TestCreateFromReportsDirtyOnlyForTheCheckedOutBranch(t *testing.T) {
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("uncommitted\n"), 0o644); err != nil {
t.Fatal(err)
}
same, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), current)
same, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "refs/heads/"+current)
if err != nil {
t.Fatal(err)
}
other, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "feature")
other, err := CreateFrom(context.Background(), opened(t, repo), testenv.TempDir(t), "refs/heads/feature")
if err != nil {
t.Fatal(err)
}
@@ -144,7 +141,7 @@ func TestCreateFromChecksTheSubdirectoryAgainstTheBase(t *testing.T) {
}
gitOut(t, repo, "add", "app")
gitOut(t, repo, "commit", "-m", "app")
if _, err := CreateFrom(context.Background(), opened(t, sub), testenv.TempDir(t), "feature"); err == nil || errors.Is(err, ErrBaseUnknown) {
if _, err := CreateFrom(context.Background(), opened(t, sub), testenv.TempDir(t), "refs/heads/feature"); err == nil || errors.Is(err, ErrSourceUnknown) {
t.Fatalf("feature has no app/ directory, err = %v", err)
}
res, err := CreateFrom(context.Background(), opened(t, sub), testenv.TempDir(t), "")
+1 -1
View File
@@ -131,7 +131,7 @@ func TestInspectRejectsUncommittedSelectedSubdirectoryWithoutGitMutation(t *test
t.Fatal(err)
}
got := Inspect(context.Background(), opened(t, untracked))
if got.Available || !strings.Contains(got.Reason, "committed HEAD") {
if got.Available || !strings.Contains(got.Reason, "source commit") {
t.Fatalf("availability = %+v", got)
}
after, _, err := runGit(context.Background(), opened(t, repo), "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/delivery-")
@@ -0,0 +1,80 @@
package control
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
)
// CarryDraftAttachments preserves only the draft's stored attachment paths
// when a new conversation starts in a different workspace.
func CarryDraftAttachments(source, destination string, paths []string) error {
for _, path := range paths {
if err := carryDraftAttachment(source, destination, path); err != nil {
return err
}
}
return nil
}
func carryDraftAttachment(source, destination, path string) error {
rel := filepath.Clean(filepath.FromSlash(path))
if filepath.IsAbs(path) || filepath.Dir(rel) != filepath.Join(".reasonix", "attachments") {
return fmt.Errorf("draft attachment must be in the attachment directory")
}
sourceRoot, err := os.OpenRoot(source)
if err != nil {
return err
}
defer sourceRoot.Close()
f, err := sourceRoot.Open(rel)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
const limit = 25 << 20
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Size() > limit {
return fmt.Errorf("invalid draft attachment: %s", path)
}
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return err
}
if len(raw) > limit {
return fmt.Errorf("draft attachment exceeds 25 MB: %s", path)
}
destRoot, err := os.OpenRoot(destination)
if err != nil {
return err
}
defer destRoot.Close()
if err := destRoot.MkdirAll(filepath.Join(".reasonix", "attachments"), 0o700); err != nil {
return err
}
out, err := destRoot.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if os.IsExist(err) {
existing, openErr := destRoot.Open(rel)
if openErr == nil {
defer existing.Close()
data, readErr := io.ReadAll(io.LimitReader(existing, limit+1))
if readErr == nil && bytes.Equal(data, raw) {
return nil
}
}
}
if err != nil {
return err
}
_, writeErr := out.Write(raw)
closeErr := out.Close()
if writeErr != nil {
return writeErr
}
return closeErr
}
@@ -0,0 +1,46 @@
package control
import (
"os"
"path/filepath"
"testing"
)
func TestCarryDraftAttachmentsConfinesAndPreservesFiles(t *testing.T) {
source, target := t.TempDir(), t.TempDir()
path, err := SaveTextAttachmentInRoot(source, "draft context")
if err != nil {
t.Fatal(err)
}
for range 2 {
if err := CarryDraftAttachments(source, target, []string{path}); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(target, path), []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
if err := CarryDraftAttachments(source, target, []string{path}); err == nil {
t.Fatal("overwrote a destination collision")
}
out, _ := os.ReadFile(filepath.Join(target, path))
if string(out) != "existing" {
t.Fatal("destination changed")
}
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
link := ".reasonix/attachments/link.txt"
if err := os.Symlink(outside, filepath.Join(source, link)); err != nil {
t.Skip(err)
}
for _, invalid := range []string{outside, "../secret", ".reasonix/attachments/../../secret", link} {
if err := CarryDraftAttachments(source, target, []string{invalid}); err == nil {
t.Fatalf("accepted escaping attachment %q", invalid)
}
}
if _, err := os.Stat(filepath.Join(target, link)); !os.IsNotExist(err) {
t.Fatal("copied escaping symlink")
}
}
+6
View File
@@ -0,0 +1,6 @@
- 新会话的分支入口升级为「配置工作目录」,可选起始分支并在首次发送时创建独立 worktree;已有对话保留分支切换。入口位于输入框内底部工具栏,与同排控件使用统一的胶囊圆角。关联 #13264。
- New conversations choose a base branch for a worktree on first send; existing ones keep branch switching. Both controls use the composer's bottom toolbar and share its pill radius. Refs #13264.
- 仅使用已提交代码;草稿附件随任务迁移,失败时保留原草稿。聊天区五个配置菜单统一采用紧凑的字号、间距、圆角与选中态,与软件整体风格保持一致。关联 #13264。
- Worktrees use committed code and carry draft attachments; failures preserve drafts. All five composer menus use compact typography, spacing and corners to match the app. Refs #13264.
- 所有窄屏聊天区的空输入框工具栏在控件获得焦点时保持紧凑;输入文字、添加附件或打开菜单时展开,避免按钮在按下与松开鼠标之间移动。关联 #13264。
- Empty toolbars in all narrow chat panes stay compact on focus, expanding for text, attachments or open menus. Buttons stay under the pointer throughout a click. Refs #13264.
+3
View File
@@ -39,6 +39,9 @@ const (
// wrong. Declared and not inferred, for the reason the sensitive paths are: no
// spelling tells a mirrored contract from a struct with json tags.
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/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"},