mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #13273 from SivanCola/codex/13264-session-worktree
feat(studio): 会话 worktree 配置与统一聊天区菜单 / session workspace setup
This commit is contained in:
43 files changed
+1109
-118
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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), "")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user