From 2d65cb07b0206efe5d83b2433e7529da680aee3e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:59:47 +0800 Subject: [PATCH 1/5] feat(studio): complete branch and worktree preparation flow Problem: workspace configuration disappeared after the first task and creating an isolated worktree left the composer without visible progress or recovery. Root cause: workspace selection, Git branch switching and runtime construction had separate UI lifetimes, with no cancellable preparation/publication boundary. Fix: keep one searchable workspace palette across conversation states; expose real preparation phases with operation-ID reconciliation, retry and cancellation. Serialize cancellation against publication, terminate checkout descendants and clean unpublished resources before acknowledging cancellation. Preserve task and attachment drafts, and reuse existing typography throughout status screens. Verification: 5,231 frontend tests, typecheck and production build; root and Desktop Go suites; affected backend race tests; lint and repository checks; real Git browser checks for switch, failure/retry, cancellation and handoff. Include light/dark and wide/narrow before-and-after screenshots and design QA. --- design-qa.md | 170 +++++++++++------- desktop/frontend-next/src/actions.ts | 1 + desktop/frontend-next/src/i18n/en_composer.ts | 76 +++++++- desktop/frontend-next/src/i18n/kernel.ts | 7 + desktop/frontend-next/src/port/mock.ts | 7 +- .../frontend-next/src/port/mock_workspace.ts | 26 +++ desktop/frontend-next/src/port/port.ts | 3 + desktop/frontend-next/src/port/sse.ts | 12 ++ desktop/frontend-next/src/port/workspace.ts | 12 ++ .../src/styles/workspace-path.css | 16 ++ .../src/styles/worktree-setup.css | 50 ++++++ desktop/frontend-next/src/styles/worktree.css | 62 ++++--- desktop/frontend-next/src/ui/BranchChip.tsx | 19 +- desktop/frontend-next/src/ui/Composer.tsx | 110 ++++-------- desktop/frontend-next/src/ui/Pane.tsx | 9 +- .../frontend-next/src/ui/WorkspacePath.tsx | 32 ++++ desktop/frontend-next/src/ui/WorktreeChip.tsx | 133 +++++++------- .../src/ui/WorktreeSetupPage.test.tsx | 54 ++++++ .../src/ui/WorktreeSetupPage.tsx | 60 +++++++ desktop/frontend-next/src/ui/composerChips.ts | 49 +++++ .../ui/pane-tree-refresh.interaction.test.tsx | 30 ++-- .../src/ui/useWorkspaceBranches.test.tsx | 24 +++ .../src/ui/useWorkspaceBranches.ts | 26 +++ .../src/ui/useWorktreeSetup.test.tsx | 55 ++++++ .../frontend-next/src/ui/useWorktreeSetup.ts | 136 +++++++++++--- .../ui/worktree-composer.interaction.test.tsx | 147 +++++++++++++-- .../workspace-flow/after-dark-narrow.png | Bin 0 -> 115441 bytes .../images/workspace-flow/after-dark-wide.png | Bin 0 -> 232291 bytes .../workspace-flow/after-light-narrow.png | Bin 0 -> 109393 bytes .../workspace-flow/after-light-wide.png | Bin 0 -> 221952 bytes .../workspace-flow/before-dark-narrow.png | Bin 0 -> 258221 bytes .../workspace-flow/before-dark-wide.png | Bin 0 -> 500986 bytes .../workspace-flow/before-light-narrow.png | Bin 0 -> 131435 bytes .../workspace-flow/before-light-wide.png | Bin 0 -> 188090 bytes docs/workspace-preparation.md | 126 +++++++++++++ internal/assembly/boot/build.go | 18 +- internal/assembly/boot/build_lifetime.go | 18 ++ internal/assembly/boot/build_lifetime_test.go | 46 +++++ internal/frontend/serve/hub.go | 1 + internal/frontend/serve/routes.go | 3 + internal/frontend/serve/serve.go | 1 + internal/frontend/serve/workspace.go | 18 +- internal/frontend/serve/workspace_prepare.go | 165 +++++++++++++++++ .../frontend/serve/workspace_prepare_test.go | 132 ++++++++++++++ internal/platform/worktree/cancel_test.go | 80 +++++++++ internal/platform/worktree/rollback.go | 28 +++ internal/platform/worktree/worktree.go | 36 +++- .../session/control/workspace_preparation.go | 117 ++++++++++++ .../control/workspace_preparation_test.go | 85 +++++++++ tools/repolint/wireparity.go | 1 + 50 files changed, 1895 insertions(+), 306 deletions(-) create mode 100644 desktop/frontend-next/src/styles/workspace-path.css create mode 100644 desktop/frontend-next/src/styles/worktree-setup.css create mode 100644 desktop/frontend-next/src/ui/WorkspacePath.tsx create mode 100644 desktop/frontend-next/src/ui/WorktreeSetupPage.test.tsx create mode 100644 desktop/frontend-next/src/ui/WorktreeSetupPage.tsx create mode 100644 desktop/frontend-next/src/ui/composerChips.ts create mode 100644 desktop/frontend-next/src/ui/useWorkspaceBranches.test.tsx create mode 100644 desktop/frontend-next/src/ui/useWorkspaceBranches.ts create mode 100644 desktop/frontend-next/src/ui/useWorktreeSetup.test.tsx create mode 100644 docs/images/workspace-flow/after-dark-narrow.png create mode 100644 docs/images/workspace-flow/after-dark-wide.png create mode 100644 docs/images/workspace-flow/after-light-narrow.png create mode 100644 docs/images/workspace-flow/after-light-wide.png create mode 100644 docs/images/workspace-flow/before-dark-narrow.png create mode 100644 docs/images/workspace-flow/before-dark-wide.png create mode 100644 docs/images/workspace-flow/before-light-narrow.png create mode 100644 docs/images/workspace-flow/before-light-wide.png create mode 100644 docs/workspace-preparation.md create mode 100644 internal/assembly/boot/build_lifetime.go create mode 100644 internal/assembly/boot/build_lifetime_test.go create mode 100644 internal/frontend/serve/workspace_prepare.go create mode 100644 internal/frontend/serve/workspace_prepare_test.go create mode 100644 internal/platform/worktree/cancel_test.go create mode 100644 internal/session/control/workspace_preparation.go create mode 100644 internal/session/control/workspace_preparation_test.go diff --git a/design-qa.md b/design-qa.md index 0b3cdd9853..994e5fdc81 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,86 +1,130 @@ --- -status: active owner: @esengine backup: @SivanCola -reviewed: 2026-10-13 +status: active +reviewed: 2026-10-17 --- -# Foundation palette verification +# Workspace flow design QA / 工作目录流程视觉验收 final result: passed -Clear Blue and Tea Green implement the two approved colour directions for -[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909). -The existing default remains the baseline. Layout, typography, copy and brand -assets follow the product rather than generated mockup variations. +## Visual truth / 视觉依据 -## Screenshots +- Selected target: the second displayed image from the latest design set. +- 选定方案:最近一组生成图中的第二张。 +- Source: selected direct-search palette reference, reviewed locally before implementation. +- Implementation: production frontend on a loopback-only real Go host with disposable Git fixtures. +- 实现:真实 Go host 加载的生产前端构建;没有另建静态原型。 -Captured from the actual Studio components with synthetic performance-harness -data, one conversation turn and an empty composer. Fixture workspace and host -names are not real user data. Screenshots use 1:1 density. +## Capture and normalization / 截图与归一化 -| Palette | Light, 1280 × 720 | Dark, 1280 × 720 | +Evidence directory / 证据目录: + +Local design references remain review-only. Published before/after captures are +linked from the pull request; fixture content contains no account credentials. + +设计参考图保留于本地验收;公开前后对比图见 PR,使用不含账号凭据的临时测试数据。 + +Published captures / 公开截图: + +| View / 视图 | Before / 修改前 | After / 修改后 | | --- | --- | --- | -| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) | -| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) | -| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) | +| Light, wide / 浅色宽屏 | [Before](docs/images/workspace-flow/before-light-wide.png) | [After](docs/images/workspace-flow/after-light-wide.png) | +| Dark, wide / 深色宽屏 | [Before](docs/images/workspace-flow/before-dark-wide.png) | [After](docs/images/workspace-flow/after-dark-wide.png) | +| Light, narrow / 浅色窄屏 | [Before](docs/images/workspace-flow/before-light-narrow.png) | [After](docs/images/workspace-flow/after-light-narrow.png) | +| Dark, narrow / 深色窄屏 | [Before](docs/images/workspace-flow/before-dark-narrow.png) | [After](docs/images/workspace-flow/after-dark-narrow.png) | -| Palette | Light, 390 × 844 | Dark, 390 × 844 | -| --- | --- | --- | -| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) | -| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) | -| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) | +Before captures use the original new-conversation configuration; after captures +show the unified entry still available in a bound worktree after task delivery. +The viewports are 1280 × 800 and 390 × 844 CSS pixels. -## Visual comparison +修改前为原有新会话配置,修改后展示任务交接后仍可使用的统一入口。 +公开截图分别使用 1280 × 800 与 390 × 844 CSS 像素视口。 -The approved light mockups were 1619 × 971 pixels, including a caption outside -the application. They were viewed together with the implementation screenshots. -Comparison excluded that caption and assessed the palette direction, rather -than treating generated layout drift as a product specification. +| Evidence / 证据 | Frame / 尺寸与状态 | +| --- | --- | +| `reference.png` | 1312 × 1199 PNG; selected source / 选定源图 | +| `branch-palette-detail.png` | 400 × 490 CSS px; 800 × 980 PNG at DPR 2 / 面板原生 PNG 截图 | +| `branch-palette.png` | 1280 × 720 CSS viewport; 2560 × 1440 PNG at DPR 2 / 桌面视口 | +| `comparison-complete.png` | Full panel and focused search/selection comparison / 整体及搜索、选中行局部对照 | +| `branch-palette-narrow.png` | 480 × 720 CSS viewport; emulation restored / 窄窗口检查后已恢复 | -- Typography: existing families, size, line height and tracking retained. Plain - light palettes now share the default prose weight; user overrides still win. -- Spacing: existing grid, controls, corners and panel proportions retained. -- Colours: cool white/blue and warm white/tea-green surfaces match the approved - directions. Text, syntax and button contrast checks pass in both schemes. -- Assets: existing logos and icons retained; no added image or sky layers. -- Copy: existing workbench text retained; new palette names and descriptions - have Chinese and English labels. +The source component crop is approximately `(200,58,915,1005)` pixels. +The comparison renders both component widths at 360px. The source is scaled +by 360/915. The implementation uses 0.9 of its 400 CSS px width, equivalent +to scaling the 800px raster by 0.45. No content is stretched. -Full views made the navigation, transcript and composer readable without -additional crops. Dark palettes extend the approved hues; the source mockups -did not specify a dark layout. Phone captures confirm no horizontal overflow. +按面板区域统一到 360px 宽度后,在同一浏览器画面并排检查整体与局部。 +源图不是完整应用视口,不对外围留白或窗口尺寸作像素等同判断。 -## Findings and iteration history +Both states show a bound worktree, an empty branch search and a collapsed path. +The real fixture has five branches and a different directory name; those values +remain authoritative rather than being replaced with mock content. -1. Default preview inherited the selected pack, and plain packs changed reading - surfaces and weight. Shared canonical default variables and plain-surface - rules now keep the preview independent and typography consistent. -2. Initial comparison found neutral selected navigation and a dark send button - overridden by body colour. Shared pack selectors now use accent ink/wash and - the accent/foreground pair; disabled controls remain neutral. -3. Revised screenshots were compared with both approved directions. No - actionable P0/P1/P2 differences remain. Sky themes now join image themes in - the expandable collection; the active backdrop remains in the first group. +两侧均为已绑定 worktree、空搜索、路径折叠。真实仓库多一个分支,项目名和分支名 +来自真实状态;因此列表高度差和具体文字差异不作为设计缺陷。 -## Behavioural evidence +## Fidelity surfaces / 必查项 -- Immediate selection, return to default, light/dark and system-following modes - verified in the browser; each preview follows its own scheme tokens. -- Default comparison: original and revised Studio CSS applied to the same DOM. - Fourteen style properties matched for 735 visible elements in each scheme - under a light system and 734 in each scheme under a dark system. -- Compared colour, background, border, shadow, font, size, weight, line height, - tracking, width, height, padding, margin and radius. -- Go integration tests cover selection, config persistence, server recreation - and reset to the default for both new built-in IDs. -- Frontend neighbourhood: 22 test files, 209 tests passed; production build and - typecheck passed. Browser warning/error logs were empty. -- Theme and HTTP server package tests, repository checks and whitespace checks - passed. Lint budgets were not increased. +| Surface / 项目 | Verdict / 结论 | +| --- | --- | +| Typography / 字体 | Existing Inter/Noto Sans SC stack and weight tokens; UI 13px, metadata 12px, helper 11px, status title 22px. / 复用现有字体、字重和字号。 | +| Spacing / 间距 | 400px palette, 16px padding, 44px branch targets; lightweight section dividers. / 保留方案的直接选择布局,以间距分组。 | +| Colors / 色彩 | Existing float, hairline, brand, muted and semantic status tokens. / 复用现有浮层、分隔线、强调色和状态色。 | +| Assets / 资源 | Existing StudioIcon source assets and shared CopyButton; no rasterized UI or new decorative drawings. / 复用图标与复制组件。 | +| Content / 文案 | Full raw identities stay in accessible names, tooltips, disclosures and copy actions. / 缩略显示不改变真实分支或路径。 | -## Remaining coverage +The user's later instruction to match surrounding font sizes takes precedence +over the mock's larger typography. Existing action wording is retained for the +workspace trigger and heading. These are intentional product constraints. -Windows native installer acceptance and hosted CI are separate from these -local results. The production build retains its existing large-chunk warning. +后续明确要求字号尽量与其他位置统一,因此实现优先使用现有字号 token。 +入口和标题保留「配置工作目录」这一既有名称,以保持动作一致。 + +## Findings and comparison history / 问题与对照历史 + +- No actionable P0/P1/P2 findings remain in the combined visual comparison. +- 正式整体和局部并排对照未发现残留的 P0/P1/P2 问题。 +- Before final comparison, a long branch could receive a second end ellipsis. + The value now uses a flexible prefix and fixed suffix, preserving its identity. +- 正式对照前修复了长分支二次尾部截断,末尾标识始终保留,完整值可悬停查看。 +- The 480px check placed the panel at x=68…468, within the viewport, with no + horizontal overflow. Expanded paths also stayed within the desktop viewport. +- 窄窗口及长路径展开均未遮断核心操作;失败详情展开时操作栏保持可见。 + +## Interaction checks / 交互验证 + +- Direct search, current selection, occupied branches, keyboard traversal, + Escape focus return, failed reads, retries and full-path copy. +- 直接搜索、选中态、占用禁用、键盘导航、Escape 焦点恢复、读取失败重试、路径复制。 +- Real Git: branch switch, selected creation ref, long wait, cancellation, + process termination, rollback, checkout failure, retry, ready and task handoff. +- 真实 Git:切换、起点选择、长等待、取消、进程终止、回收、检出失败、重试和成功交接。 +- Fresh production-bundle page: zero console errors and one original user task. +- 最终构建重新载入后控制台无错误,原用户任务只出现一次。 + +Creation, success, failure, cancellation and retained-result screens extend the +selected typography and surface hierarchy. No separate pixel-exact mock was +provided for these states; their behavior and consistency were inspected live. + +其他状态页沿用选定方案的信息层级和字号;这些页面没有单独提供逐像素参考图, +因此按真实流程、状态准确性、信息完整性和视觉一致性验收。 + +## Validation and limits / 测试与边界 + +- Frontend: 631 files, 5,231 tests passed; typecheck and production build passed. +- 前端全量、类型检查和生产构建通过。 +- Backend: focused race tests passed in worktree, serve, control and boot. +- 后端相关四个包的定向并发测试通过。 +- macOS browser plus real Git; model replies use a local test service. +- 使用 macOS 浏览器和真实 Git;模型回复由本地测试服务提供。 +- Windows/Linux native execution and packaged Electron acceptance were not run. +- 未进行 Windows/Linux 原生运行或 Electron 安装包验收。 + +## Follow-up polish / 后续微调 + +No blocking visual work remains. Native shell qualification remains a separate +delivery check; this change does not alter shell chrome or window controls. + +没有剩余的阻塞视觉问题。原生安装包验收属于独立交付检查,本次未修改窗口外壳。 diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index c19f3094f6..179a08975a 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -69,6 +69,7 @@ export const ACTIONS: UIAction[] = [ { id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" }, { id: "workspace.configure", kind: "view", target: "none", proof: "interaction" }, + { id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "workspace.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" }, diff --git a/desktop/frontend-next/src/i18n/en_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts index d8244ac528..fc31cc852e 100644 --- a/desktop/frontend-next/src/i18n/en_composer.ts +++ b/desktop/frontend-next/src/i18n/en_composer.ts @@ -1,10 +1,71 @@ // 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 = { + "工作目录准备": "Preparing workspace", + "正在提交创建请求": "Submitting creation request", + "正在检查起始分支": "Checking source branch", + "正在创建工作目录": "Creating workspace", + "正在检出已提交文件": "Checking out committed files", + "正在复制任务附件": "Copying task attachments", + "正在绑定工作目录": "Binding workspace", + "正在清理未完成目录": "Cleaning up incomplete workspace", + "正在中止并清理": "Stopping and cleaning up", + "工作目录已绑定": "Workspace bound", + "worktree 创建失败": "Could not create worktree", + "已取消创建": "Creation cancelled", + "工作目录已就绪": "Workspace ready", + "需要确认创建结果": "Confirm creation outcome", + "正在取消创建…": "Cancelling creation…", + "正在创建 worktree…": "Creating worktree…", + "任务尚未发送,输入和附件已为你保留。": "The task has not been sent. Your draft and attachments are retained.", + "任务尚未发送,可以继续编辑。": "The task has not been sent. You can continue editing.", + "目录已经创建成功,取消未能撤回目录切换。任务尚未发送。": "The workspace was already created and bound. Cancellation could not reverse it. The task has not been sent.", + "即将进入对话,自动开始任务。": "Opening the conversation and starting your task.", + "暂不能确认操作或清理结果,请先查看详情并核对。": "The operation or cleanup outcome is unconfirmed. Check the details before proceeding.", + "正在中止操作并清理未完成的目录。": "Stopping the operation and cleaning up the incomplete workspace.", + "创建耗时较长,尚未收到完成确认。": "Creation is taking longer. Completion has not been confirmed.", + "准备完成后将自动开始任务。": "Your task will start automatically when the workspace is ready.", + "正在等待状态确认": "Waiting for confirmation", + "创建已中止,目录已清理": "Creation stopped and workspace cleaned up", + "已等待 {n} 秒": "Waiting {n}s", + "用户任务": "Your task", + "请查看附件": "See the attachments", + "附件({n})": "Attachments ({n})", + "待交接": "Pending handoff", + "已保留": "Retained", + "查看详情": "View details", + "收起详情": "Hide details", + "仍在等待创建完成。你可以继续等待、查看详情,或取消创建。": "Still waiting for creation. You can keep waiting, view details, or cancel.", + "操作编号": "Operation ID", + "当前状态": "Current state", + "再次核对": "Check again", + "重试创建": "Retry creation", + "返回编辑": "Back to editing", + "任务和附件正在交接…": "Handing over the task and attachments…", + "取消创建": "Cancel creation", + "正在取消…": "Cancelling…", + "准备工作目录": "Preparing workspace", + "创建失败": "Creation failed", + "创建已取消": "Creation cancelled", + "切换当前分支": "Switch current branch", + "创建请求不完整或与原操作不一致,请返回编辑后重试": "The creation request is incomplete or differs from the original. Return to editing and try again.", + "无法确认此前创建的结果,请先检查工作目录,避免重复创建": "The previous outcome is unconfirmed. Check the workspace before creating another.", + "已有工作目录正在准备或等待清理,请先确认该操作的结果": "A workspace is being prepared or awaiting cleanup. Reconcile that operation first.", + "工作目录创建失败,任务和附件已保留": "Workspace creation failed. The task and attachments are retained.", + "未完成目录尚未清理,暂不能重试创建,请查看详情": "The incomplete workspace could not be cleaned up. Check the details before retrying.", + "创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.", + "创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.", "配置工作目录": "Configure workspace", - "独立 worktree · {branch}": "New worktree · {branch}", + "独立 worktree · {branch}": "Isolated worktree · {branch}", "当前目录 · {branch}": "Current folder · {branch}", "新建 worktree": "New worktree", + "独立 worktree": "Isolated worktree", + "此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.", + "如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.", + "使用当前目录": "Use current folder", + "关闭工作目录配置": "Close workspace configuration", + "发送消息时开始创建。": " Creation starts when you send the message.", + "选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.", "为此会话创建独立的代码目录": "Create a separate code folder for this session", "起始分支": "Source branch", "任务分支": "Task branch", @@ -14,6 +75,19 @@ export const EN_COMPOSER: Record = { "选择起始分支": "Choose source branch", "搜索分支": "Search branches", "搜索分支…": "Search branches…", + "当前目录": "Current folder", + "查看完整路径": "Show full path", + "复制完整路径": "Copy full path", + "复制操作编号": "Copy operation ID", + "任务分支自动创建": "A task branch will be created automatically", + "搜索起始分支…": "Search source branches…", + "搜索本地分支…": "Search local branches…", + "分支读取失败": "Could not read branches", + "正在读取分支…": "Reading branches…", + "其他 worktree 占用": "Used by another worktree", + "起点": "Source", + "从已提交版本创建,任务和附件将在就绪后发送": "Create from the committed version; the task and attachments are sent when ready", + "选择后立即切换,同目录会话也会受影响": "Switches immediately; other sessions in this folder are affected", "远程分支": "Remote branches", "当前分支": "Current branch", "没有匹配的分支": "No matching branches", diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index 463bce1377..989d476154 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -170,6 +170,13 @@ const SAID: Record = { "workspace.changing_disabled": "这台服务器不支持切换工作区", "workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支", "workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree", + "workspace.worktree_request_invalid": "创建请求不完整或与原操作不一致,请返回编辑后重试", + "workspace.worktree_operation_unknown": "无法确认此前创建的结果,请先检查工作目录,避免重复创建", + "workspace.worktree_busy": "已有工作目录正在准备或等待清理,请先确认该操作的结果", + "workspace.worktree_failed": "工作目录创建失败,任务和附件已保留", + "workspace.worktree_cleanup_failed": "未完成目录尚未清理,暂不能重试创建,请查看详情", + "workspace.worktree_cancelled": "创建已取消,任务尚未发送", + "workspace.worktree_timeout": "创建等待超时,任务和附件已保留,可查看详情后重试", "settings.unknown_preset": "不存在该预设", "settings.unknown_model": "没有找到这个模型,默认模型没有改动", "settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置", diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index 87e7606b73..921398737b 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval"; import { HttpError } from "./port"; import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port"; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; -import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace"; +import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace"; import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture"; import { MockExecutionHold, mockExecutionGraph } from "./mock_graph"; import { mockStorage, mockStoragePlan } from "./mock_storage"; @@ -238,6 +238,11 @@ export class MockPort extends MockWorkspace implements AgentPort { return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch); } + private preparations = new MockPreparations(); + prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) { return this.preparations.prepare(id, selection, async () => { await this.isolateWorkspace(selection); return (await this.status()).workspaceRoot ?? ""; }); } + worktreePreparation(id: string) { return this.preparations.read(id); } + cancelWorktreePreparation(id: string) { return this.preparations.read(id); } + async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) { await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)"); this.branchState.branch = "reasonix/delivery-demo"; diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts index 65df451e57..6a63322c45 100644 --- a/desktop/frontend-next/src/port/mock_workspace.ts +++ b/desktop/frontend-next/src/port/mock_workspace.ts @@ -1,3 +1,4 @@ +import { reason } from "../i18n/kernel"; import { HttpError } from "./http_error"; import type { KeptText } from "./attachment"; import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace"; @@ -118,3 +119,28 @@ export class MockWorkspace extends MockFeedback { } } + +export class MockPreparations { + private items = new Map(); + async prepare(id: string, selection: import("./workspace").WorktreeSelection, create: () => Promise) { + const found = this.items.get(id); + if (found) return found; + const value: import("./workspace").WorktreePreparation = { id, phase: "validating", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() }; + try { + value.workspaceRoot = await create(); + value.phase = "ready"; + value.branch = "reasonix/demo"; + } catch (error) { + value.phase = "failed"; + value.code = "workspace.worktree_failed"; + value.detail = reason(error); + } + this.items.set(id, value); + return value; + } + async read(id: string) { + const value = this.items.get(id); + if (!value) throw new Error("unknown worktree operation"); + return value; + } +} diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index 9941dde03e..74f85c6854 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -451,6 +451,9 @@ export interface AgentPort { setWorkspace(path: string): Promise; isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise; worktreeSources(): Promise; + prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection): Promise; + worktreePreparation(id: string): Promise; + cancelWorktreePreparation(id: string): Promise; // 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 diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index c45535a27b..7c17fcf195 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -407,6 +407,18 @@ export class SsePort extends SseWorkspace implements AgentPort { return this.get("/workspace/worktree-sources"); } + prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) { + return this.post0("/workspace/prepare", { id, selection }); + } + + worktreePreparation(id: string) { + return this.get(`/workspace/prepare/${encodeURIComponent(id)}`); + } + + cancelWorktreePreparation(id: string) { + return this.post0(`/workspace/prepare/${encodeURIComponent(id)}/cancel`); + } + async openInEditor(): Promise<{ editor: string; root: string }> { return this.post0<{ editor: string; root: string }>("/workspace/editor"); } diff --git a/desktop/frontend-next/src/port/workspace.ts b/desktop/frontend-next/src/port/workspace.ts index 2b617792e0..39f790b650 100644 --- a/desktop/frontend-next/src/port/workspace.ts +++ b/desktop/frontend-next/src/port/workspace.ts @@ -128,6 +128,18 @@ export interface WorktreeSelection { attachments?: string[]; } +export interface WorktreePreparation { + id: string; + phase: string; + sourceRoot: string; + ref: string; + startedAt: number; + branch?: string; + workspaceRoot?: string; + code?: string; + detail?: string; +} + export interface CommitFile { path: string; // git raw status letter: "A", "M", "D", "T". diff --git a/desktop/frontend-next/src/styles/workspace-path.css b/desktop/frontend-next/src/styles/workspace-path.css new file mode 100644 index 0000000000..0621b88f28 --- /dev/null +++ b/desktop/frontend-next/src/styles/workspace-path.css @@ -0,0 +1,16 @@ +.workspace-path { display: flex; align-items: flex-start; gap: 6px; min-width: 0; color: var(--muted); font: var(--fs-meta)/1.5 var(--ui) } +.workspace-path details { flex: 1; min-width: 0 } +.workspace-path summary { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 6px 4px; border-radius: 6px; list-style: none; cursor: pointer } +.workspace-path summary::-webkit-details-marker { display: none } +.workspace-path summary > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis } +.workspace-path summary > .studio-icon { width: 17px; height: 17px; flex-shrink: 0 } +.workspace-path details[open] summary > .studio-icon:last-child { transform: rotate(180deg) } +.workspace-path summary:hover { color: var(--text); background: var(--float-hi) } +.workspace-path code { display: block; padding: 6px 4px 10px; overflow-wrap: anywhere; user-select: text; color: var(--text); font: var(--fs-micro)/1.7 var(--mono) } +.workspace-path .workspace-path-copy { display: grid; place-items: center; width: 36px; min-width: 36px; height: 38px; margin: 0; padding: 6px; color: var(--muted); background: none; border: 0; border-radius: 6px; cursor: pointer } +.workspace-path .workspace-path-copy:hover { color: var(--text); background: var(--float-hi) } +.workspace-path .workspace-path-copy .studio-icon { width: 18px; height: 18px } +.workspace-path :is(summary, button):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } +.workspace-branch-value { display: flex; min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap } +.workspace-branch-value > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis } +.workspace-branch-value > span:last-child { flex-shrink: 0 } diff --git a/desktop/frontend-next/src/styles/worktree-setup.css b/desktop/frontend-next/src/styles/worktree-setup.css new file mode 100644 index 0000000000..5e525241e9 --- /dev/null +++ b/desktop/frontend-next/src/styles/worktree-setup.css @@ -0,0 +1,50 @@ +.worktree-stage-host { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain } +.worktree-setup { width: min(580px, calc(100% - 48px)); margin: 0 auto; padding: clamp(24px, 7vh, 78px) 0 28px; color: var(--text); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) } +.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px } +.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 } +.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) } +.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) } +.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em } +.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 } +.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) } +.worktree-setup-phase > span:first-child { display: flex; align-items: center; gap: 10px; min-width: 0 } +.worktree-setup-phase > span:last-child { white-space: nowrap; color: var(--muted); font-size: var(--fs-micro) } +.worktree-setup-phase .studio-icon { width: 19px; height: 19px; flex-shrink: 0 } +.worktree-spinner { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; border: 2px solid var(--hair); border-top-color: var(--brand); border-right-color: var(--brand); border-radius: 50%; animation: worktree-spin 1.2s linear infinite } +.worktree-setup-branches { padding: 20px 0; border-bottom: 1px solid var(--hair) } +.worktree-setup dl { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin: 0; font-size: var(--fs-meta) } +.worktree-setup dt { color: var(--muted) } +.worktree-setup dd { display: flex; align-items: center; gap: 8px; margin: 0; overflow-wrap: anywhere; min-width: 0 } +.worktree-setup dd > .studio-icon { width: 16px; height: 16px; color: var(--muted); flex-shrink: 0 } +.worktree-setup-draft { padding: 20px 0; border-bottom: 1px solid var(--hair) } +.worktree-setup-label { color: var(--muted); font-size: var(--fs-meta) } +.worktree-setup-task { margin: 7px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui); max-height: 150px; overflow: auto } +.worktree-setup-files { margin-top: 18px } +.worktree-setup-files > div { display: flex; align-items: center; gap: 9px; padding: 8px 0; font-size: var(--fs-meta) } +.worktree-setup-files .studio-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--muted) } +.worktree-setup-files > div > span { overflow-wrap: anywhere; min-width: 0 } +.worktree-setup-files small { margin-left: auto; color: var(--muted); font-size: var(--fs-micro); white-space: nowrap } +.worktree-setup-location { padding: 8px 0; border-bottom: 1px solid var(--hair) } +.worktree-setup-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; position: sticky; bottom: 0; z-index: 1; padding: 16px 0 4px; background: var(--surface) } +.worktree-setup-actions > div { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 9px } +.worktree-setup-actions .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 8px 14px; border-radius: 8px; font-size: var(--fs-ui) } +.worktree-setup-actions .studio-icon { width: 17px; height: 17px; flex-shrink: 0 } +.worktree-setup-detail-button { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: var(--fs-meta); cursor: pointer } +.worktree-setup-detail-button:hover { color: var(--text) } +.worktree-setup-handoff { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: var(--fs-meta) } +.worktree-setup-slow { margin: 10px 0 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.7 } +.worktree-setup-details { padding: 16px 0 0; font-size: var(--fs-micro) } +.worktree-setup-details dd { font-family: var(--mono); font-size: var(--fs-micro) } +.worktree-setup-details .copy { padding: 3px; flex-shrink: 0 } +.worktree-setup-details .copy .studio-icon { width: 14px; height: 14px } +.worktree-setup-details pre { padding: 12px; background: var(--raised); border-radius: 7px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fs-micro)/1.7 var(--mono) } +.worktree-setup[data-phase="failed"] .worktree-setup-phase, .worktree-setup[data-phase="cleanup_failed"] .worktree-setup-phase { background: color-mix(in srgb, var(--err) 8%, transparent); color: var(--err) } +.worktree-setup[data-phase="ready"] .worktree-setup-phase, .worktree-setup[data-phase="retained"] .worktree-setup-phase { background: color-mix(in srgb, var(--ok) 8%, transparent); color: var(--ok) } +.worktree-setup[data-phase="cancelled"] .worktree-setup-phase { background: var(--raised); color: var(--muted) } +.worktree-setup :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px } +.dropzone[data-worktree-setup] .fmain, .dropzone[data-worktree-setup] .shots, .dropzone[data-worktree-setup] .composeguide { display: none } +.dropzone[data-worktree-setup] { opacity: .65 } +@keyframes worktree-spin { to { transform: rotate(360deg) } } +@media (max-height: 800px) { .worktree-setup { padding-top: 20px } .worktree-setup-context { margin-bottom: 15px } .worktree-setup-description { margin-bottom: 16px } .worktree-setup-branches, .worktree-setup-draft { padding-block: 15px } } +@media (max-width: 600px) { .worktree-setup { width: calc(100% - 28px); padding-top: 22px } .worktree-setup-description { font-size: var(--fs-ui) } .worktree-setup-phase { flex-wrap: wrap; gap: 8px; font-size: var(--fs-meta) } .worktree-setup dl { grid-template-columns: 70px minmax(0, 1fr); gap: 10px } .worktree-setup-actions { flex-wrap: wrap } .worktree-setup-actions > div { margin-left: auto } } +@media (prefers-reduced-motion: reduce) { .worktree-spinner { animation: none } } diff --git a/desktop/frontend-next/src/styles/worktree.css b/desktop/frontend-next/src/styles/worktree.css index c15eb3e586..8804073483 100644 --- a/desktop/frontend-next/src/styles/worktree.css +++ b/desktop/frontend-next/src/styles/worktree.css @@ -1,28 +1,40 @@ .worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } :root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) } :root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) } -.worktree-panel { position: fixed; z-index: 1000; width: min(370px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 18px; border: 1px solid var(--hair); border-radius: 20px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: 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 } +.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) } +.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 } +.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) } +.workspace-palette-close { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--muted); background: none; cursor: pointer } +.workspace-palette-close:hover { background: var(--float-hi); color: var(--text) } +.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px } +.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) } +.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) } +.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) } +.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px } +.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer } +.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) } +.workspace-palette-base { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin-bottom: 12px; font-size: var(--fs-meta) } +.workspace-palette-base > span, .workspace-palette-base > small { color: var(--muted) } +.workspace-palette-base > strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: var(--w-med) } +.workspace-palette-base > small { grid-column: 1 / -1; font-size: var(--fs-micro) } +.workspace-palette-search { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 11px; margin-bottom: 10px; border: 1px solid var(--hair); border-radius: 9px; color: var(--muted); background: var(--raised) } +.workspace-palette-search:focus-within { border-color: var(--focus) } +.workspace-palette-search input { flex: 1; min-width: 0; width: 100%; border: 0; padding: 0; border-radius: 0; background: transparent; color: var(--text); font: inherit; outline: none } +.worktree-sources { max-height: 250px; overflow: auto; overscroll-behavior: contain } +.workspace-palette-group { display: block; padding: 8px; color: var(--muted); font-size: var(--fs-micro) } +.worktree-sources button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; background: none; color: var(--text); padding: 10px 8px; text-align: left; font: inherit; cursor: pointer } +.worktree-sources > div > button:last-child { border-bottom-color: transparent } +.worktree-sources button .workspace-palette-branch { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-ui) } +.worktree-sources button small { color: var(--muted); font-size: var(--fs-micro); white-space: nowrap } +.worktree-sources button > .studio-icon:last-child { width: 14px; height: 14px; color: var(--muted) } +.worktree-sources button:not(:disabled):hover { background: var(--float-hi); border-radius: 7px } +.worktree-sources button[aria-selected="true"] { color: var(--brand); background: var(--brand-wash); border-radius: 8px; border-bottom-color: transparent } +.worktree-sources button[aria-selected="true"] > .studio-icon:last-child { color: var(--brand) } +.worktree-sources button[aria-selected="true"] small { color: var(--brand); background: var(--brand-wash); padding: 2px 5px; border-radius: 4px } +.worktree-sources button:disabled { color: var(--muted); cursor: not-allowed } +.workspace-palette-note { margin: 12px 0 14px; color: var(--muted); font-size: var(--fs-micro); line-height: 1.6 } +.workspace-palette-footer { padding-top: 8px; border-top: 1px solid var(--hair) } +.workspace-palette-empty { padding: 18px 8px; text-align: center; color: var(--muted); font-size: var(--fs-meta) } +.workspace-palette-empty p { margin: 0 0 10px } +.workspace-palette :is(button, summary):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } +@media (max-width: 480px) { .workspace-palette { padding: 12px } .workspace-palette-identity { gap: 7px } .worktree-sources button { gap: 7px; padding-inline: 5px } .worktree-sources button small { max-width: 92px; white-space: normal; font-size: var(--fs-micro) } } diff --git a/desktop/frontend-next/src/ui/BranchChip.tsx b/desktop/frontend-next/src/ui/BranchChip.tsx index 461b28a5bd..bf5bcaf702 100644 --- a/desktop/frontend-next/src/ui/BranchChip.tsx +++ b/desktop/frontend-next/src/ui/BranchChip.tsx @@ -1,8 +1,9 @@ -import { useCallback, useId, useState, type CSSProperties } from "react"; +import { useId, type CSSProperties } from "react"; import { t } from "../i18n"; -import type { AgentPort, WorkspaceBranch, WorkspaceGit } from "../port/port"; +import type { AgentPort, WorkspaceGit } from "../port/port"; import { Picker, type MenuItem } from "./Menu"; import { StudioIcon } from "./StudioIcon"; +import { useWorkspaceBranches } from "./useWorkspaceBranches"; interface Props { port: AgentPort; @@ -51,15 +52,11 @@ function fitCard(anchor: HTMLDivElement) { // the pane's tree refresh; this component owns only the choosing. export function BranchChip({ port, git, changeCount, countElsewhere = false, onChanged, onSwitched, onError }: Props) { const branchTipId = useId(); - const [moving, setMoving] = useState(null); + const { locals, moving, loadBranches, switchTo } = useWorkspaceBranches(port, onChanged, onSwitched, onError); const busy = moving !== null; - const [locals, setLocals] = useState([]); // The list re-reads on open: a terminal or another window can move HEAD // while this pane is not looking, and a stale list would offer a switch // the repository no longer means. - const loadBranches = useCallback(() => { - void port.branches().then((r) => setLocals(r.branches)).catch(() => setLocals([])); - }, [port]); // A branch another worktree holds is listed but closed: git would refuse // the switch, so the row says where the branch lives instead of failing. const branchItems: MenuItem[] = [ @@ -71,14 +68,6 @@ export function BranchChip({ port, git, changeCount, countElsewhere = false, onC desc: b.worktree ? t("已由其他工作树检出") : undefined, })), ]; - const switchTo = (name: string) => { - if (busy) return; - setMoving(name); - void port.switchBranch(name) - .then((git) => { onChanged(); onSwitched?.(git); }) - .catch(onError) - .finally(() => setMoving(null)); - }; if (git && !git.repo && git.absent) { const refused = git.absent === "refused"; diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index 778a1b5993..ac7737c76c 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { t } from "../i18n"; import { UNCONFIGURED_REF } from "./ConnectNote"; import { reason } from "../i18n/kernel"; import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep"; -import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment, WorkspaceGit } from "../port/port"; +import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port"; import { Picker } from "./Menu"; import { BranchChip } from "./BranchChip"; import { WorktreeChip } from "./WorktreeChip"; -import { useWorktreeSetup } from "./useWorktreeSetup"; +import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup"; +import { WorktreeSetupPage } from "./WorktreeSetupPage"; import { Policy } from "./Policy"; import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu"; import { ModelGroupChips } from "./ModelGroupChips"; @@ -32,8 +34,11 @@ import { useDraft } from "./useDraft"; import { touchKeyboard } from "./touchKeyboard"; import { ImagePreview } from "./ImagePreview"; import { SideAskButton } from "./SideAskButton"; +import { chipId, chipName, isPicture, quoteBlock, releaseChip, type Chip } from "./composerChips"; interface Props { + worktreeTarget?: HTMLElement | null; + onWorktreePhase?: (phase: string | null) => void; fresh?: boolean; port: AgentPort; status: SessionStatus | null; @@ -73,62 +78,15 @@ interface Props { draftKey?: string; } -// What is riding along with this turn. An attachment travels as bytes or a path -// the kernel already saved; a long paste is held out of the box so eight hundred -// lines of log do not become the composer. -type Chip = - | { - k: "attachment"; - id: string; - name: string; - state: "adding" | "ready" | "failed"; - a?: Attachment; - blob?: File; - url?: string; - error?: string; - } - | PasteChip - | { k: "quote"; id: string; body: string; turn?: number; source?: string; lines: number }; - -function chipName(c: Chip): string { - if (c.k === "quote" && c.source) return t("引用 {name}", { name: c.source.split("/").at(-1) ?? c.source }); - if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn }); - return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name; -} - -// What the model reads instead of a bare blockquote. Which reply this is about -// is the kernel's turn number, so it is stated rather than left to be inferred -// from the words; a rebuilt transcript with no turn says nothing instead of -// guessing one. English like the kernel's other host-authored prefixes: the -// line is addressed to the model, not to the reader. -function quoteBlock(c: Extract): string { - // The path is a file name the workspace chose, so it cannot end the line or close the bracket. - const where = c.source?.replace(/[\u0000-\u001f\u007f\u2028\u2029[\]]/g, " ").trim(); - const head = where - ? `[Quoting text selected in the workspace file ${where}.]` - : c.turn === undefined - ? "[Quoting an earlier assistant reply in this conversation.]" - : `[Quoting the assistant reply from turn ${c.turn}.]`; - return [head, ...c.body.split("\n").map((line) => `> ${line}`)].join("\n"); -} - -function isPicture(c: Chip): boolean { - return c.k === "attachment" && (c.a?.image === true || (c.a?.image !== false && c.blob?.type.startsWith("image/") === true)); -} - -function releaseChip(c: Chip) { - if (c.k === "attachment" && c.url) URL.revokeObjectURL(c.url); -} - -let chipSeq = 0; -const chipId = () => `c${++chipSeq}`; - -export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false }: Props) { +export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) { const touch = touchKeyboard(); const providerOrder = useProviderOrder(); const modelGroups = useModelFilter(); const [submitting, setSubmitting] = useState(false); - const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged); + const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged, status?.sessionPath ?? ""); + const [setupDraft, setSetupDraft] = useState<{ text: string; files: string[] }>({ text: "", files: [] }); + const setupPhase = worktree.stage?.phase ?? null; + useEffect(() => { onWorktreePhase?.(setupPhase); }, [onWorktreePhase, setupPhase]); const { text, setText, beginSubmit, finishSubmit } = useDraft(draftKey, submitting); // The caret decides which token is being completed, so it is state here // rather than something read off the element when a menu happens to open. @@ -262,6 +220,15 @@ export function Composer({ port, status, running, quote, restore, inject, focus, const line = compose(v); const call = chips.call(compose); const draft = { text, shots, caret: caretRef.current, chips: chips.held() }; + if (worktree.selection || worktree.stage) setSetupDraft({ text: v, files: shots.map(chipName) }); + const restoreDraft = () => { + finishSubmit(false); + pending.current = draft.caret; + type(draft.text, draft.caret); + chips.restore(draft.chips); + setShots((current) => [...draft.shots, ...current]); + queueMicrotask(() => box.current?.focus()); + }; beginSubmit(text); submittingRef.current = true; setSubmitting(true); @@ -269,28 +236,23 @@ export function Composer({ port, status, running, quote, restore, inject, focus, setShots([]); void (async () => { try { - if (worktree.selection) await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : [])); + if (worktree.selection || worktree.stage) { + const prepared = await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : [])); + if (!prepared) { restoreDraft(); return; } + } const sent = await (call ? onSubmit(line, call) : onSubmit(line)); if (sent) { finishSubmit(true); + worktree.dismiss(); draft.shots.forEach(releaseChip); } else { - finishSubmit(false); - pending.current = draft.caret; - type(draft.text, draft.caret); - chips.restore(draft.chips); - setShots((current) => [...draft.shots, ...current]); - queueMicrotask(() => box.current?.focus()); + restoreDraft(); + worktree.dismiss(); } } catch (e) { - finishSubmit(false); - pending.current = draft.caret; - type(draft.text, draft.caret); - chips.restore(draft.chips); - setShots((current) => [...draft.shots, ...current]); - queueMicrotask(() => box.current?.focus()); - onError(e); + restoreDraft(); + if (!(e instanceof WorktreeSetupFailure)) onError(e); } finally { submittingRef.current = false; setSubmitting(false); @@ -421,11 +383,13 @@ export function Composer({ port, status, running, quote, restore, inject, focus, const hasDraft = text.trim().length > 0 || shots.some((c) => c.k !== "attachment" || c.state === "ready"); const lines = countLines(text); const showCount = text.length >= 240 || lines > 3; - const sendDisabled = submitting || stopping || adding || failed || !hasDraft; + const sendDisabled = submitting || stopping || adding || failed || !hasDraft || !!worktree.stage; const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c)); - return ( -
+ const setupPage = worktree.stage && void worktree.cancel()} onEdit={worktree.dismiss}/>; + return (<> + {setupPage && (worktreeTarget ? createPortal(setupPage, worktreeTarget) : setupPage)} +
{menu.open && ( {t("计划")}{status?.plan ? "Plan" : "Agent"}} />
- {fresh && worktree.sources?.available ? : } + {git?.repo && (worktree.sources || status?.workspaceRoot) ? : } {/* The toggle keeps its legacy meaning: it follows `plan`, which the kernel turns off the moment a plan is approved. The lifecycle is a separate reading — an approved plan is still running, and saying so @@ -795,6 +759,6 @@ export function Composer({ port, status, running, quote, restore, inject, focus, }
- + ); } diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx index 8e8bf5dccd..15b0c1508e 100644 --- a/desktop/frontend-next/src/ui/Pane.tsx +++ b/desktop/frontend-next/src/ui/Pane.tsx @@ -94,6 +94,8 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc const ready = sessionRead.kind === "settled"; const [tab, showView] = useShowView(rt.id, onManualBrowser); const [sideAsk, setSideAsk] = useState(false); + const [worktreeTarget, setWorktreeTarget] = useState(null); + const [worktreePhase, setWorktreePhase] = useState(null); const [mcp, setMcp] = useState([]); const [askFocus, setAskFocus] = useState(0); const [tree, setTree] = useState(null); @@ -473,6 +475,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
+