From f35fc8dd10e7290696a2941a40549faf2bf92b6d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 24 Jul 2026 22:35:47 +0800 Subject: [PATCH 1/7] perf(web-shell): paint the composer git chip before git status completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sessions gated the chip on a full `git status --porcelain` subprocess behind GET /workspaces/:ws/git, so the branch chip appeared hundreds of milliseconds (worst case seconds) after the composer was ready. The daemon now keeps a per-workspace last-known summary with in-flight dedup and a 2s background-refresh throttle: the default GET returns the cached status (branch-only on a cold start) immediately and recomputes in the background, publishing git_status_changed over SSE only on a delta, while ?wait=1 keeps the previous blocking semantics. The composer fetches both paths concurrently — the fresh GET also covers the no-session state, which has no per-session SSE stream — so the branch paints in ~3ms and the counters land when the computation finishes. The sidebar keeps wait:true since it has no SSE fill-in path. --- ...026-07-24-webshell-git-status-fast-path.md | 192 +++++++++++++++ ...026-07-24-webshell-git-status-fast-path.md | 57 +++++ .../src/serve/routes/workspace-git.test.ts | 54 ++++- .../cli/src/serve/routes/workspace-git.ts | 16 +- .../cli/src/serve/workspace-git-state.test.ts | 220 +++++++++++++++++- packages/cli/src/serve/workspace-git-state.ts | 157 +++++++++++-- .../sdk-typescript/src/daemon/DaemonClient.ts | 17 +- packages/sdk-typescript/src/daemon/events.ts | 5 + .../src/daemon/ui/normalizer.ts | 3 + .../test/unit/DaemonClient.test.ts | 30 +++ packages/web-shell/client/App.test.tsx | 83 ++++++- packages/web-shell/client/App.tsx | 46 +++- .../components/sidebar/WorkspaceSection.tsx | 7 +- .../webui/src/daemon/session/mappers.test.ts | 43 ++++ packages/webui/src/daemon/session/mappers.ts | 14 ++ packages/webui/src/daemon/session/types.ts | 7 + 16 files changed, 906 insertions(+), 45 deletions(-) create mode 100644 docs/design/2026-07-24-webshell-git-status-fast-path.md create mode 100644 docs/plans/2026-07-24-webshell-git-status-fast-path.md diff --git a/docs/design/2026-07-24-webshell-git-status-fast-path.md b/docs/design/2026-07-24-webshell-git-status-fast-path.md new file mode 100644 index 00000000000..eef97e8d4df --- /dev/null +++ b/docs/design/2026-07-24-webshell-git-status-fast-path.md @@ -0,0 +1,192 @@ +# Web Shell git chip 快速显示:branch 先行 + status 缓存/推送 + +日期:2026-07-24 +状态:待确认 + +## 背景与问题 + +Web Shell 新建会话时,composer 工具条里的 git chip 出现得慢。根因(已逐行确认): + +1. **daemon 端 branch 被 `git status` 子进程拖住**——`WorkspaceGitState.getStatus()` + (`packages/cli/src/serve/workspace-git-state.ts`)里 branch 有毫秒级快路径 + (`resolveBranchName` 读 `HEAD` 文件 + reflog watcher),但 HTTP 响应必须等 + `getGitWorkingTreeStatus()` 完成——每次请求同步 spawn + `git status --porcelain=v1 --branch -z`(`gitDiff.ts` runGit,5s 超时,零缓存)。 +2. **前端 chip 渲染被全量 status 门控**——新建会话时 chip 文本只认 + `selectedWorkspaceGitStatus?.branch`(App.tsx 7860–7871),它要等整个 + HTTP + git status 往返(App.tsx 1480–1520 的 effect)才 setState。 +3. **同一路由并发打两次**——`DaemonSessionProvider` 的 metadata 拉取 + (`DaemonSessionProvider.tsx:1320`,只用 `.branch`)与 App.tsx 的 git-status + effect 几乎同时发 `GET /workspaces/:ws/git`,daemon 端 spawn 两个相同子进程。 +4. 串行门控:`activeWorkspaceCwd` 依赖 `GET /capabilities` 先完成。 + +## 目标与非目标 + +目标: + +- 新建会话 / 首屏时 git chip 以 branch 文本**立即出现**(一个本地 HTTP RTT,毫秒级), + dirty/ahead/behind/stash 等计数器在 daemon 算完即补齐(`wait: true` fresh 请求; + 有会话时另有 SSE 实时推送)。 +- 消除重复 `git status` 子进程(并发去重 + stale-while-revalidate)。 +- 不回归:侧栏 workspace chip(要计数器)、worktree 会话 chip、detached HEAD、 + 非 git workspace、git 失败降级。 + +非目标: + +- worktree `?cwd=` 路径不引入 watcher/缓存(维持现状:直接计算,避免每 worktree + 泄漏一个 fs watcher)。worktree chip 延迟不变。 +- 不做 daemon 启动预热(capabilities 后前端立即就会来请求,预热收益小)。 +- 不改 `git_branch_changed` 现有语义。 + +## 方案总览 + +三层改动:daemon 缓存 + 后台刷新 + SSE 推送(P0),响应两阶段化(P1), +前端消费 SSE 并保留慢路径给需要的调用方(P2 重评估后见下文)。 + +### P0+P1:daemon——`WorkspaceGitState` 缓存、去重、后台刷新、SSE 推送 + +`WorkspaceGitEntry` 扩展: + +```ts +interface WorkspaceGitEntry { + branch: string | undefined; // watcher 保持新鲜(现状) + dispose: () => void; // 现状 + status?: GitWorkingTreeStatus; // 上次计算的原始 working-tree summary + statusComputedAt?: number; // epoch ms + statusPromise?: Promise; // in-flight 去重 + disposed?: boolean; // dispose 后禁止 publish +} +``` + +`getStatus(cwd, bridge, opts?: { wait?: boolean })` 语义改为: + +- **默认(fast path)**:确保 entry 存在(branch 秒回);按 + stale-while-revalidate 踢一次后台刷新(见下);**立即返回**上次缓存的 + status(materialize:overlay `entry.branch ?? status.branch`,v2 形状 + + `computedAt`);从未计算过时返回 branch-only `{ v, workspaceCwd, branch }` + (无 `computedAt`,前端据此区分"未计算"与"clean")。 +- **`wait: true`**:等待(或发起并等待,in-flight 复用)一次新鲜计算, + 返回全量 status。计算失败降级 branch-only(现状语义)。 + +后台刷新 `refreshStatus(entry)`: + +- in-flight 复用:`statusPromise` 存在则直接返回它。 +- 节流:距上次发起 < 2s 则跳过(防 focus 风暴串行排队 git 子进程)。 +- 计算成功且与缓存的 enriched 字段有差异 → 更新缓存 + 通过 + `bridge.publishWorkspaceEvent({ type: 'git_status_changed', data })` 推送 + materialized 全量 status(data 即 `DaemonWorkspaceGitStatus`,含 workspaceCwd)。 + 首次计算(缓存为空)视为有差异,必推送——这是冷启动 chip 补齐计数器的通道。 +- 无差异 → 只更新缓存,不推送(避免 30s 轮询每次都引起前端 setState/re-render)。 +- 计算失败/非 git 目录 → 保留旧缓存,不推送。 +- entry 已 disposed → 不推送。 + +**无 TTL**。last-known + 每次 GET 触发后台刷新 + SSE 纠偏已足够; +节流 2s 承担" TTL 防爆"职责。`wait: true` 调用方总是拿到新鲜计算(in-flight 复用)。 + +路由(`packages/cli/src/serve/routes/workspace-git.ts`): + +- `/workspace/git` 与 `/workspaces/:workspace/git` 解析 `?wait=1`,透传给 + `getStatus`。默认 fast。 +- worktree `?cwd=` 分支维持现状(直接 `getGitWorkingTreeStatus`,不进缓存)。 + +### SDK(`packages/sdk-typescript`) + +- `events.ts`:`DAEMON_KNOWN_EVENT_TYPE_VALUES` 增加 `'git_status_changed'` + (紧跟 `'git_branch_changed'`)。旧 SDK 经 `asKnownDaemonEvent` 静默丢弃—— + 向后兼容,无需协议 bump(与 `followup_suggestion` 同模式)。 +- `ui/normalizer.ts`:`case 'git_status_changed': return [];`(与 + `git_branch_changed` 一样由 session mappers 处理,不进 UI 归一化流)。 +- `DaemonClient.workspaceGit` 签名改为 options 对象: + `workspaceGit(opts?: { cwd?: string; wait?: boolean })`,拼 query + (`cwd` 与 `wait=1` 可组合)。迁移全部 4 个调用点(App.tsx、WorkspaceSection、 + DaemonSessionProvider ×2 处)与 SDK 单测。 + +### webui(`packages/webui`) + +- `session/types.ts`:`DaemonConnectionState` 增加 + `gitStatus?: DaemonWorkspaceGitStatus`(仅当前 workspace 的全量 status, + 由 SSE 维护)。 +- `session/mappers.ts`:`updateConnectionFromDaemonEvent` 增加 + `case 'git_status_changed'`——`data.workspaceCwd` 与 + `current.workspaceCwd` 不匹配则忽略(镜像 `git_branch_changed` 的守卫), + 否则 `setConnection({ ...current, gitStatus: data })`。 + +### web-shell(`packages/web-shell`) + +- `App.tsx` git-status effect:composer 用**客户端 stale-while-revalidate**—— + 每次触发并发两个请求(worktree 会话除外,见下): + 1. `workspaceGit({ cwd: sessionWorktree?.path })`(fast):last-known 秒回, + 立即渲染(冷缓存 branch-only); + 2. `workspaceGit({ wait: true })`(fresh):daemon 后台算完即返回全量 status, + 补齐计数器。两个请求在 daemon 端共享同一次计算(in-flight 去重), + 不增加 git 子进程数。 +- **为什么 fresh 请求必须存在(反向审计发现)**:SSE `git_status_changed` 走 + 每会话事件流(`GET /session/:id/events`),**新建会话态(deferred connect, + 无 sessionId)没有 SSE 订阅**——只发 fast GET 时计数器要等 30s 轮询或 + focus 才补上。fresh 请求不依赖会话存在,保证"branch 立即、计数器算完即得" + 在所有会话态成立。(`git_branch_changed` 今天就有同样的无会话盲区,非回归。) +- `App.tsx` 另保留 SSE 同步 effect:`connection.gitStatus` 变化且 + `workspaceCwd` 匹配、无 `sessionWorktree` 时写入 `selectedWorkspaceGitStatus`—— + 覆盖**有会话时**两次轮询之间的实时推送(另一客户端/CLI 触发的后台刷新 + 推送过来)。 +- worktree 会话只发 fast 请求:`?cwd=` 路径本就绕过缓存直接计算 + (fast 与 wait 等价),行为不变。 +- `sidebar/WorkspaceSection.tsx`:`workspaceGit({ wait: true })`——侧栏 chip + 要计数器且没有 SSE/fresh 双发通道,保留阻塞语义(现状行为不变;非活跃 + workspace 没有 SSE 通道)。 + +### P2 重评估(按价值裁剪) + +原 P2(前端去重:provider 首拉存全量 status 给 App 复用)**降格为不做**: +P0 的 daemon 端 in-flight 去重已消除重复 `git status` 子进程(原问题的实质), +剩下的只是一次毫秒级本地 HTTP 往返。把全量 status 存进 provider 再让 App +复用会引入跨层耦合(provider→App 初始值协议),收益约等于零。 +provider 两处 `workspaceGit()` 调用只取 `.branch`,走默认 fast path 即可,零改动。 + +## 兼容性 + +- 路由响应形状不变(v2,enriched 字段本就 optional);新增 `?wait=1` query 为可选。 +- 默认 fast path 语义变化:调用方可能收到 last-known(旧缓存)而非新鲜计算。 + 全部现存调用方逐一核对: + - `DaemonSessionProvider`(×2):只读 `.branch`——branch 始终新鲜(watcher),无影响。 + - App.tsx composer chip:正是本设计的服务对象。 + - WorkspaceSection:显式改 `wait: true`,语义不变。 +- 新 SSE 事件旧客户端静默丢弃(SDK known-list 机制)。 +- `git status_changed` 仅 publish 给该 workspace 的 session SSE bus + (`publishWorkspaceEvent` 现有机制,含多 workspace 隔离)。 + +## 风险与缓解 + +| 风险 | 缓解 | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| chip 先显示 branch 后出现计数器,工具条宽度抖动 | 已有隐藏测量副本(ChatEditor toolbar-measure)处理 re-measure;接受轻微 shift | +| branch-only 响应被误读为 "clean" | branch-only 不携带 `computedAt`;GitBranchIndicator 现有逻辑在 `computedAt` 缺失时不显示 "clean" | +| 缓存 status 与 watcher branch 不一致 | materialize 时 overlay `entry.branch ?? status.branch`(现状逻辑保留) | +| 后台刷新泄漏(dispose 后 publish) | `disposed` flag 守卫 | +| focus 风暴触发串行 git spawn | 2s 节流 + in-flight 复用 | + +## 测试计划 + +单测: + +- `workspace-git-state.test.ts`(扩展):fast path 立即返回 last-known; + 冷缓存返回 branch-only 且无 `computedAt`;后台刷新有差异才 publish + `git_status_changed`;首次计算必 publish;并发 getStatus 只触发一次 + `getGitWorkingTreeStatus`;2s 节流;`wait: true` 等待新鲜计算; + 计算失败保留旧缓存不 publish;dispose 后不 publish。 +- `routes/workspace-git.test.ts`(扩展):`?wait=1` 透传;worktree `?cwd=` + 路径不进缓存(维持直接计算)。 +- SDK `DaemonClient.test.ts`:options 对象 query 拼接(cwd / wait / 组合)。 +- webui `mappers.test.ts`:`git_status_changed` 匹配/不匹配 workspaceCwd + 两种分支。 + +E2E(`.qwen/e2e-tests/2026-07-24-git-chip-fast-branch.md`,验证阶段补): +真 daemon + web shell,大工作区新建会话——chip(branch)在编辑器就绪后立刻出现, +计数器随后补齐;侧栏 chip 行为不变;focus/30s 轮询仍刷新;worktree 会话 chip 不变。 + +## 被否决的备选 + +- **TTL 缓存(无后台刷新/SSE)**:只能加速重复请求,冷启动仍需等 git status—— + 不解决"新建会话 chip 慢"的主诉。 +- **capabilities 后 daemon 预热**:首 GET 与预热几乎同时,in-flight 去重后收益≈0。 +- **前端只做去重/合并请求**:不消除 git status 子进程等待,治标。 diff --git a/docs/plans/2026-07-24-webshell-git-status-fast-path.md b/docs/plans/2026-07-24-webshell-git-status-fast-path.md new file mode 100644 index 00000000000..a7bb3d1ac25 --- /dev/null +++ b/docs/plans/2026-07-24-webshell-git-status-fast-path.md @@ -0,0 +1,57 @@ +# 实施计划:Web Shell git chip 快速显示 + +日期:2026-07-24 +设计文档:`docs/design/2026-07-24-webshell-git-status-fast-path.md` + +## Goal + +新建会话/首屏时 composer git chip 以 branch 立即出现,计数器经 SSE 补齐; +daemon 端消除重复 `git status` 子进程(缓存 + in-flight 去重 + 2s 节流)。 + +## Architecture + +daemon `WorkspaceGitState` 增加 per-workspace status 缓存与后台刷新 +(stale-while-revalidate),路由默认 fast、`?wait=1` 阻塞;SDK 新增 +`git_status_changed` 事件与 `workspaceGit({cwd, wait})` options; +webui 把 SSE status 存入 connection.gitStatus;web-shell App 消费它补齐 chip, +侧栏显式走 `wait: true` 保持现状语义。 + +## Tech Stack + +TypeScript ESM monorepo(cli / sdk-typescript / webui / web-shell),vitest。 + +## File Structure + +| 包 | 文件 | 改动 | +| --------- | ------------------------------------------------ | --------------------------------------------------------------------------------- | +| cli | `src/serve/workspace-git-state.ts` | 缓存 + 去重 + 节流 + 后台刷新 + SSE publish + `getStatus` opts | +| cli | `src/serve/routes/workspace-git.ts` | 解析 `?wait=1` 透传(两条路由;worktree `?cwd=` 不变) | +| cli | `src/serve/workspace-git-state.test.ts` | 新增 fast/cache/dedup/throttle/wait/dispose 用例 | +| cli | `src/serve/routes/workspace-git.test.ts` | `?wait=1` 透传、worktree 不进缓存 | +| sdk | `src/daemon/events.ts` | known types 增加 `git_status_changed` | +| sdk | `src/daemon/ui/normalizer.ts` | case → `return []` | +| sdk | `src/daemon/DaemonClient.ts` | `workspaceGit(opts?: {cwd?, wait?})` options 对象 | +| sdk | `test/unit/DaemonClient.test.ts` | query 拼接用例 | +| webui | `src/daemon/session/types.ts` | `DaemonConnectionState.gitStatus?` | +| webui | `src/daemon/session/mappers.ts` | `case 'git_status_changed'`(workspaceCwd 守卫) | +| webui | `src/daemon/session/mappers.test.ts` | 匹配/不匹配两分支 | +| web-shell | `client/App.tsx` | options 迁移 + fast/fresh 双发(fresh 不依赖 SSE,覆盖无会话态)+ SSE 同步 effect | +| web-shell | `client/components/sidebar/WorkspaceSection.tsx` | `workspaceGit({ wait: true })` | + +## Tasks + +- [x] 1. daemon:`WorkspaceGitState` 缓存/去重/节流/后台刷新/SSE + `getStatus` opts +- [x] 2. daemon:路由 `?wait=1` 透传 +- [x] 3. daemon:单测(state + route) +- [x] 4. sdk:事件类型 + normalizer + `workspaceGit` options 对象 + 单测 +- [x] 5. webui:connection.gitStatus + mapper + 单测 +- [x] 6. web-shell:App.tsx(options 迁移 + fast/fresh 双发 + SSE 同步 effect)+ WorkspaceSection `wait: true` +- [x] 7. `npm run build && npm run typecheck` + 各包目标单测全绿 +- [x] 8. E2E 测试计划写入 `.qwen/e2e-tests/`,test-engineer 实测验证(协议级 6/6 通过,记录见计划文件) +- [x] 9. 自审 diff(两轮)+ `/review`(medium local:无遗留发现;评审中补了 2 个 App 用例) + +## 备注 + +- P2(前端请求去重)经价值重评估后不做——daemon 去重已消除重复子进程, + 前端再合并只剩一次毫秒级本地 HTTP,不抵跨层耦合成本。设计文档已记录。 +- worktree `?cwd=` 路径维持直接计算,不进缓存(避免 watcher 泄漏)。 diff --git a/packages/cli/src/serve/routes/workspace-git.test.ts b/packages/cli/src/serve/routes/workspace-git.test.ts index beb232b4f98..b4f03765ff5 100644 --- a/packages/cli/src/serve/routes/workspace-git.test.ts +++ b/packages/cli/src/serve/routes/workspace-git.test.ts @@ -62,7 +62,32 @@ describe('workspace Git routes', () => { workspaceCwd: '/work/main', branch: 'main', }); - expect(getStatus).toHaveBeenCalledWith('/work/main', bridge); + expect(getStatus).toHaveBeenCalledWith('/work/main', bridge, { + wait: false, + }); + }); + + it('passes wait:true through to the bound workspace git state', async () => { + const app = express(); + const bridge = runtime('primary', '/work/main', true).bridge; + const getStatus = vi.fn(async () => ({ + v: 1 as const, + workspaceCwd: '/work/main', + branch: 'main', + })); + registerWorkspaceGitRoutes(app, { + boundWorkspace: '/work/main', + bridge, + gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, + }); + + const response = await request(app).get('/workspace/git?wait=1'); + + expect(response.status).toBe(200); + expect(getStatus).toHaveBeenCalledWith('/work/main', bridge, { + wait: true, + }); }); it('returns a structured error when bound Git status fails', async () => { @@ -111,6 +136,33 @@ describe('workspace Git routes', () => { expect(getStatus).toHaveBeenCalledWith( secondary.workspaceCwd, secondary.bridge, + { wait: false }, + ); + }); + + it('passes wait:true through to the qualified workspace git state', async () => { + const app = express(); + const primary = runtime('primary', '/work/main', true); + const getStatus = vi.fn(async () => ({ + v: 1 as const, + workspaceCwd: primary.workspaceCwd, + branch: 'main', + })); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry: registry([primary]), + gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, + }); + + const response = await request(app).get('/workspaces/primary/git?wait=1'); + + expect(response.status).toBe(200); + expect(getStatus).toHaveBeenCalledWith( + primary.workspaceCwd, + primary.bridge, + { + wait: true, + }, ); }); diff --git a/packages/cli/src/serve/routes/workspace-git.ts b/packages/cli/src/serve/routes/workspace-git.ts index ed532baf2dd..a5f204c9ea9 100644 --- a/packages/cli/src/serve/routes/workspace-git.ts +++ b/packages/cli/src/serve/routes/workspace-git.ts @@ -29,11 +29,14 @@ export function registerWorkspaceGitRoutes( sendBridgeError: SendBridgeError; }, ): void { - app.get('/workspace/git', async (_req, res) => { + app.get('/workspace/git', async (req, res) => { try { - res - .status(200) - .json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge)); + const wait = req.query['wait'] === '1'; + res.status(200).json( + await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge, { + wait, + }), + ); } catch (err) { deps.sendBridgeError(res, err, { route: 'GET /workspace/git' }); } @@ -106,9 +109,12 @@ export function registerWorkspaceQualifiedGitRoutes( : { v: 2, workspaceCwd: gitCwd, branch: null }, ); } else { + const wait = req.query['wait'] === '1'; res .status(200) - .json(await deps.gitState.getStatus(gitCwd, runtime.bridge)); + .json( + await deps.gitState.getStatus(gitCwd, runtime.bridge, { wait }), + ); } } catch (err) { deps.sendBridgeError(res, err, { route }); diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index deb61eba79c..8fd50180c2d 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -9,6 +9,7 @@ import { getGitWorkingTreeStatus, resolveBranchName, watchRepoBranch, + type GitWorkingTreeStatus, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; import { WorkspaceGitState } from './workspace-git-state.js'; @@ -23,6 +24,31 @@ const getGitWorkingTreeStatusMock = vi.mocked(getGitWorkingTreeStatus); const resolveBranchNameMock = vi.mocked(resolveBranchName); const watchRepoBranchMock = vi.mocked(watchRepoBranch); +function summary( + overrides: Partial = {}, +): GitWorkingTreeStatus { + return { + branch: 'main', + detached: false, + hasUpstream: true, + ahead: 0, + behind: 0, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + stashCount: 0, + ...overrides, + }; +} + +function bridgeWith(publishWorkspaceEvent = vi.fn()) { + return { + bridge: { publishWorkspaceEvent } as unknown as AcpSessionBridge, + publishWorkspaceEvent, + }; +} + describe('WorkspaceGitState', () => { beforeEach(() => { vi.clearAllMocks(); @@ -86,9 +112,13 @@ describe('WorkspaceGitState', () => { const state = new WorkspaceGitState(); await expect( - state.getStatus('/workspace', { - publishWorkspaceEvent: vi.fn(), - } as unknown as AcpSessionBridge), + state.getStatus( + '/workspace', + { + publishWorkspaceEvent: vi.fn(), + } as unknown as AcpSessionBridge, + { wait: true }, + ), ).resolves.toEqual({ v: 2, workspaceCwd: '/workspace', @@ -158,7 +188,7 @@ describe('WorkspaceGitState', () => { publishWorkspaceEvent: vi.fn(), } as unknown as AcpSessionBridge; - const status = await state.getStatus('/workspace', bridge); + const status = await state.getStatus('/workspace', bridge, { wait: true }); expect(status).toMatchObject({ v: 2, workspaceCwd: '/workspace', @@ -197,7 +227,7 @@ describe('WorkspaceGitState', () => { publishWorkspaceEvent: vi.fn(), } as unknown as AcpSessionBridge; - const status = await state.getStatus('/workspace', bridge); + const status = await state.getStatus('/workspace', bridge, { wait: true }); expect(status).not.toHaveProperty('operation'); }); @@ -223,7 +253,9 @@ describe('WorkspaceGitState', () => { publishWorkspaceEvent: vi.fn(), } as unknown as AcpSessionBridge; - await expect(state.getStatus('/workspace', bridge)).resolves.toMatchObject({ + await expect( + state.getStatus('/workspace', bridge, { wait: true }), + ).resolves.toMatchObject({ branch: 'a1b2c3d', detached: true, }); @@ -279,4 +311,180 @@ describe('WorkspaceGitState', () => { expect(resolveBranchNameMock).toHaveBeenCalledTimes(2); state.dispose(); }); + + it('serves branch-only on a cold cache, then publishes the computed summary', async () => { + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + getGitWorkingTreeStatusMock.mockResolvedValue(summary({ staged: 2 })); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + + // Fast path: last-known (nothing yet) without waiting for `git status`. + await expect(state.getStatus('/workspace', bridge)).resolves.toEqual({ + v: 2, + workspaceCwd: '/workspace', + branch: 'main', + }); + + await vi.waitFor(() => + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'git_status_changed', + data: expect.objectContaining({ + workspaceCwd: '/workspace', + branch: 'main', + staged: 2, + }), + }), + ); + + // Warm cache: the fast path now returns the enriched last-known status. + const warm = await state.getStatus('/workspace', bridge); + expect(warm).toMatchObject({ staged: 2 }); + expect(typeof warm.computedAt).toBe('number'); + state.dispose(); + }); + + it('publishes a follow-up event only when the summary changes', async () => { + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + getGitWorkingTreeStatusMock.mockResolvedValue(summary({ staged: 1 })); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + + await state.getStatus('/workspace', bridge); + await vi.waitFor(() => + expect(publishWorkspaceEvent).toHaveBeenCalledTimes(1), + ); + + vi.useFakeTimers({ toFake: ['Date'] }); + try { + // Past the throttle window: a refresh runs but an unchanged summary + // must not re-publish (clients poll on a 30s cadence; a per-poll event + // would needlessly re-render). + vi.setSystemTime(Date.now() + 2_000); + await state.getStatus('/workspace', bridge); + await vi.waitFor(() => + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(2), + ); + expect(publishWorkspaceEvent).toHaveBeenCalledTimes(1); + + getGitWorkingTreeStatusMock.mockResolvedValue(summary({ staged: 3 })); + vi.setSystemTime(Date.now() + 2_000); + await state.getStatus('/workspace', bridge); + await vi.waitFor(() => + expect(publishWorkspaceEvent).toHaveBeenCalledTimes(2), + ); + expect(publishWorkspaceEvent).toHaveBeenLastCalledWith({ + type: 'git_status_changed', + data: expect.objectContaining({ staged: 3 }), + }); + } finally { + vi.useRealTimers(); + } + state.dispose(); + }); + + it('shares one in-flight computation across concurrent fast callers', async () => { + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + getGitWorkingTreeStatusMock.mockResolvedValue(summary()); + const state = new WorkspaceGitState(); + const { bridge } = bridgeWith(); + + await Promise.all([ + state.getStatus('/workspace', bridge), + state.getStatus('/workspace', bridge), + state.getStatus('/workspace', bridge), + ]); + await vi.waitFor(() => + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(1), + ); + + // Within the throttle window another fast call does not refresh again. + await state.getStatus('/workspace', bridge); + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(1); + state.dispose(); + }); + + it('wait:true awaits a fresh computation and bypasses the throttle', async () => { + let resolveGit!: (value: GitWorkingTreeStatus | null) => void; + getGitWorkingTreeStatusMock.mockImplementation( + () => + new Promise((resolve) => { + resolveGit = resolve; + }), + ); + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + const state = new WorkspaceGitState(); + const { bridge } = bridgeWith(); + + let settled = false; + const pending = state + .getStatus('/workspace', bridge, { wait: true }) + .then((status) => { + settled = true; + return status; + }); + await vi.waitFor(() => + expect(getGitWorkingTreeStatusMock).toHaveBeenCalled(), + ); + expect(settled).toBe(false); + + resolveGit(summary({ ahead: 4 })); + await expect(pending).resolves.toMatchObject({ ahead: 4 }); + + // A second wait:true right after still forces a fresh computation. + getGitWorkingTreeStatusMock.mockResolvedValue(summary({ ahead: 5 })); + await expect( + state.getStatus('/workspace', bridge, { wait: true }), + ).resolves.toMatchObject({ ahead: 5 }); + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(2); + state.dispose(); + }); + + it('keeps the cached summary and stays silent when a refresh fails', async () => { + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + getGitWorkingTreeStatusMock.mockResolvedValueOnce(summary({ staged: 1 })); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + + await expect( + state.getStatus('/workspace', bridge, { wait: true }), + ).resolves.toMatchObject({ staged: 1 }); + expect(publishWorkspaceEvent).toHaveBeenCalledTimes(1); + + getGitWorkingTreeStatusMock.mockRejectedValueOnce( + new Error('git exploded'), + ); + await expect( + state.getStatus('/workspace', bridge, { wait: true }), + ).resolves.toMatchObject({ staged: 1 }); + expect(publishWorkspaceEvent).toHaveBeenCalledTimes(1); + state.dispose(); + }); + + it('does not publish when a refresh finishes after dispose', async () => { + let resolveGit!: (value: GitWorkingTreeStatus | null) => void; + getGitWorkingTreeStatusMock.mockImplementation( + () => + new Promise((resolve) => { + resolveGit = resolve; + }), + ); + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + + const pending = state.getStatus('/workspace', bridge, { wait: true }); + await vi.waitFor(() => + expect(getGitWorkingTreeStatusMock).toHaveBeenCalled(), + ); + state.dispose(); + resolveGit(summary({ staged: 5 })); + await pending; + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts index e2d7ae2a937..5b425468357 100644 --- a/packages/cli/src/serve/workspace-git-state.ts +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -9,6 +9,7 @@ import { resolveBranchName, watchRepoBranch, type GitOperation, + type GitWorkingTreeStatus, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; @@ -35,23 +36,102 @@ export interface WorkspaceGitStatus { interface WorkspaceGitEntry { branch: string | undefined; dispose: () => void; + /** Last computed working-tree summary; served as last-known on fast paths. */ + status?: GitWorkingTreeStatus; + statusComputedAt?: number; + /** In-flight working-tree computation, shared by all callers. */ + statusPromise?: Promise; + /** Last time a computation was kicked, for background-refresh throttling. */ + refreshStartedAt?: number; + /** Set on dispose: a late-finishing refresh must not publish. */ + disposed?: boolean; +} + +/** + * Minimum interval between background working-tree refreshes. `wait: true` + * callers bypass it — only reactive kicks (one per fast `getStatus`) are + * throttled, so a focus/poll burst can't queue a train of `git status` + * subprocesses. + */ +const BACKGROUND_REFRESH_THROTTLE_MS = 2_000; + +function sameWorkingTreeStatus( + a: GitWorkingTreeStatus, + b: GitWorkingTreeStatus, +): boolean { + return ( + a.branch === b.branch && + a.detached === b.detached && + a.hasUpstream === b.hasUpstream && + a.ahead === b.ahead && + a.behind === b.behind && + a.staged === b.staged && + a.unstaged === b.unstaged && + a.untracked === b.untracked && + a.conflicted === b.conflicted && + a.stashCount === b.stashCount && + a.operation === b.operation + ); } export class WorkspaceGitState { private readonly entries = new Map>(); + /** + * Default (fast) path: return the last-known status immediately — branch-only + * when the working-tree summary has never been computed — and kick a + * throttled background refresh that publishes `git_status_changed` when it + * finds a delta. `wait: true` awaits a fresh computation and returns the + * full status (in-flight refreshes are shared), matching the pre-cache + * blocking semantics. + */ async getStatus( workspaceCwd: string, bridge: AcpSessionBridge, + opts?: { wait?: boolean }, ): Promise { const entry = await this.getOrCreateEntry(workspaceCwd, bridge); - // The watcher keeps `entry.branch` live between refreshes; the heavier - // working-tree summary is computed fresh per call (the client bounds how - // often it asks). A non-repo / git failure yields null → branch-only; a - // transient state (merge/rebase/…) still returns a summary with `operation`. - const status = await getGitWorkingTreeStatus(workspaceCwd).catch( - () => null, - ); + if (opts?.wait) { + await this.startRefresh(workspaceCwd, entry, bridge, true); + } else { + void this.startRefresh(workspaceCwd, entry, bridge, false); + } + return this.materialize(workspaceCwd, entry); + } + + dispose(): void { + for (const pending of this.entries.values()) { + void pending + .then((entry) => { + entry.disposed = true; + entry.dispose(); + }) + .catch(() => {}); + } + this.entries.clear(); + } + + disposeWorkspace(workspaceCwd: string): void { + const pending = this.entries.get(workspaceCwd); + if (!pending) return; + this.entries.delete(workspaceCwd); + void pending + .then((entry) => { + entry.disposed = true; + entry.dispose(); + }) + .catch(() => {}); + } + + private materialize( + workspaceCwd: string, + entry: WorkspaceGitEntry, + ): WorkspaceGitStatus { + const status = entry.status; + // The watcher keeps `entry.branch` live between refreshes; prefer it over + // the (possibly stale) summary branch. A missing summary yields the + // branch-only shape with no `computedAt`, which clients distinguish from + // "computed and clean". if (!status) { return { v: 2, workspaceCwd, branch: entry.branch ?? null }; } @@ -69,22 +149,60 @@ export class WorkspaceGitState { behind: status.behind, stashCount: status.stashCount, ...(status.operation ? { operation: status.operation } : {}), - computedAt: Date.now(), + computedAt: entry.statusComputedAt ?? Date.now(), }; } - dispose(): void { - for (const pending of this.entries.values()) { - void pending.then((entry) => entry.dispose()).catch(() => {}); + /** + * Start a working-tree computation unless one is already in flight (shared) + * or a non-forced kick lands within the throttle window. Returns the + * in-flight promise, or undefined when throttled. A failed computation keeps + * the previous cache and stays silent; a successful one publishes + * `git_status_changed` only when the summary actually changed. + */ + private startRefresh( + workspaceCwd: string, + entry: WorkspaceGitEntry, + bridge: AcpSessionBridge, + force: boolean, + ): Promise | undefined { + if (entry.statusPromise) return entry.statusPromise; + if ( + !force && + entry.refreshStartedAt !== undefined && + Date.now() - entry.refreshStartedAt < BACKGROUND_REFRESH_THROTTLE_MS + ) { + return undefined; } - this.entries.clear(); - } - - disposeWorkspace(workspaceCwd: string): void { - const pending = this.entries.get(workspaceCwd); - if (!pending) return; - this.entries.delete(workspaceCwd); - void pending.then((entry) => entry.dispose()).catch(() => {}); + entry.refreshStartedAt = Date.now(); + const run = (async () => { + const status = await getGitWorkingTreeStatus(workspaceCwd).catch( + () => null, + ); + if (!status) return; + const changed = + !entry.status || !sameWorkingTreeStatus(entry.status, status); + entry.status = status; + entry.statusComputedAt = Date.now(); + if (changed && !entry.disposed) { + try { + bridge.publishWorkspaceEvent({ + type: 'git_status_changed', + data: this.materialize(workspaceCwd, entry), + }); + } catch { + // SSE fan-out is a side effect — it must not reject the refresh + // (an awaited wait:true call would 500 the route) or drop the cache. + } + } + })(); + entry.statusPromise = run; + void run.finally(() => { + if (entry.statusPromise === run) { + entry.statusPromise = undefined; + } + }); + return run; } private getOrCreateEntry( @@ -116,6 +234,7 @@ export class WorkspaceGitState { const branch = await resolveBranchName(workspaceCwd); if (branch === entry.branch) return; entry.branch = branch; + if (entry.disposed) return; bridge.publishWorkspaceEvent({ type: 'git_branch_changed', data: { workspaceCwd, branch: branch ?? null }, diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 9478f714061..3b96d8e19da 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1089,9 +1089,11 @@ export class DaemonClient { ); } - async workspaceGit(): Promise { + async workspaceGit(opts?: { + wait?: boolean; + }): Promise { return await this.jsonRequest( - '/workspace/git', + `/workspace/git${opts?.wait ? '?wait=1' : ''}`, 'GET /workspace/git', { mode: 'rest' }, ); @@ -4433,8 +4435,15 @@ export class WorkspaceDaemonClient { ); } - workspaceGit(cwd?: string): Promise { - const suffix = cwd ? `/git?cwd=${encodeURIComponent(cwd)}` : '/git'; + workspaceGit(opts?: { + cwd?: string; + wait?: boolean; + }): Promise { + const params = new URLSearchParams(); + if (opts?.cwd) params.set('cwd', opts.cwd); + if (opts?.wait) params.set('wait', '1'); + const query = params.toString(); + const suffix = query ? `/git?${query}` : '/git'; return this.client.workspaceJsonRequest( this.workspaceSelector, suffix, diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index fda6b435c2c..ef2a53a6d12 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -157,6 +157,11 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ // clients can seed their reducer without an extra round-trip. 'session_snapshot', 'git_branch_changed', + // Enriched working-tree summary push: the daemon recomputes `git status` + // in the background (stale-while-revalidate on `GET …/git`) and publishes + // this only when the summary changed. `data` is a DaemonWorkspaceGitStatus. + // Old SDK consumers silently drop it via `asKnownDaemonEvent`. + 'git_status_changed', ] as const; const DAEMON_KNOWN_EVENT_TYPES: ReadonlySet = new Set( diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 3446b3865db..d4f547ff9d4 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -310,6 +310,9 @@ export function normalizeDaemonEvent( case 'git_branch_changed': return []; + case 'git_status_changed': + return []; + case 'memory_changed': return normalizeMemoryChanged(event, base); diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 0047fad2618..e24d2520db3 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -872,6 +872,36 @@ describe('DaemonClient', () => { expect(transportFetch).not.toHaveBeenCalled(); }); + it('builds Git status query strings from cwd/wait options', async () => { + const status = { + v: 1 as const, + workspaceCwd: '/work/main', + branch: 'main', + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, status)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.workspaceGit({ wait: true }); + await client + .workspaceByCwd('/work/secondary') + .workspaceGit({ cwd: '/work/secondary/wt-1' }); + await client + .workspaceByCwd('/work/secondary') + .workspaceGit({ cwd: '/work/secondary/wt-1', wait: true }); + + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['GET', 'http://daemon/workspace/git?wait=1'], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/git?cwd=%2Fwork%2Fsecondary%2Fwt-1', + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/git?cwd=%2Fwork%2Fsecondary%2Fwt-1&wait=1', + ], + ]); + }); + it('reads Git diff list and per-file hunks (incl. rename oldPath) over REST', async () => { const diffList = { v: 1 as const, diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 62bd590c4a9..551b030e5fc 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, createRef, type CSSProperties } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; +import type { + DaemonInputAnnotation, + DaemonWorkspaceGitStatus, +} from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; import type { Message } from './adapters/types'; import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl'; @@ -26,6 +29,8 @@ type MockConnection = { error?: string; errorStatus?: number; missingSession?: boolean; + gitBranch?: string; + gitStatus?: DaemonWorkspaceGitStatus; }; type ChatEditorTestProps = { @@ -57,6 +62,8 @@ type ChatEditorTestProps = { name?: string; slug?: string; }) => void; + gitBranch?: string; + gitStatus?: DaemonWorkspaceGitStatus; }; type AddWorkspaceDialogTestProps = { @@ -1011,6 +1018,8 @@ beforeEach(() => { mockConnection.skills = []; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; + mockConnection.gitBranch = undefined; + mockConnection.gitStatus = undefined; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; @@ -1950,6 +1959,78 @@ describe('App session callbacks', () => { ).toBeUndefined(); }); + it('fetches the composer git status on both the fast and the wait:true fresh path', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + const workspaceGit = vi.fn().mockResolvedValue({ branch: 'main' }); + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit, + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); + + // Fast path paints the chip from the daemon's last-known cache; the + // wait:true fresh path fills in the enriched counters once the daemon's + // background recomputation lands (no SSE exists before the first + // prompt). Both share one daemon-side `git status` computation. + await vi.waitFor(() => { + expect(workspaceGit).toHaveBeenCalledWith({ cwd: undefined }); + expect(workspaceGit).toHaveBeenCalledWith({ wait: true }); + }); + }); + + it('mirrors connection.gitStatus into the composer git chip', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); + + // Fast GET applied the branch-only last-known status. + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.gitStatus).toEqual({ + branch: 'main', + }); + }); + + // The daemon's `git_status_changed` push lands as connection.gitStatus + // (a provider state update in production; simulated here by mutating the + // connection object and forcing a re-render). + act(() => { + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/workspace', + branch: 'main', + staged: 2, + computedAt: 1_700_000_000_000, + }; + testState.latestChatEditorProps?.onInputTextChange?.('x'); + }); + await flush(); + + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.gitStatus).toMatchObject({ + workspaceCwd: '/workspace', + branch: 'main', + staged: 2, + }); + }); + }); + it('forwards the branch intent to createSession when submitting a prompt', async () => { mockConnection.sessionId = undefined; mockWorkspace.capabilities = { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e74aeeb2467..c859d3a71e3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1492,20 +1492,39 @@ export function App({ } let cancelled = false; const fetchStatus = () => { - void workspace.client - .workspaceByCwd(activeWorkspaceCwd) - .workspaceGit(sessionWorktree?.path) - .then((git) => { - if (!cancelled) setSelectedWorkspaceGitStatus(git); + const git = workspace.client.workspaceByCwd(activeWorkspaceCwd); + // Fast path: last-known cache (branch-only on a cold start) paints the + // chip immediately. + void git + .workspaceGit({ cwd: sessionWorktree?.path }) + .then((status) => { + if (!cancelled) setSelectedWorkspaceGitStatus(status); }) .catch(() => { if (!cancelled) setSelectedWorkspaceGitStatus(undefined); }); + // Fresh path: resolves when the daemon's recomputation lands, so the + // enriched counters fill in without depending on SSE — the + // `git_status_changed` push only flows on a per-session event stream, + // which doesn't exist before the first prompt (deferred connect). + // Daemon-side in-flight dedup shares one `git status` computation + // across both requests. Worktree `?cwd=` reads always compute + // directly, so a second request would be a duplicate there. + if (!sessionWorktree) { + void git + .workspaceGit({ wait: true }) + .then((status) => { + if (!cancelled) setSelectedWorkspaceGitStatus(status); + }) + .catch(() => {}); + } }; fetchStatus(); - // The enriched working-tree summary isn't pushed over SSE, so refresh it on - // focus and on a slow poll for the active workspace only. A live branch - // change re-runs this effect via the connection.gitBranch dependency. + // Refresh triggers stay on focus and on a slow poll for the active + // workspace only. A live branch change re-runs this effect via the + // connection.gitBranch dependency. With an active session the daemon's + // `git_status_changed` push (mirrored by the effect below) additionally + // covers realtime updates between polls. const onFocus = () => fetchStatus(); window.addEventListener('focus', onFocus); const poll = window.setInterval(() => { @@ -1522,6 +1541,17 @@ export function App({ workspace.client, sessionWorktree, ]); + // Mirror the daemon's `git_status_changed` push (surfaced as + // connection.gitStatus by the session provider) into the chip state so the + // enriched counters fill in right after the branch-only first paint. + // Worktree sessions bypass the daemon cache/SSE path — their status comes + // from the ?cwd= fetch above. + useEffect(() => { + const status = connection.gitStatus; + if (!status || sessionWorktree) return; + if (status.workspaceCwd !== activeWorkspaceCwd) return; + setSelectedWorkspaceGitStatus(status); + }, [connection.gitStatus, activeWorkspaceCwd, sessionWorktree]); const onToastRef = useRef(onToast); onToastRef.current = onToast; const toastIdRef = useRef(0); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index e5792a6ac77..e9bc040a0a6 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -231,7 +231,12 @@ export function WorkspaceSection({ const loadGitStatus = useCallback(async () => { if (!gitStatusEnabled || !workspace.trusted || !gitPollCwd) return; try { - const status = await client.workspaceByCwd(gitPollCwd).workspaceGit(); + // wait: the sidebar chip shows the enriched counters and has no SSE + // fill-in path, so it keeps the blocking semantics instead of the + // composer's last-known fast path. + const status = await client + .workspaceByCwd(gitPollCwd) + .workspaceGit({ wait: true }); gitPollFailed.current = false; setGitStatus(status); } catch (err) { diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index 0c8fb1f3063..ebbe1d5eea4 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -285,6 +285,49 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next).toBe(current); }); + it('stores the enriched git status pushed for the current workspace', () => { + const next = applyEvent( + { status: 'connected', workspaceCwd: '/workspace' }, + { + v: 1, + type: 'git_status_changed', + data: { + v: 2, + workspaceCwd: '/workspace', + branch: 'main', + staged: 2, + computedAt: 1_700_000_000_000, + }, + }, + ); + + expect(next.gitStatus).toMatchObject({ + workspaceCwd: '/workspace', + branch: 'main', + staged: 2, + }); + }); + + it('ignores git status pushes from a previous workspace', () => { + const current = { + status: 'connected' as const, + workspaceCwd: '/workspace/current', + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_status_changed', + data: { + v: 2, + workspaceCwd: '/workspace/previous', + branch: 'stale-branch', + staged: 9, + }, + }); + + expect(next).toBe(current); + }); + it('replaces commands and skills from an available_commands_update', () => { const next = applyEvent( { status: 'connected', workspaceCwd: '/workspace' }, diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 8e6f691321b..029313e6321 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -10,6 +10,7 @@ import type { DaemonEvent, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, + DaemonWorkspaceGitStatus, DaemonWorkspaceProvidersStatus, DaemonWorkspaceSkillsStatus, } from '@qwen-code/sdk/daemon'; @@ -268,6 +269,19 @@ export function updateConnectionFromDaemonEvent( ); break; } + case 'git_status_changed': { + const data = getRecord(event.data); + const workspaceCwd = getString(data, 'workspaceCwd'); + setConnection((current) => + workspaceCwd && workspaceCwd !== current.workspaceCwd + ? current + : { + ...current, + gitStatus: data as unknown as DaemonWorkspaceGitStatus, + }, + ); + break; + } case 'session_metadata_updated': { const data = getRecord(event.data); if (Object.prototype.hasOwnProperty.call(data ?? {}, 'displayName')) { diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 31405e4ed39..28ea28424d6 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -33,6 +33,7 @@ import type { DaemonShellCommandResult, DaemonTranscriptBlock, DaemonTranscriptStore, + DaemonWorkspaceGitStatus, DaemonWorkspaceProvidersStatus, HeartbeatResult, PermissionResponse, @@ -62,6 +63,12 @@ export interface DaemonConnectionState { workspaceCwd?: string; /** Current Git branch, short detached-HEAD hash, or undefined outside Git. */ gitBranch?: string; + /** + * Last enriched working-tree summary for the current workspace, pushed by + * the daemon via `git_status_changed` (only set when the event's + * workspaceCwd matches this connection's workspace). + */ + gitStatus?: DaemonWorkspaceGitStatus; commands?: DaemonCommandInfo[]; skills?: string[]; models?: DaemonModelInfo[]; From 1b94d4338889a087c6fe8c7952683be92f4d0aa5 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Fri, 24 Jul 2026 18:44:26 +0000 Subject: [PATCH 2/7] fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680) --- packages/cli/src/serve/workspace-git-state.ts | 16 ++++++++ packages/web-shell/client/App.test.tsx | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts index 5b425468357..0220b74aa9b 100644 --- a/packages/cli/src/serve/workspace-git-state.ts +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -59,6 +59,22 @@ function sameWorkingTreeStatus( a: GitWorkingTreeStatus, b: GitWorkingTreeStatus, ): boolean { + // Exhaustiveness guard: if a field is added to GitWorkingTreeStatus, + // this line will fail to compile until the field is added below. + const _exhaustive: Record = { + branch: true, + detached: true, + hasUpstream: true, + ahead: true, + behind: true, + staged: true, + unstaged: true, + untracked: true, + conflicted: true, + stashCount: true, + operation: true, + }; + void _exhaustive; return ( a.branch === b.branch && a.detached === b.detached && diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 551b030e5fc..6e8f6ad0acf 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -2031,6 +2031,43 @@ describe('App session callbacks', () => { }); }); + it('skips the wait:true fresh path for worktree sessions', async () => { + const worktreePath = '/workspace/.worktrees/feat-a'; + mockWorkspace.client.sessionStatus.mockResolvedValue({ + worktree: { slug: 'feat-a', path: worktreePath, branch: 'feat-a' }, + }); + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + const workspaceGit = vi.fn().mockResolvedValue({ branch: 'feat-a' }); + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit, + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); + + // The worktree session status lands and the git effect re-runs with the + // worktree path. + await vi.waitFor(() => { + expect(workspaceGit).toHaveBeenCalledWith({ cwd: worktreePath }); + }); + + // After the worktree cwd call, no wait:true call should follow — worktree + // ?cwd= reads compute directly, so a second request would be a duplicate. + const calls = workspaceGit.mock.calls.map(([arg]) => arg); + const cwdIndex = calls.findIndex( + (arg: Record) => arg?.cwd === worktreePath, + ); + const waitAfter = calls + .slice(cwdIndex + 1) + .filter((arg: Record) => arg?.wait === true); + expect(waitAfter).toHaveLength(0); + }); + it('forwards the branch intent to createSession when submitting a prompt', async () => { mockConnection.sessionId = undefined; mockWorkspace.capabilities = { From 098385c728cf3ed3f5717c0d753d8753eee8710a Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Fri, 24 Jul 2026 19:47:55 +0000 Subject: [PATCH 3/7] fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680) --- .../cli/src/serve/workspace-git-state.test.ts | 19 +++++++++++++++++++ packages/cli/src/serve/workspace-git-state.ts | 3 +++ 2 files changed, 22 insertions(+) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index 8fd50180c2d..b9d29427790 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -487,4 +487,23 @@ describe('WorkspaceGitState', () => { await pending; expect(publishWorkspaceEvent).not.toHaveBeenCalled(); }); + + it('keeps the cache and resolves wait:true when publishWorkspaceEvent throws', async () => { + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + getGitWorkingTreeStatusMock.mockResolvedValue(summary({ staged: 2 })); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + publishWorkspaceEvent.mockImplementationOnce(() => { + throw new Error('sse down'); + }); + + await expect( + state.getStatus('/workspace', bridge, { wait: true }), + ).resolves.toMatchObject({ staged: 2 }); + + const fast = await state.getStatus('/workspace', bridge); + expect(fast.computedAt).toBeDefined(); + state.dispose(); + }); }); diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts index 0220b74aa9b..12255261dff 100644 --- a/packages/cli/src/serve/workspace-git-state.ts +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -75,6 +75,9 @@ function sameWorkingTreeStatus( operation: true, }; void _exhaustive; + // branch is compared even though materialize() overlays the watcher branch: + // a status.branch delta is the first signal of a checkout the watcher has + // not yet seen — publishing is intentionally conservative. return ( a.branch === b.branch && a.detached === b.detached && From c9cb07d4e5800b961d14d8f4a01e7b12abef39ad Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 24 Jul 2026 21:19:54 +0000 Subject: [PATCH 4/7] fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680) --- .../cli/src/serve/workspace-git-state.test.ts | 31 +++++++++++++++++++ packages/cli/src/serve/workspace-git-state.ts | 8 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index b9d29427790..dcfc4d1bb24 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -406,6 +406,37 @@ describe('WorkspaceGitState', () => { state.dispose(); }); + it('wait:true joins an already in-flight fast-path refresh', async () => { + let resolveGit!: (value: GitWorkingTreeStatus | null) => void; + getGitWorkingTreeStatusMock.mockImplementation( + () => + new Promise((resolve) => { + resolveGit = resolve; + }), + ); + resolveBranchNameMock.mockResolvedValue('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + const state = new WorkspaceGitState(); + const { bridge } = bridgeWith(); + + // Fast path kicks a background refresh, setting statusPromise. + const fastPromise = state.getStatus('/workspace', bridge); + await vi.waitFor(() => + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(1), + ); + + // wait:true should join the in-flight computation, not spawn a second one. + const waitPromise = state.getStatus('/workspace', bridge, { wait: true }); + + resolveGit(summary({ staged: 3 })); + + const [fast, wait] = await Promise.all([fastPromise, waitPromise]); + expect(getGitWorkingTreeStatusMock).toHaveBeenCalledTimes(1); + expect(fast.branch).toBe('main'); + expect(wait.staged).toBe(3); + state.dispose(); + }); + it('wait:true awaits a fresh computation and bypasses the throttle', async () => { let resolveGit!: (value: GitWorkingTreeStatus | null) => void; getGitWorkingTreeStatusMock.mockImplementation( diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts index 12255261dff..9465b1f6a81 100644 --- a/packages/cli/src/serve/workspace-git-state.ts +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -12,6 +12,7 @@ import { type GitWorkingTreeStatus, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; export interface WorkspaceGitStatus { v: 2; @@ -196,7 +197,12 @@ export class WorkspaceGitState { entry.refreshStartedAt = Date.now(); const run = (async () => { const status = await getGitWorkingTreeStatus(workspaceCwd).catch( - () => null, + (err) => { + writeStderrLine( + `qwen serve: git status failed for ${workspaceCwd}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + }, ); if (!status) return; const changed = From 7532a29db6706329819abf7d99bef02b6048cd0f Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Fri, 24 Jul 2026 23:04:12 +0000 Subject: [PATCH 5/7] fix(cli): use writeStderrLineSafe in git-status refresh error path (#7680) --- packages/cli/src/serve/workspace-git-state.test.ts | 5 +++++ packages/cli/src/serve/workspace-git-state.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index dcfc4d1bb24..62cb21e65bb 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -20,6 +20,11 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ watchRepoBranch: vi.fn(), })); +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStderrLine: vi.fn(), + writeStderrLineSafe: vi.fn(), +})); + const getGitWorkingTreeStatusMock = vi.mocked(getGitWorkingTreeStatus); const resolveBranchNameMock = vi.mocked(resolveBranchName); const watchRepoBranchMock = vi.mocked(watchRepoBranch); diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts index 9465b1f6a81..1e149af6078 100644 --- a/packages/cli/src/serve/workspace-git-state.ts +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -12,7 +12,7 @@ import { type GitWorkingTreeStatus, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; -import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; export interface WorkspaceGitStatus { v: 2; @@ -198,7 +198,7 @@ export class WorkspaceGitState { const run = (async () => { const status = await getGitWorkingTreeStatus(workspaceCwd).catch( (err) => { - writeStderrLine( + writeStderrLineSafe( `qwen serve: git status failed for ${workspaceCwd}: ${err instanceof Error ? err.message : String(err)}`, ); return null; From 4e89745451c1f134c8969ea7ffae25156da8a69b Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Sat, 25 Jul 2026 01:12:51 +0000 Subject: [PATCH 6/7] fix(web-shell): add debug trail to fresh-path catch and test branch-watcher dispose guard (#7680) --- .../cli/src/serve/workspace-git-state.test.ts | 24 +++++++++++++++++++ packages/web-shell/client/App.tsx | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index 62cb21e65bb..dab57be84f3 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -524,6 +524,30 @@ describe('WorkspaceGitState', () => { expect(publishWorkspaceEvent).not.toHaveBeenCalled(); }); + it('does not publish git_branch_changed when the watcher fires after dispose', async () => { + let onChange: (() => void) | undefined; + const dispose = vi.fn(); + resolveBranchNameMock + .mockResolvedValueOnce('main') + .mockResolvedValueOnce('feature'); + watchRepoBranchMock.mockImplementation(async (_cwd, callback) => { + onChange = callback; + return dispose; + }); + const state = new WorkspaceGitState(); + const { bridge, publishWorkspaceEvent } = bridgeWith(); + + await state.getStatus('/workspace', bridge); + state.dispose(); + await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()); + + onChange?.(); + await vi.waitFor(() => + expect(resolveBranchNameMock).toHaveBeenCalledTimes(2), + ); + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + it('keeps the cache and resolves wait:true when publishWorkspaceEvent throws', async () => { resolveBranchNameMock.mockResolvedValue('main'); watchRepoBranchMock.mockResolvedValue(() => {}); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c859d3a71e3..c870bc6dac9 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1516,7 +1516,9 @@ export function App({ .then((status) => { if (!cancelled) setSelectedWorkspaceGitStatus(status); }) - .catch(() => {}); + .catch((err) => { + console.warn('[web-shell] git status fresh path failed:', err); + }); } }; fetchStatus(); From 1cd94891882207b7c0e71c00a7d5258d46b423c4 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Sat, 25 Jul 2026 02:43:28 +0000 Subject: [PATCH 7/7] fix(cli): assert writeStderrLineSafe in git-status refresh failure test (#7680) --- packages/cli/src/serve/workspace-git-state.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index dab57be84f3..4e851f39d2e 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -12,6 +12,7 @@ import { type GitWorkingTreeStatus, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; import { WorkspaceGitState } from './workspace-git-state.js'; vi.mock('@qwen-code/qwen-code-core', () => ({ @@ -28,6 +29,7 @@ vi.mock('../utils/stdioHelpers.js', () => ({ const getGitWorkingTreeStatusMock = vi.mocked(getGitWorkingTreeStatus); const resolveBranchNameMock = vi.mocked(resolveBranchName); const watchRepoBranchMock = vi.mocked(watchRepoBranch); +const writeStderrLineSafeMock = vi.mocked(writeStderrLineSafe); function summary( overrides: Partial = {}, @@ -498,6 +500,9 @@ describe('WorkspaceGitState', () => { state.getStatus('/workspace', bridge, { wait: true }), ).resolves.toMatchObject({ staged: 1 }); expect(publishWorkspaceEvent).toHaveBeenCalledTimes(1); + expect(writeStderrLineSafeMock).toHaveBeenCalledWith( + expect.stringContaining('git status failed'), + ); state.dispose(); });