diff --git a/docs/design/2026-07-19-webshell-worktree-sessions.md b/docs/design/2026-07-19-webshell-worktree-sessions.md new file mode 100644 index 00000000000..f929b0e7b3e --- /dev/null +++ b/docs/design/2026-07-19-webshell-worktree-sessions.md @@ -0,0 +1,428 @@ +# Web Shell worktree 隔离会话 + +## 背景 + +当前 Web Shell 的一个 workspace 同一时间只能有效地跑一个任务:所有 session +共享同一个 working tree,agent 的文件编辑、`git add`、`git checkout` 等操作 +直接作用于主目录。如果用户想同时推进两个独立任务(比如"修 bug A"和"实现 +feature B"),两个 session 会互相踩踏——一个改了 `foo.ts`,另一个也在改 +`foo.ts`,结果不可预测。 + +CLI 侧已有 worktree 基础设施: + +- `enter_worktree` / `exit_worktree` 工具:agent 可以在会话中手动创建和退出 + worktree,但这是 **tool 级别** 的——需要 agent 自己调用,且 worktree 内的 + cwd 切换靠 model 记住路径前缀,不是真正的进程级 cwd 切换。 +- `GitWorktreeService`(`packages/core/src/services/gitWorktreeService.ts`): + 成熟的 worktree 生命周期管理——创建(`createUserWorktree`)、slug 生成与 + 校验、分支命名(`worktree-`)、session marker、symlink 目录、清理 + (`removeWorktree`)、启动时孤儿扫描(`cleanupStaleAgentWorktrees`)。 +- agent 工具的 `isolation: "worktree"`:子 agent 可以在隔离 worktree 中运行, + 结果通过 branch 返回。 + +但这些都是 **会话内** 或 **子 agent** 级别的。用户想要的是:**创建 session +时自动隔离**——点"新建会话",session 直接在自己的 worktree 里工作,主目录 +干净,多个 session 可以真正并行。 + +## 目标 + +- 创建 session 时可选"worktree 隔离":daemon 自动创建 git worktree,CLI + 子进程直接以 worktree 路径为 cwd 启动。 +- 同一 workspace 的多个 worktree session 可以真正并行,互不干扰。 +- 主目录保持干净——agent 的所有文件操作发生在 worktree 里。 +- 复用 `GitWorktreeService` 已有能力,不重新实现 worktree 管理。 +- session 列表和 git chip 能区分 worktree session 和普通 session。 +- worktree 生命周期与 session 绑定:session 结束时提示清理或保留。 +- 向后兼容:不传 worktree 参数时行为完全不变。 + +## 非目标 + +- 不做 merge-back UI(worktree 的改动合回主分支)。agent 可以在终端里做 + `git merge` / `git rebase`,UI 层面的合并工作流属于后续增量。 +- 不做 worktree 之间的文件对比或冲突解决。 +- 不改变 `enter_worktree` / `exit_worktree` 工具的行为——它们继续作为会话内 + 的手动 worktree 管理工具。 +- 不在非 git 仓库下提供 worktree 隔离(worktree 是 git 概念)。 +- 不做 worktree 的远程同步(push/pull)。 + +## 现状链路 + +### session 创建 + +```text +Web Shell UI (createSession) + → SDK DaemonClient.createSession({ workspaceCwd, ... }) + → POST /session (routes/session.ts) + → resolveRuntimeForSessionCreation(body) + → workspaceRegistry 解析 workspace runtime + → bridge.spawnOrAttach({ workspaceCwd, ... }) + → spawnChannel: spawn(cliEntry, { cwd: workspaceCwd }) + → CLI 子进程以 workspaceCwd 为 cwd 启动 +``` + +关键约束:`workspaceCwd` 必须是 `workspaceRegistry` 中已注册的 workspace。 +worktree 路径(如 `/.qwen/worktrees/my-task/`)不在注册表中,不能 +直接作为 `workspaceCwd` 传入。 + +### worktree 基础设施 + +- `GitWorktreeService.createUserWorktree(slug, baseBranch?, opts?)`: + 创建 worktree + 分支 `worktree-`,返回 `{ success, worktree: { path, branch } }`。 +- worktree 存放路径:`/.qwen/worktrees//`。 +- `writeWorktreeSessionMarker(path, sessionId)`:写入 `.qwen-session` 标记。 +- `GitWorktreeService.removeWorktree(slug)`:清理 worktree + 分支。 +- `GitWorktreeService.cleanupStaleAgentWorktrees()`:启动时清理孤儿 worktree。 +- `WorktreeSession` sidecar(`worktreeSessionService.ts`):已有 + `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch, +originalHeadCommit }` 结构,存于 `/.worktree.json`, + 用于 `--resume` 恢复上下文。可直接复用。 +- `cleanupStaleAgentWorktrees`(`worktreeCleanup.ts`):30 天孤儿扫描, + 但只清理 `agent-{7hex}` slug,用户命名的 worktree 不会被自动清理。 +- `Config.relocateWorkingDirectory(newDir)`:运行时切换 cwd 的方法 + (ACP 模式跳过 `process.chdir`),可作为备选路径但不如 `initialCwd` + 直接。 + +### git 状态绑定 + +- `WorkspaceGitState`(daemon):每个 workspace 一个 entry,用 + `watchRepoBranch(workspaceCwd)` 监听 branch 变化。 +- Web Shell 的 git chip / `/diff` 绑定的是 **workspace cwd**,不是 session + 的实际 cwd。agent cd 进 worktree 后,chip 仍显示主目录的状态。 + +## 方案 + +### 核心思路 + +在 `POST /session` 增加可选的 `worktree` 参数。daemon 在 spawn 子进程之前 +创建 worktree,然后把子进程的 cwd 设为 worktree 路径(而非 workspace cwd)。 +workspace 注册和 runtime 解析仍走主目录,worktree 只影响子进程的实际工作目录。 + +> **为什么不用 `--worktree` CLI 参数?** CLI 已有 `--worktree` 启动参数 +> (`worktreeStartup.ts`),但它在 ACP 模式下被显式拒绝(`gemini.tsx:623`: +> "--worktree cannot be combined with --acp"),因为 ACP host 自己管理 +> per-session cwd。错误信息建议"Pass the worktree path as the cwd of the +> ACP loadSession / newSession request instead"——正是本方案的做法。 + +```text +POST /session { cwd: "/repo", worktree: { slug?: "my-task" } } + │ + ├─ resolveRuntimeForSessionCreation (仍用 /repo 解析 runtime) + │ + ├─ 创建 worktree + │ GitWorktreeService("/repo").createUserWorktree("my-task", currentBranch) + │ → /repo/.qwen/worktrees/my-task/ (branch: worktree-my-task) + │ + ├─ bridge.spawnOrAttach({ + │ workspaceCwd: "/repo", ← workspace 注册 / runtime 解析 + │ initialCwd: "/repo/.qwen/worktrees/my-task/", ← 子进程实际 cwd + │ worktree: { slug, path, branch } ← session 元数据 + │ }) + │ + └─ CLI 子进程以 worktree 路径为 cwd 启动 + → 所有文件操作、git 命令自然发生在 worktree 里 +``` + +### 数据流变更 + +```text +SDK CreateSessionRequest + + worktree?: { slug?: string } [新增] + +BridgeSpawnRequest + + initialCwd?: string [新增] 子进程实际 cwd + + worktree?: { [新增] worktree 元数据 + slug: string; + path: string; + branch: string; + } + +BridgeSession + + worktree?: { slug, path, branch } [新增] 返回给调用方 + +DaemonSessionSummary (SSE / REST) + + worktree?: { slug, path, branch } [新增] session 列表可展示 + +spawnChannel + spawn(cliEntry, { cwd: initialCwd ?? workspaceCwd }) [改动] +``` + +### daemon 侧 + +#### `POST /session` 路由扩展 + +```ts +// routes/session.ts +app.post('/session', mutate(), async (req, res) => { + const body = safeBody(req); + const resolvedRuntime = resolveRuntimeForSessionCreation(body, res); + if (!resolvedRuntime) return; + const { runtime, workspaceCwd } = resolvedRuntime; + + // —— 新增:worktree 创建 —— + let worktreeMeta: { slug: string; path: string; branch: string } | undefined; + let initialCwd: string | undefined; + + if (body['worktree'] && typeof body['worktree'] === 'object') { + const wtReq = body['worktree'] as { slug?: string }; + const service = new GitWorktreeService(workspaceCwd); + + // 前置检查:必须是 git 仓库 + if (!(await service.isGitRepository())) { + res.status(400).json({ + error: 'Worktree isolation requires a git repository', + code: 'worktree_not_git_repo', + }); + return; + } + + const slug = wtReq.slug ?? GitWorktreeService.generateAutoSlug(); + const validation = GitWorktreeService.validateUserWorktreeSlug(slug); + if (validation) { + res + .status(400) + .json({ error: validation, code: 'worktree_invalid_slug' }); + return; + } + + const baseBranch = await service.getCurrentBranch().catch(() => undefined); + const result = await service.createUserWorktree(slug, baseBranch); + if (!result.success || !result.worktree) { + res.status(500).json({ + error: result.error ?? 'Failed to create worktree', + code: 'worktree_create_failed', + }); + return; + } + + worktreeMeta = { + slug, + path: result.worktree.path, + branch: result.worktree.branch, + }; + initialCwd = result.worktree.path; + } + + const session = await runtime.bridge.spawnOrAttach({ + workspaceCwd, + ...(initialCwd ? { initialCwd } : {}), + ...(worktreeMeta ? { worktree: worktreeMeta } : {}), + // ... 其余参数不变 + }); + + // worktree session 写入 session marker + if (worktreeMeta) { + await writeWorktreeSessionMarker( + worktreeMeta.path, + session.sessionId, + ).catch(() => {}); + } + + res.json({ ...session }); +}); +``` + +#### spawnChannel 改动 + +```ts +// acp-bridge/src/spawnChannel.ts +// 现有:cwd: workspaceCwd +// 改为:cwd: initialCwd ?? workspaceCwd +const child = spawn(process.execPath, [...args], { + cwd: initialCwd ?? workspaceCwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, +}); +``` + +#### worktree 清理 + +session 结束时(`session_ended` 事件或 bridge 的 `onExit` 回调),检查 +session 是否有 worktree 元数据: + +- 有未提交改动 → 保留 worktree,在 session 摘要中标记 + `worktree.cleanupNeeded: true`,Web Shell 提示用户。 +- 无改动 → 自动清理(`removeWorktree`)。 +- 用户也可以显式保留(后续 UI 支持)。 + +清理逻辑放在 bridge 的 session 退出回调中,不在路由层。 + +### SDK 侧 + +```ts +// DaemonClient.ts +export interface CreateSessionRequest { + // ... 现有字段 + /** 创建 worktree 隔离会话。slug 可选,不传则自动生成。 */ + worktree?: { slug?: string }; +} + +// DaemonSessionSummary (session 列表 / SSE 事件) +export interface DaemonSessionSummary { + // ... 现有字段 + worktree?: { + slug: string; + path: string; + branch: string; + /** true 表示 session 已结束但 worktree 有未提交改动,需用户处理。 */ + cleanupNeeded?: boolean; + }; +} +``` + +### Web Shell 侧 + +#### 新建会话 UI + +Web Shell 的 session 创建是**懒加载**的:点"新建会话"只清前端状态 +(`clearSession`),daemon session 在第一次提交 prompt 时才创建 +(`ensureSessionForPrompt` → `createAndAttachSessionForPrompt` → +`sessionActions.createSession`)。worktree 参数需要穿透这条路径: +`createNewSession` 时记住"下一个 session 要 worktree 隔离", +`ensureSessionForPrompt` 时把 `worktree` 参数传给 `createSession`。 + +在"新建会话"按钮旁增加 worktree 选项。两种形态: + +**最小方案(推荐先做)**:新建会话时,如果当前 workspace 是 git 仓库, +在会话创建请求中自动带 `worktree: {}`(自动 slug)。用户无需额外操作, +每个新 session 天然隔离。 + +**可选方案**:在"新建会话"按钮旁加一个下拉/开关,让用户选择"普通会话" +或"worktree 隔离会话"。适合不想每次都隔离的用户。 + +先做最小方案,通过 settings 或 workspace 级开关控制是否默认启用。 + +#### session 列表 + +worktree session 在会话列表中显示分支标记: + +```text +┌─────────────────────────────────────────┐ +│ 💬 Fix login bug │ ← 普通 session +│ main │ +├─────────────────────────────────────────┤ +│ 💬 Add dark mode ⑂ worktree-dark-mode │ ← worktree session +│ worktree-dark-mode │ +└─────────────────────────────────────────┘ +``` + +#### git chip + +worktree session 的 git chip 显示 worktree 分支名,git status 跟随 +session 的实际 cwd(worktree 路径)而非 workspace cwd。 + +实现:`App.tsx` 中 `workspaceGit()` 的调用改为使用 session 的 +`worktree.path`(如果有)作为 cwd 参数。 + +> **已有基础**:daemon 已有 `POST /session/:id/cd` 路由和 +> `session_cwd_changed` 事件(`bridge.changeSessionCwd`),但 Web Shell +> 的 mappers 未消费该事件。worktree session 不需要走 cd 路由(子进程直接 +> 以 worktree 为 cwd 启动),但 git 状态刷新需要知道 session 的实际 cwd。 +> 最简做法:session 创建响应中返回 `worktree.path`,Web Shell 用它替代 +> `connection.workspaceCwd` 来拉取 git 状态。 + +#### `/diff` + +worktree session 中 `/diff` 显示 worktree 内的 diff,不是主目录的。 +`diffWorkspaceCwd` 改为从 session 的实际 cwd 取值。 + +### 刷新策略 + +worktree session 的 git 状态刷新与普通 session 一致(focus / branch 变化 / +30s 轮询),只是 cwd 指向 worktree 路径。`watchRepoBranch` 对 worktree +同样有效(worktree 共享 `.git` 目录,reflog 变化会触发 watch)。 + +## 兼容性 + +- **旧 daemon + 新 client**:client 传 `worktree` 参数,旧 daemon 忽略 + 未知字段,创建普通 session。行为退化但不报错。 +- **新 daemon + 旧 client**:不传 `worktree`,行为完全不变。 +- **非 git 仓库**:传 `worktree` 时返回 `400 worktree_not_git_repo`, + client 应捕获并提示用户。 +- **worktree 创建失败**(磁盘满、权限、分支冲突):返回 `500`,session + 不创建,client 提示错误。 +- **session 异常退出**(daemon 崩溃、kill -9):worktree 残留在 + `.qwen/worktrees/` 下。已有的 `cleanupStaleAgentWorktrees` 在 daemon 重启时 + 清理无 session marker 的孤儿 worktree。 + +## 关键修改点 + +| 操作 | 文件 | 说明 | +| ---- | ----------------------------------------------------- | ---------------------------------------------------------- | +| 修改 | `packages/acp-bridge/src/bridgeTypes.ts` | `BridgeSpawnRequest` 加 `initialCwd` / `worktree` | +| 修改 | `packages/acp-bridge/src/spawnChannel.ts` | spawn cwd 改为 `initialCwd ?? workspaceCwd` | +| 修改 | `packages/cli/src/serve/routes/session.ts` | `POST /session` 处理 worktree 创建 | +| 修改 | `packages/sdk-typescript/src/daemon/DaemonClient.ts` | `CreateSessionRequest` 加 `worktree` | +| 修改 | `packages/sdk-typescript/src/daemon/types.ts` | `DaemonSessionSummary` 加 `worktree` | +| 修改 | `packages/web-shell/client/App.tsx` | `createSession` 传 worktree 参数;git 状态跟随 session cwd | +| 修改 | `packages/web-shell/client/components/ChatEditor.tsx` | git chip 使用 session 实际 cwd | +| 修改 | session 列表组件 | 显示 worktree 分支标记 | + +## 测试计划 + +### Unit tests + +- `POST /session` + `worktree`:创建成功返回 worktree 元数据;非 git 仓库 + 返回 400;无效 slug 返回 400;创建失败返回 500。 +- `spawnChannel`:`initialCwd` 传入时子进程 cwd 为 worktree 路径;不传时 + 仍为 workspaceCwd。 +- `GitWorktreeService.createUserWorktree`:已有充分测试,不需新增。 +- SDK `createSession({ worktree })`:正确序列化参数。 +- Web Shell `createSession`:worktree 参数透传。 + +### Integration / browser verification + +- 创建 worktree session → agent 在 worktree 里工作 → 主目录无变化。 +- 同时创建两个 worktree session → 各自独立,互不影响。 +- session 结束 → 无改动时 worktree 自动清理;有改动时保留并提示。 +- git chip 显示 worktree 分支名和状态。 +- `/diff` 显示 worktree 内的 diff。 +- 非 git 仓库下创建 worktree session → 友好错误提示。 +- daemon 重启 → 孤儿 worktree 被 sweep 清理。 + +## 风险和控制 + +- **风险**:worktree 创建增加 session 启动延迟(~100-500ms,取决于仓库 + 大小)。**控制**:`git worktree add` 是轻量操作(不复制文件,只创建 + 目录和 `.git` 文件),对大仓库也很快。如果成为瓶颈,可以异步创建并 + 让 session 先启动、后切换。 + +- **风险**:worktree 残留占用磁盘。**控制**:session 正常退出时自动清理; + 异常退出靠 `cleanupStaleAgentWorktrees` 兜底;Web Shell 可展示残留 worktree + 列表供手动清理(后续增量)。 + +- **风险**:worktree 内的 branch 与主目录 branch 冲突(同一 branch 不能 + 同时被两个 worktree checkout)。**控制**:`createUserWorktree` 总是创建 + 新分支 `worktree-`,不会 checkout 已有分支。 + +- **风险**:用户在 worktree session 里做的改动"找不到"(不知道在哪个 + worktree 里)。**控制**:session 列表显示 worktree 分支名和路径; + session 摘要包含 worktree 元数据。 + +- **风险**:`sessionScope: 'single'` 下同一 workspace 的第二次 + `POST /session` 会 attach 到已有 session 而非创建新的,worktree 参数 + 被忽略。**控制**:worktree session 强制 `sessionScope: 'thread'`, + 确保每次调用创建独立 session。 + +## 实施分步 + +### Phase 1:daemon + SDK(核心链路) + +1. `BridgeSpawnRequest` 加 `initialCwd` / `worktree` 字段。 +2. `spawnChannel` 支持 `initialCwd`。 +3. `POST /session` 处理 worktree 创建。 +4. SDK `CreateSessionRequest` 加 `worktree`。 +5. session 摘要返回 worktree 元数据。 +6. 单测。 + +### Phase 2:Web Shell UI + +1. `createSession` 传 worktree 参数。 +2. git chip / `/diff` 跟随 session 实际 cwd。 +3. session 列表显示 worktree 标记。 +4. 浏览器验收。 + +### Phase 3:生命周期管理(后续增量) + +1. session 结束时 worktree 清理策略。 +2. 残留 worktree 列表和手动清理 UI。 +3. worktree session 的 merge-back 辅助(agent 侧)。 diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a0d0133e786..3a461d201e4 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -435,6 +435,8 @@ interface SessionEntry { /** Immutable creator attribution, persisted in the transcript when present. */ sourceType?: string; sourceId?: string; + /** Worktree isolation metadata, when created with worktree param. */ + worktree?: { slug: string; path: string; branch: string }; channel: AcpChannel; connection: ClientSideConnection; /** Per-session event bus drives `GET /session/:id/events`. */ @@ -1668,6 +1670,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { hasTurnError: entry.turnError !== undefined, ...(entry.turnError !== undefined ? { turnError: entry.turnError } : {}), pendingInteractions: [...entry.pendingInteractions.values()], + ...(entry.worktree ? { worktree: entry.worktree } : {}), }; }; // Pending + resolved permission state lives in @@ -2223,6 +2226,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { parentSessionId?: string, sourceType?: string, sourceId?: string, + worktree?: { slug: string; path: string; branch: string }, ): Promise { // Get-or-create the daemon's single channel, then call // `connection.newSession()` on it. Sessions share the child's @@ -2327,7 +2331,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { newSessionResp.sessionId, boundWorkspace, undefined, - { parentSessionId, sourceType, sourceId }, + { parentSessionId, sourceType, sourceId, worktree }, ); initializedSessionId = entry.sessionId; sessionRegistered = true; @@ -2518,6 +2522,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.parentSessionId ? { parentSessionPersisted: parentSessionPersisted === true } : {}), + ...(entry.worktree ? { worktree: entry.worktree } : {}), }; } finally { ci.sessionSpawnsInFlight = Math.max(0, ci.sessionSpawnsInFlight - 1); @@ -3462,6 +3467,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { parentSessionId?: string; sourceType?: string; sourceId?: string; + worktree?: { slug: string; path: string; branch: string }; } = {}, ): SessionEntry => { const entry: SessionEntry = { @@ -3473,6 +3479,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : {}), ...(options.sourceType ? { sourceType: options.sourceType } : {}), ...(options.sourceId !== undefined ? { sourceId: options.sourceId } : {}), + ...(options.worktree ? { worktree: options.worktree } : {}), channel: ci.channel, connection: ci.connection, events, @@ -4651,6 +4658,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { req.parentSessionId, source.sourceType, source.sourceId, + req.worktree, ); // Track in-flight spawns regardless of scope. Under `single` // this also serves the coalescing path above (a parallel diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 436bbf56be0..2f0c4343539 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -94,6 +94,8 @@ export interface BridgeSpawnRequest { /** Optional source-specific identifier. Valid only with `sourceType`. */ sourceId?: string; approvalMode?: ApprovalMode; + /** Worktree isolation metadata, set by the daemon route before spawn. */ + worktree?: { slug: string; path: string; branch: string }; } export interface BridgeSession { @@ -126,6 +128,8 @@ export interface BridgeSession { sourceId?: string; /** True iff the source metadata was durably written to the transcript. */ sourcePersisted?: boolean; + /** Present when the session was created with worktree isolation. */ + worktree?: { slug: string; path: string; branch: string }; } export interface BridgeRestoreSessionRequest { @@ -406,6 +410,8 @@ export interface BridgeSessionSummary { groupId?: string | null; /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ color?: SessionGroupPresetColor | null; + /** Present when the session was created with worktree isolation. */ + worktree?: { slug: string; path: string; branch: string }; } /** diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a8ef3705400..5cfc148f069 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -10,6 +10,7 @@ import { APPROVAL_MODES, BTW_MAX_INPUT_LENGTH, GROUP_COLOR_OPTIONS, + GitWorktreeService, SessionService, SessionOrganizationError, SESSION_TRANSCRIPT_MAX_LIMIT, @@ -20,6 +21,8 @@ import { SessionTranscriptSnapshotUnavailableError, addDaemonRequestAttribute, runWithoutDebugLogSession, + writeWorktreeSessionMarker, + writeWorktreeSession, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -1195,6 +1198,84 @@ export function registerSessionRoutes( } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + + // ── Worktree isolation ────────────────────────────────────────── + // When `worktree` is present, create a git worktree before spawning + // and relocate the session into it immediately after. The workspace + // runtime resolution still uses the main workspace cwd; only the + // child process's effective working directory changes. + let worktreeMeta: + | { slug: string; path: string; branch: string } + | undefined; + const rawWorktree = body['worktree']; + if (rawWorktree !== undefined && rawWorktree !== null) { + if (typeof rawWorktree !== 'object' || Array.isArray(rawWorktree)) { + res.status(400).json({ + error: + '`worktree` must be an object (e.g. `{}` or `{"slug":"my-task"}`)', + code: 'invalid_worktree', + }); + return; + } + const wtReq = rawWorktree as Record; + let wtService: GitWorktreeService; + try { + wtService = new GitWorktreeService(workspaceCwd); + } catch { + res.status(500).json({ + error: 'Failed to initialize worktree service', + code: 'worktree_init_failed', + }); + return; + } + if (!(await wtService.isGitRepository())) { + res.status(400).json({ + error: 'Worktree isolation requires a git repository', + code: 'worktree_not_git_repo', + }); + return; + } + const rawSlug = wtReq['slug']; + let slug: string; + if (rawSlug === undefined || rawSlug === null) { + slug = GitWorktreeService.generateAutoSlug(); + } else if (typeof rawSlug !== 'string' || rawSlug.length === 0) { + res.status(400).json({ + error: '`worktree.slug` must be a non-empty string when provided', + code: 'worktree_invalid_slug', + }); + return; + } else { + slug = rawSlug; + } + const slugError = GitWorktreeService.validateUserWorktreeSlug(slug); + if (slugError) { + res + .status(400) + .json({ error: slugError, code: 'worktree_invalid_slug' }); + return; + } + const baseBranch = await wtService + .getCurrentBranch() + .catch(() => undefined); + const wtResult = await wtService.createUserWorktree(slug, baseBranch); + if (!wtResult.success || !wtResult.worktree) { + res.status(500).json({ + error: wtResult.error ?? 'Failed to create worktree', + code: 'worktree_create_failed', + }); + return; + } + worktreeMeta = { + slug, + path: wtResult.worktree.path, + branch: wtResult.worktree.branch, + }; + // Worktree sessions must be independent — never coalesce onto an + // existing single-scope session that lives in the main checkout. + sessionScope = 'thread'; + } + try { const session = await runtime.bridge.spawnOrAttach({ workspaceCwd, @@ -1206,6 +1287,7 @@ export function registerSessionRoutes( ? { sourceType: source.sourceType } : {}), ...(source.sourceId !== undefined ? { sourceId: source.sourceId } : {}), + ...(worktreeMeta ? { worktree: worktreeMeta } : {}), }); // Client may have disconnected during the 1–3s spawn window. If // so, the response can't be delivered. The session is otherwise @@ -1260,6 +1342,12 @@ export function registerSessionRoutes( await new SessionService(runtime.workspaceCwd).removeSession( session.sessionId, ); + // Clean up the worktree if one was created for this session. + if (worktreeMeta) { + await new GitWorktreeService(workspaceCwd) + .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) + .catch(() => {}); + } } } catch { // Best-effort cleanup; channel.exited will eventually reap. @@ -1282,8 +1370,78 @@ export function registerSessionRoutes( } return; } + + // Relocate the freshly spawned session into its worktree. The + // cd chains onto the session's promptQueue, so it completes + // before any subsequent prompt is processed. + if (worktreeMeta) { + try { + await runtime.bridge.changeSessionCwd(session.sessionId, { + path: worktreeMeta.path, + }); + await writeWorktreeSessionMarker( + worktreeMeta.path, + session.sessionId, + ).catch(() => {}); + // Write the worktree sidecar so the session list can restore + // worktree metadata after a daemon restart. + await writeWorktreeSession( + new SessionService(workspaceCwd).getWorktreeSessionPath( + session.sessionId, + ), + { + slug: worktreeMeta.slug, + worktreePath: worktreeMeta.path, + worktreeBranch: worktreeMeta.branch, + originalCwd: workspaceCwd, + originalBranch: '', + originalHeadCommit: '', + }, + ).catch(() => {}); + } catch (cdErr) { + // cd failed — relocation is transactional: kill the session, + // remove the worktree, and return an error. Leaving the session + // alive with stale worktree metadata in the bridge entry would + // make GET /session/:id/status claim isolation the session + // doesn't have. + if (daemonLog) { + daemonLog.warn('worktree cd failed, rolling back', { + sessionId: session.sessionId, + error: cdErr instanceof Error ? cdErr.message : String(cdErr), + }); + } + const killed = await runtime.bridge + .killSession(session.sessionId, { requireZeroAttaches: true }) + .catch(() => false); + if (killed) { + await new SessionService(workspaceCwd) + .removeSession(session.sessionId) + .catch(() => {}); + } + // cd failed so the session never entered the worktree — the + // worktree is unused regardless of whether the session was + // killed or another client keeps it alive in the main checkout. + await new GitWorktreeService(workspaceCwd) + .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) + .catch(() => {}); + res.status(500).json({ + error: 'Failed to relocate session into worktree', + code: 'worktree_relocate_failed', + }); + return; + } + } + res.status(200).json(session); } catch (err) { + // Roll back the worktree if spawn failed — otherwise the directory + // and branch are orphaned (the agent-* stale cleanup won't collect + // user-named worktrees). + if (worktreeMeta) { + await new GitWorktreeService(workspaceCwd) + .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) + .catch(() => {}); + } sendBridgeError(res, err, { route: 'POST /session' }); } }); diff --git a/packages/cli/src/serve/routes/workspace-git.ts b/packages/cli/src/serve/routes/workspace-git.ts index fea33d7fb28..ed532baf2dd 100644 --- a/packages/cli/src/serve/routes/workspace-git.ts +++ b/packages/cli/src/serve/routes/workspace-git.ts @@ -5,6 +5,9 @@ */ import type { Application, Request, Response } from 'express'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getGitWorkingTreeStatus } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; import type { SendBridgeError } from '../server/error-response.js'; import type { WorkspaceGitState } from '../workspace-git-state.js'; @@ -59,12 +62,54 @@ export function registerWorkspaceQualifiedGitRoutes( const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); if (!runtime) return; const route = 'GET /workspaces/:workspace/git'; + // Optional ?cwd= override for worktree sessions whose working directory + // differs from the workspace root. Canonicalize both paths with realpath + // to prevent symlink escape, then validate containment. + const rawCwd = req.query['cwd']; + let gitCwd = runtime.workspaceCwd; + if (typeof rawCwd === 'string' && rawCwd.length > 0) { + try { + const resolved = fs.realpathSync(path.resolve(rawCwd)); + const root = fs.realpathSync(runtime.workspaceCwd); + const rel = path.relative(root, resolved); + if (!rel.startsWith('..') && !path.isAbsolute(rel)) { + gitCwd = resolved; + } + } catch { + // Path doesn't exist or can't be resolved — use workspace root. + } + } try { - res - .status(200) - .json( - await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), + if (gitCwd !== runtime.workspaceCwd) { + // Worktree cwd: call getGitWorkingTreeStatus directly to avoid + // creating a watcher entry in WorkspaceGitState (which would leak + // one fs watcher per worktree path, never disposed). + const status = await getGitWorkingTreeStatus(gitCwd).catch(() => null); + res.status(200).json( + status + ? { + v: 2, + workspaceCwd: gitCwd, + branch: status.branch ?? null, + detached: status.detached, + staged: status.staged, + unstaged: status.unstaged, + untracked: status.untracked, + conflicted: status.conflicted, + hasUpstream: status.hasUpstream, + ahead: status.ahead, + behind: status.behind, + stashCount: status.stashCount, + ...(status.operation ? { operation: status.operation } : {}), + computedAt: Date.now(), + } + : { v: 2, workspaceCwd: gitCwd, branch: null }, ); + } else { + res + .status(200) + .json(await deps.gitState.getStatus(gitCwd, runtime.bridge)); + } } catch (err) { deps.sendBridgeError(res, err, { route }); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c26f3f012e3..f047d7d9f78 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -152,6 +152,32 @@ import { TRUSTED_FOLDERS_FILENAME, } from '../config/trustedFolders.js'; +// ── Worktree mock infrastructure ──────────────────────────────────── +// GitWorktreeService's constructor calls simpleGit() which validates +// the directory exists — test workspaces (/work/bound) don't. Replace +// the class with a controllable mock; each worktree test sets +// `mockWt.impl` to control instance behaviour. +const mockWt = vi.hoisted(() => ({ + impl: undefined as (() => Record) | undefined, +})); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + GitWorktreeService: class MockGitWorktreeService { + static validateUserWorktreeSlug = + original.GitWorktreeService.validateUserWorktreeSlug; + static generateAutoSlug = original.GitWorktreeService.generateAutoSlug; + constructor() { + if (mockWt.impl) { + Object.assign(this, mockWt.impl()); + } + } + }, + }; +}); + const baseOpts: ServeOptions = { hostname: '127.0.0.1', port: 4170, @@ -725,6 +751,7 @@ interface FakeBridge extends AcpSessionBridge { opts?: { requireZeroAttaches?: boolean }; }>; detachCalls: Array<{ sessionId: string; clientId?: string }>; + changeSessionCwdCalls: Array<{ sessionId: string; path: string }>; enqueueMidTurnCalls: Array<{ sessionId: string; message: string; @@ -902,6 +929,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { opts?: { requireZeroAttaches?: boolean }; }> = []; const detachCalls: FakeBridge['detachCalls'] = []; + const changeSessionCwdCalls: Array<{ sessionId: string; path: string }> = []; const enqueueMidTurnCalls: FakeBridge['enqueueMidTurnCalls'] = []; const enqueueMidTurnImpl = opts.enqueueMidTurnImpl ?? (() => ({ accepted: true })); @@ -964,6 +992,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { workspaceCwd: req.workspaceCwd, attached: false, clientId: `client-${calls.length}`, + ...(req.worktree ? { worktree: req.worktree } : {}), })); const loadImpl = opts.loadImpl ?? @@ -1465,6 +1494,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { cancelCalls, killCalls, detachCalls, + changeSessionCwdCalls, enqueueMidTurnCalls, permissionVotes, sessionPermissionVotes, @@ -1942,6 +1972,15 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { ...(clientId !== undefined ? { clientId } : {}), }); }, + async changeSessionCwd(sessionId, req) { + changeSessionCwdCalls.push({ sessionId, path: req.path }); + return { + sessionId, + previousCwd: '/fake/previous', + newCwd: req.path, + warnings: [], + }; + }, isChannelLive() { return false; }, @@ -8070,6 +8109,146 @@ describe('createServeApp', () => { // dangerous key landed via spread, this check would fail.) expect(({} as Record)['polluted']).toBeUndefined(); }); + + // ── Worktree isolation ────────────────────────────────────────── + + it('creates a worktree session and relocates via changeSessionCwd', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const mockCreate = vi.fn().mockResolvedValue({ + success: true, + worktree: { + path: '/work/a/.qwen/worktrees/my-task', + branch: 'worktree-my-task', + }, + }); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + createUserWorktree: mockCreate, + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: { slug: 'my-task' } }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toEqual({ + slug: 'my-task', + path: '/work/a/.qwen/worktrees/my-task', + branch: 'worktree-my-task', + }); + expect(bridge.calls[0]?.sessionScope).toBe('thread'); + expect(bridge.changeSessionCwdCalls).toHaveLength(1); + expect(bridge.changeSessionCwdCalls[0]?.path).toBe( + '/work/a/.qwen/worktrees/my-task', + ); + expect(mockCreate).toHaveBeenCalledWith('my-task', 'main'); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree is requested on a non-git workspace', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(false), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: {} }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('worktree_not_git_repo'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree slug is invalid', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: { slug: '../escape' } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('worktree_invalid_slug'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree is not an object', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: 'yes' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_worktree'); + expect(bridge.calls).toHaveLength(0); + }); + + it('500 when worktree creation fails', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + createUserWorktree: () => + Promise.resolve({ success: false, error: 'disk full' }), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: {} }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('worktree_create_failed'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); }); describe('POST /session/:id/load and /resume', () => { diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index ee51a282329..167558f0dbe 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -7,6 +7,7 @@ import { SessionService, SessionOrganizationError, + readWorktreeSession, type SessionArchiveState, type SessionGroupPresetColor, } from '@qwen-code/qwen-code-core'; @@ -272,6 +273,34 @@ function encodeMetadataSessionCursor( ).toString('base64url'); } +/** + * Enrich persisted session summaries with worktree metadata from sidecar + * files so the ⑂ badge survives daemon restarts. Shared by all three + * listing paths (default, organized, metadata-filtered). + */ +async function enrichWorktreeSidecars( + bySessionId: Map, + sessionService: SessionService, + archiveState: SessionArchiveState = 'active', +): Promise { + for (const [sessionId, summary] of bySessionId) { + if (summary.worktree) continue; + const sidecar = await readWorktreeSession( + sessionService.getWorktreeSessionPathForArchiveState( + sessionId, + archiveState, + ), + ).catch(() => null); + if (sidecar) { + summary.worktree = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + } + } +} + function toSummary(item: { sessionId: string; cwd: string; @@ -491,6 +520,8 @@ async function listOrganizedWorkspaceSessionsForResponse( ); } + await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); + if ( readOptions.mergeLive !== false && archiveState !== 'archived' && @@ -606,6 +637,8 @@ async function listWorkspaceSessionsByMetadataForResponse( bySessionId.set(session.sessionId, session); } + await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); + let liveMergeFailed = false; if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { @@ -749,6 +782,8 @@ export async function listWorkspaceSessionsForResponse( bySessionId.set(item.sessionId, toSummary(item)); } + await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); + if (archiveState === 'archived' || readOptions.mergeLive === false) { const sessions = [...bySessionId.values()]; const nextCursor = diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 29692a85d01..90e947d6783 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -399,6 +399,13 @@ export class SessionService { return this.getWorktreeSessionPathForState(sessionId, 'active'); } + getWorktreeSessionPathForArchiveState( + sessionId: string, + state: SessionArchiveState, + ): string { + return this.getWorktreeSessionPathForState(sessionId, state); + } + private async readProjectSessionHead( sessionId: string, filePath: string, diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 49dd6bf4e7b..cadbd3b037c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -463,6 +463,15 @@ export interface CreateSessionRequest { sourceType?: string; /** Optional source-specific identifier. Requires `sourceType`. */ sourceId?: string; + /** + * Create the session in an isolated git worktree. The daemon creates + * a worktree under `/.qwen/worktrees/` and relocates + * the session's working directory into it. Pass `{}` for an + * auto-generated slug, or `{ slug: 'my-task' }` for a named one. + * Requires the workspace to be a git repository. Worktree sessions + * are always created with `sessionScope: 'thread'`. + */ + worktree?: { slug?: string }; } export interface RestoreSessionRequest { @@ -1997,6 +2006,7 @@ export class DaemonClient { ? { sourceType: req.sourceType } : {}), ...(req.sourceId !== undefined ? { sourceId: req.sourceId } : {}), + ...(req.worktree !== undefined ? { worktree: req.worktree } : {}), }), }, async (res) => { @@ -4199,10 +4209,11 @@ export class WorkspaceDaemonClient { ); } - workspaceGit(): Promise { + workspaceGit(cwd?: string): Promise { + const suffix = cwd ? `/git?cwd=${encodeURIComponent(cwd)}` : '/git'; return this.client.workspaceJsonRequest( this.workspaceSelector, - '/git', + suffix, 'GET /workspaces/:workspace/git', { mode: 'rest' }, ); diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index d0e6e77da3f..40c2b51adbc 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -263,6 +263,10 @@ export class DaemonSessionClient { return this.session.clientId; } + get worktree(): DaemonSession['worktree'] { + return this.session.worktree; + } + get lastEventId(): number | undefined { return this.lastSeenEventId; } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index fd7c8258a2e..d8a9acdc726 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -427,6 +427,7 @@ export type { DaemonRestoredSession, DaemonSession, DaemonSessionArchiveState, + DaemonWorktreeInfo, DaemonSessionExportFormat, DaemonSessionExportResult, DaemonSessionTranscriptPage, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 65a1569d3b5..d6847e6b8bd 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -555,6 +555,13 @@ export interface DaemonStatusReport { }; } +/** Worktree metadata returned when a session is created with worktree isolation. */ +export interface DaemonWorktreeInfo { + slug: string; + path: string; + branch: string; +} + /** Returned from `POST /session`. */ export interface DaemonSession { sessionId: string; @@ -576,6 +583,8 @@ export interface DaemonSession { sourceId?: string; /** True iff supplied source metadata was durably written to the transcript. */ sourcePersisted?: boolean; + /** Present when the session was created with worktree isolation. */ + worktree?: DaemonWorktreeInfo; } /** @@ -719,6 +728,8 @@ export interface DaemonSessionSummary { groupId?: string | null; /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ color?: DaemonSessionGroupPresetColor | null; + /** Present when the session was created with worktree isolation. */ + worktree?: DaemonWorktreeInfo; } export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl'; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 26d75c0fc65..865aaaa6771 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -152,6 +152,7 @@ export { type DaemonRestoredSession, type DaemonSession, type DaemonSessionClosedReason, + type DaemonWorktreeInfo, type DaemonSessionClientOptions, type DaemonSessionContextStatus, type DaemonLspServerStatus, diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index a85c62cf59b..aa910208f89 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -484,6 +484,29 @@ overflow: visible; } +.worktreeWelcomeBadge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 12px; + padding: 12px 20px; + border-radius: 12px; + background: var(--color-accent-bg, rgba(139, 92, 246, 0.06)); + border: 1px solid var(--color-accent-border, rgba(139, 92, 246, 0.15)); + color: var(--color-accent-fg, #8b5cf6); +} + +.worktreeWelcomeTitle { + font-size: 14px; + font-weight: 600; +} + +.worktreeWelcomeDesc { + font-size: 12px; + opacity: 0.7; +} + .missingSessionState { flex: 1 1 auto; min-height: 0; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 4f60e29ca25..e6e9c65ad8b 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -84,6 +84,7 @@ const { workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), workspaceSkills: loadSkillsStatus, })), + sessionStatus: vi.fn(() => Promise.resolve({})), }; return { mockConnection: connection, diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c9471eead63..b8448c59bc2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -38,6 +38,7 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; +import { GitForkIcon } from 'lucide-react'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; @@ -612,7 +613,11 @@ type SessionActionsWithCreate = { workspaceCwd?: string; approvalMode?: string; sourceType?: string; - }) => Promise<{ sessionId: string }>; + worktree?: { slug?: string }; + }) => Promise<{ + sessionId: string; + worktree?: { slug: string; path: string; branch: string }; + }>; attachSession: () => Promise; clearSession: () => Promise; releaseSession: (sessionId: string) => Promise; @@ -1288,6 +1293,30 @@ export function App({ // branch/dirty counts while the new fetch is in flight; same-workspace // re-runs (branch change, focus, poll) keep the live value to avoid flicker. const gitStatusWorkspaceCwdRef = useRef(undefined); + /** Worktree metadata for the current session (set after creation). */ + const [sessionWorktree, setSessionWorktree] = useState< + { slug: string; path: string; branch: string } | undefined + >(undefined); + // Restore worktree info from the server when switching to an existing session. + useEffect(() => { + const sid = connection.sessionId; + if (!sid) { + setSessionWorktree(undefined); + return; + } + let cancelled = false; + workspace.client + .sessionStatus(sid) + .then((summary) => { + if (!cancelled) setSessionWorktree(summary.worktree); + }) + .catch(() => { + if (!cancelled) setSessionWorktree(undefined); + }); + return () => { + cancelled = true; + }; + }, [connection.sessionId, workspace.client]); // Active workspace: the connected session's workspace, else the workspace // picked for the next session (locked / selected / primary). Computed once // and shared by the git-status effect and the Changes-dialog entry point so @@ -1307,6 +1336,8 @@ export function App({ workspaces, ], ); + // Worktree sessions override the git chip branch via sessionWorktree.branch + // and query git status with the worktree path (?cwd= parameter). useEffect(() => { if (!activeWorkspaceCwd) { gitStatusWorkspaceCwdRef.current = undefined; @@ -1321,7 +1352,7 @@ export function App({ const fetchStatus = () => { void workspace.client .workspaceByCwd(activeWorkspaceCwd) - .workspaceGit() + .workspaceGit(sessionWorktree?.path) .then((git) => { if (!cancelled) setSelectedWorkspaceGitStatus(git); }) @@ -1343,7 +1374,12 @@ export function App({ window.removeEventListener('focus', onFocus); window.clearInterval(poll); }; - }, [activeWorkspaceCwd, connection.gitBranch, workspace.client]); + }, [ + activeWorkspaceCwd, + connection.gitBranch, + workspace.client, + sessionWorktree, + ]); const onToastRef = useRef(onToast); onToastRef.current = onToast; const toastIdRef = useRef(0); @@ -2704,6 +2740,10 @@ export function App({ const [isPreparingPrompt, setIsPreparingPrompt] = useState(false); const createSessionPromiseRef = useRef | null>(null); const preparingSessionIdRef = useRef(null); + /** Worktree request for the next lazily-created session. */ + const pendingWorktreeRef = useRef<{ slug?: string } | undefined>(undefined); + /** Render-visible mirror of pendingWorktreeRef for the empty-state badge. */ + const [worktreePending, setWorktreePending] = useState(false); const newSessionSuggestionSubmitTokenRef = useRef(0); const pendingNewSessionSuggestionSubmitRef = useRef<{ token: number; @@ -2766,11 +2806,21 @@ export function App({ lockedWorkspaceCwd ?? selectedWorkspaceCwdRef.current ?? primaryWorkspaceCwd, + worktree: pendingWorktreeRef.current, onSessionCreated: onSessionCreatedRef.current, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; }, getCurrentSessionId: () => connectionRef.current.sessionId, + }).then((result) => { + if (result.worktree) { + setSessionWorktree(result.worktree); + } + // Clear the pending intent only on success. On failure the + // welcome badge stays visible so the user knows the isolation + // intent was not fulfilled and can retry. + pendingWorktreeRef.current = undefined; + setWorktreePending(false); }); // One-shot: the picker targets only the *next* new session, so clear // it after creation. The next new chat defaults back to the primary @@ -3853,11 +3903,13 @@ export function App({ * stay mounted until its prompt is admitted, or a rejection has nowhere to * render. Only that caller passes this. */ - opts?: { keepView?: boolean }, + opts?: { keepView?: boolean; worktree?: { slug?: string } }, ) => { const targetWorkspaceCwd = lockedWorkspaceCwd ?? workspaceCwd; selectedWorkspaceCwdRef.current = targetWorkspaceCwd; setSelectedWorkspaceCwd(targetWorkspaceCwd); + pendingWorktreeRef.current = opts?.worktree; + setWorktreePending(Boolean(opts?.worktree)); // Close the drawer before awaiting so a failed createSession() doesn't leave // it stuck open with the page scroll still locked, matching loadSidebarSession. closeMobileDrawer(); @@ -3875,6 +3927,9 @@ export function App({ clearPromise, reloadLoadedSkills(targetWorkspaceCwd), ]); + // Clear after successful clearSession — if it rejects, the old + // session's worktree state is preserved. + setSessionWorktree(undefined); return true; } catch (error) { if (composerFocusRequestRef.current === focusRequest) { @@ -4082,6 +4137,9 @@ export function App({ async (sessionId: string, workspaceCwd?: string) => { composerFocusRequestRef.current += 1; setSidebarSwitchingSessionId(sessionId); + pendingWorktreeRef.current = undefined; + setWorktreePending(false); + setSessionWorktree(undefined); // Close the drawer before awaiting the load; the transcript clears // immediately and shows its loading skeleton for the selected session. closeMobileDrawer(); @@ -5891,13 +5949,27 @@ export function App({ ); const welcomeHeader = useMemo( - () => - renderWelcomeHeader ? ( - renderWelcomeHeader(welcomeHeaderProps) - ) : ( - - ), - [renderWelcomeHeader, welcomeHeaderProps], + () => ( + <> + {renderWelcomeHeader ? ( + renderWelcomeHeader(welcomeHeaderProps) + ) : ( + + )} + {worktreePending && ( +
+ + + {t('worktree.welcomeTitle')} + + + {t('worktree.welcomeDesc')} + +
+ )} + + ), + [renderWelcomeHeader, welcomeHeaderProps, worktreePending, t], ); const welcomeFooter = useMemo( () => renderWelcomeFooter?.(welcomeHeaderProps), @@ -6378,8 +6450,8 @@ export function App({ webShellThemeToSettingValue(theme), ); }} - onNewSession={(workspaceCwd) => { - return createNewSession(workspaceCwd); + onNewSession={(workspaceCwd, opts) => { + return createNewSession(workspaceCwd, opts); }} onLoadSession={(sessionId, workspaceCwd) => { setMainView('chat'); @@ -7192,13 +7264,16 @@ export function App({ currentMode={currentMode} currentModel={currentModel} gitBranch={ - connection.sessionId + sessionWorktree?.branch ?? + (connection.sessionId ? connection.gitBranch - : (selectedWorkspaceGitStatus?.branch ?? undefined) + : (selectedWorkspaceGitStatus?.branch ?? + undefined)) } + gitWorktree={Boolean(sessionWorktree)} gitStatus={selectedWorkspaceGitStatus} onOpenGitDiff={ - gitDiffWorkspaceCwd + gitDiffWorkspaceCwd && !sessionWorktree ? () => setDiffWorkspaceCwd(gitDiffWorkspaceCwd) : undefined } diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 648ee9bc049..2dc65eb2d2a 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -57,6 +57,7 @@ interface TranscriptMessageOptions { function isIgnoredWebShellStatus(text: string): boolean { return ( text.startsWith('language_changed (unrecognized daemon event):') || + text.startsWith('session_cwd_changed (unrecognized daemon event):') || text.startsWith('Model switched: ') ); } diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 2178a2ca908..60cbde407b8 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -996,6 +996,10 @@ color: var(--warning-color); } +.gitBranchChip[data-worktree='true'] { + color: var(--color-accent-fg, #8b5cf6); +} + .gitBranchIconWrap { position: relative; display: inline-flex; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index b638ce8edf1..778987ea8d4 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -131,6 +131,8 @@ interface ChatEditorProps { currentMode?: string; currentModel?: string; gitBranch?: string; + /** Whether the session is in a worktree (styles the git chip purple). */ + gitWorktree?: boolean; /** Enriched working-tree summary (dirty / ahead-behind / stash / operation). */ gitStatus?: DaemonWorkspaceGitStatus; /** Opens the working-tree Changes dialog; makes the git chip clickable. */ @@ -1145,6 +1147,7 @@ export const ChatEditor = memo( currentMode = 'default', currentModel = '', gitBranch, + gitWorktree, gitStatus, onOpenGitDiff, workspaceName, @@ -2121,6 +2124,7 @@ export const ChatEditor = memo( status={gitStatus} compact={!showGitBranchLabel} onOpenDiff={onOpenGitDiff} + worktree={gitWorktree} /> )} {showModeAction && ( @@ -2446,6 +2450,7 @@ export const ChatEditor = memo( branch={gitBranch} status={gitStatus} compact + worktree={gitWorktree} /> diff --git a/packages/web-shell/client/components/GitBranchIndicator.tsx b/packages/web-shell/client/components/GitBranchIndicator.tsx index 57d02dfd42a..175b9065578 100644 --- a/packages/web-shell/client/components/GitBranchIndicator.tsx +++ b/packages/web-shell/client/components/GitBranchIndicator.tsx @@ -5,7 +5,12 @@ */ import type { DaemonWorkspaceGitStatus } from '@qwen-code/sdk/daemon'; -import { CircleDotIcon, LayersIcon, TriangleAlertIcon } from 'lucide-react'; +import { + CircleDotIcon, + GitForkIcon, + LayersIcon, + TriangleAlertIcon, +} from 'lucide-react'; import { useI18n } from '../i18n'; import styles from './ChatEditor.module.css'; import { @@ -87,10 +92,12 @@ export function GitBranchChipContent({ branch, status, compact, + worktree = false, }: { branch: string; status?: DaemonWorkspaceGitStatus; compact: boolean; + worktree?: boolean; }) { const { t } = useI18n(); const s = deriveStatus(status); @@ -99,7 +106,13 @@ export function GitBranchChipContent({ <> - {s.detached ? : } + {worktree ? ( + + ) : s.detached ? ( + + ) : ( + + )} {compact && tone && ( void; + worktree?: boolean; }) { const { t } = useI18n(); const s = deriveStatus(status); @@ -187,10 +202,16 @@ export function GitBranchIndicator({ 'data-dirty': s.dirty ? 'true' : undefined, 'data-operation': s.operation ?? undefined, 'data-clickable': onOpenDiff ? 'true' : undefined, + 'data-worktree': worktree ? 'true' : undefined, } as const; const chipInner = ( - + ); return ( diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 7890f773f79..2fb02fd5116 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -476,6 +476,14 @@ white-space: nowrap; } +.sessionBadgeIcon { + display: inline; + vertical-align: -1px; + margin-right: 3px; + color: var(--color-accent-fg, #8b5cf6); + flex-shrink: 0; +} + .projectName { flex: 0 1 auto; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index b42d006e431..af35fd3dd4b 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -41,6 +41,7 @@ import { ArchiveRestoreIcon, DownloadIcon, FolderInputIcon, + GitForkIcon, PencilIcon, PinIcon, Trash2Icon, @@ -222,7 +223,10 @@ interface WebShellSidebarProps { onOpenSplitView: () => void; /** Whether to offer the in-window split view (large screens only). */ canOpenSplitView?: boolean; - onNewSession: (workspaceCwd?: string) => Promise | boolean; + onNewSession: ( + workspaceCwd?: string, + opts?: { worktree?: { slug?: string } }, + ) => Promise | boolean; onLoadSession: ( sessionId: string, workspaceCwd?: string, @@ -1383,14 +1387,14 @@ export function WebShellSidebar({ ]); const handleNewSession = useCallback( - (workspaceCwd?: string) => { + (workspaceCwd?: string, opts?: { worktree?: { slug?: string } }) => { if (creatingSessionRef.current) return; creatingSessionRef.current = true; setCreatingSession(true); void (async () => { try { - const created = await onNewSession(workspaceCwd); + const created = await onNewSession(workspaceCwd, opts); if (created) { void reload().catch(() => undefined); bumpWorkspaceReload(); @@ -2552,7 +2556,17 @@ export function WebShellSidebar({ ) : ( <> - {label} + + {session.worktree && ( + + )} + {label} +
{attentionLabel && ( + handleNewSession(ws.primary ? undefined : cwd, { + worktree: {}, + }) + } formatTime={(iso) => formatRelativeTime(iso, t)} searchQuery={searchQuery} expanded={ws.primary ? projectExpanded : undefined} @@ -3558,6 +3577,7 @@ export function WebShellSidebar({ !ws.primary && ws.removable === true; if (!ws.trusted && !canRemove) return null; + const wsCwd = ws.primary ? undefined : ws.cwd; return (
{ event.preventDefault(); event.stopPropagation(); - handleNewSession( - ws.primary ? undefined : ws.cwd, - ); + handleNewSession(wsCwd); }} > { }); describe('WorkspaceSection git chip', () => { - it('renders a clickable git chip for a trusted repo and opens its diff', async () => { + it('renders a git chip inside a dropdown trigger for a trusted repo', async () => { const status: DaemonWorkspaceGitStatus = { v: 2, workspaceCwd: '/tmp/project', @@ -136,17 +136,12 @@ describe('WorkspaceSection git chip', () => { const chip = gitChip(); expect(chip).not.toBeNull(); - expect(chip?.tagName).toBe('BUTTON'); + // The chip is now a read-only OUTPUT inside a DropdownMenuTrigger + // (the dropdown offers "Changes" and "New Worktree Task"). + expect(chip?.tagName).toBe('OUTPUT'); expect(chip?.getAttribute('data-dirty')).toBe('true'); - // Icon-only (compact) form: the branch name is not shown as inline text but - // stays reachable via the accessible name (the hover tooltip). expect(chip?.className).toContain(gitStyles.gitBranchChipCompact); expect(chip?.getAttribute('aria-label')).toContain('main'); - - act(() => { - chip?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - expect(onOpenGitDiff).toHaveBeenCalledWith('/tmp/project'); }); it('hides the chip for an untrusted workspace and never queries git', async () => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index 851382fd578..a8dcd2b1c4f 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -13,9 +13,16 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; -import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; +import { FolderClosedIcon, FolderOpenIcon, GitForkIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; +import { useI18n } from '../../i18n'; import { readWorkspaceCollapsedGroupIds, writeWorkspaceCollapsedGroupIds, @@ -97,6 +104,8 @@ interface WorkspaceSectionProps { * fires this on click. Omitted for untrusted workspaces (no git surface). */ onOpenGitDiff?: (workspaceCwd: string) => void; + /** Create a new worktree-isolated session in this workspace. */ + onNewWorktreeSession?: (workspaceCwd: string) => void; } export function WorkspaceSection({ @@ -126,7 +135,9 @@ export function WorkspaceSection({ groupActionsDisabled, excludePinned = false, onOpenGitDiff, + onNewWorktreeSession, }: WorkspaceSectionProps) { + const { t } = useI18n(); const [sessions, setSessions] = useState([]); const [groups, setGroups] = useState([]); const [loadError, setLoadError] = useState(false); @@ -352,14 +363,40 @@ export function WorkspaceSection({ )} {onOpenGitDiff && workspace.trusted && gitStatus?.branch && ( - - onOpenGitDiff(workspace.cwd)} - /> - + + + + + + + + onOpenGitDiff(workspace.cwd)}> + {t('gitDiff.title')} + + {onNewWorktreeSession && ( + onNewWorktreeSession(workspace.cwd)} + className="flex-col items-start gap-0" + > + + + {t('sidebar.newWorktreeTask')} + + + {t('sidebar.worktreeDescription')} + + + )} + + )} {headerActions?.(actionsVisible)}
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 03940d1b41a..b702b637b59 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -47,6 +47,9 @@ const EN: Messages = { 'gitDiff.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`, 'gitDiff.expand': (v) => `Show changes for ${v?.path ?? 'file'}`, 'gitDiff.collapse': (v) => `Hide changes for ${v?.path ?? 'file'}`, + 'worktree.welcomeTitle': 'Worktree isolated session', + 'worktree.welcomeDesc': + 'Changes are made in a separate copy of the repo and won’t affect your main branch', 'workspace.paneLabel': (v) => `Workspace: ${v?.name ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', @@ -871,6 +874,9 @@ const EN: Messages = { 'sidebar.toggleMenu': 'Toggle menu', 'sidebar.newChat': 'New chat', 'sidebar.newTask': 'New task', + 'sidebar.newWorktreeTask': 'New worktree task', + 'sidebar.worktreeDescription': + 'Work in an isolated copy — changes stay separate from the main branch', 'sidebar.plugins': 'Plugins', 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', @@ -2178,6 +2184,8 @@ const ZH: Messages = { 'gitDiff.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`, 'gitDiff.expand': (v) => `显示 ${v?.path ?? '文件'} 的变更`, 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, + 'worktree.welcomeTitle': 'Worktree 隔离会话', + 'worktree.welcomeDesc': '变更在仓库的独立副本中进行,不会影响主分支', 'workspace.paneLabel': (v) => `工作区:${v?.name ?? ''}`, // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // a wire name with no entry here falls back to the English display name via @@ -3013,6 +3021,8 @@ const ZH: Messages = { 'sidebar.toggleMenu': '切换菜单', 'sidebar.newChat': '新对话', 'sidebar.newTask': '新建任务', + 'sidebar.newWorktreeTask': '新建 Worktree 任务', + 'sidebar.worktreeDescription': '在隔离的仓库副本中工作——变更不会影响主分支', 'sidebar.plugins': '插件', 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶', diff --git a/packages/web-shell/client/utils/sessionPreparation.test.ts b/packages/web-shell/client/utils/sessionPreparation.test.ts index 9cf38078f73..2e764bebcaf 100644 --- a/packages/web-shell/client/utils/sessionPreparation.test.ts +++ b/packages/web-shell/client/utils/sessionPreparation.test.ts @@ -133,7 +133,7 @@ describe('createAndAttachSessionForPrompt', () => { modeId: 'yolo', warn, }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({}); expect(order).toEqual(['create', 'attach', 'model']); expect(warn).toHaveBeenCalledWith( diff --git a/packages/web-shell/client/utils/sessionPreparation.ts b/packages/web-shell/client/utils/sessionPreparation.ts index 9f3807f9dcc..a638d6e81a7 100644 --- a/packages/web-shell/client/utils/sessionPreparation.ts +++ b/packages/web-shell/client/utils/sessionPreparation.ts @@ -11,7 +11,11 @@ type PromptSessionActions = { workspaceCwd?: string; approvalMode?: DaemonApprovalMode; sourceType?: string; - }) => Promise<{ sessionId: string }>; + worktree?: { slug?: string }; + }) => Promise<{ + sessionId: string; + worktree?: { slug: string; path: string; branch: string }; + }>; attachSession: () => Promise; clearSession: () => Promise; releaseSession: (sessionId: string) => Promise; @@ -27,6 +31,7 @@ export async function createAndAttachSessionForPrompt({ modelId, modeId, workspaceCwd, + worktree, onSessionCreated, onSessionAllocated, getCurrentSessionId, @@ -36,11 +41,12 @@ export async function createAndAttachSessionForPrompt({ modelId?: string; modeId?: string; workspaceCwd?: string; + worktree?: { slug?: string }; onSessionCreated?: (sessionId: string) => Promise | void; onSessionAllocated?: (sessionId: string) => void; getCurrentSessionId: () => string | undefined; warn?: (message?: unknown, ...optionalParams: unknown[]) => void; -}): Promise { +}): Promise<{ worktree?: { slug: string; path: string; branch: string } }> { // Seed the approval mode in the create request itself so the daemon applies // it atomically at spawn (`POST /session` → `spawnOrAttach({ approvalMode })`), // saving a follow-up round-trip. Approval mode is fail-closed at spawn: if the @@ -49,11 +55,13 @@ export async function createAndAttachSessionForPrompt({ // The model, by contrast, stays a best-effort follow-up below. const approvalMode = modeId && isDaemonApprovalMode(modeId) ? modeId : undefined; - const { sessionId } = await sessionActions.createSession({ - workspaceCwd, - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, - ...(approvalMode ? { approvalMode } : {}), - }); + const { sessionId, worktree: worktreeInfo } = + await sessionActions.createSession({ + workspaceCwd, + sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + ...(approvalMode ? { approvalMode } : {}), + ...(worktree ? { worktree } : {}), + }); onSessionAllocated?.(sessionId); let preparationStep = 'prepare new session'; try { @@ -124,4 +132,5 @@ export async function createAndAttachSessionForPrompt({ warn('[WebShell] failed to set model for new session:', error); }); } + return worktreeInfo ? { worktree: worktreeInfo } : {}; } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 40911f4faa7..cefbc1b0738 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2055,7 +2055,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }), createDetachedSession: ( workspaceCwd?: string, - overrides?: Pick, + overrides?: Pick< + CreateSessionRequest, + 'approvalMode' | 'sourceType' | 'worktree' + >, ) => { const client = workspaceClientRef.current ?? @@ -2076,6 +2079,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...(overrides?.sourceType !== undefined ? { sourceType: overrides.sourceType } : {}), + ...(overrides?.worktree !== undefined + ? { worktree: overrides.worktree } + : {}), }; const requestClientId = clientId ? clientIdRef.current diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 9f1e7bba774..ce14b5b1f4d 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -61,7 +61,10 @@ export interface CreateDaemonSessionActionsArgs { getCreateSessionRequest: () => CreateSessionRequest; createDetachedSession: ( workspaceCwd?: string, - overrides?: Pick, + overrides?: Pick< + CreateSessionRequest, + 'approvalMode' | 'sourceType' | 'worktree' + >, ) => Promise; getConnection: () => DaemonConnectionState; hasSessionActivePrompt: () => boolean; @@ -619,6 +622,7 @@ export function createDaemonSessionActions({ workspaceCwd?: string; approvalMode?: DaemonApprovalMode; sourceType?: string; + worktree?: { slug?: string }; }) { try { manualSessionClearRef.current = false; @@ -635,6 +639,9 @@ export function createDaemonSessionActions({ ...(options?.sourceType !== undefined ? { sourceType: options.sourceType } : {}), + ...(options?.worktree !== undefined + ? { worktree: options.worktree } + : {}), }; const session = sessionRef.current; const activeSession =