diff --git a/docs/design/2026-07-19-webshell-git-log.md b/docs/design/2026-07-19-webshell-git-log.md new file mode 100644 index 00000000000..b4276d2917d --- /dev/null +++ b/docs/design/2026-07-19-webshell-git-log.md @@ -0,0 +1,465 @@ +# Web Shell Git 提交历史浏览器 + +## 背景 + +`2026-07-16-webshell-git-status-diff.md` 落地了 branch chip 增强(dirty / +ahead-behind / stash / detached / operation)和可视化 diff 查看器 +(`GitDiffDialog`)。这两项解决了"工作区当前状态"的感知问题。 + +但用户还有另一个高频需求:**查看提交历史**。目前要看最近做了什么,只能在 chat +里让 agent 跑 `git log --oneline`,然后读一段纯文本。对于一个图形界面来说, +这是明显的体验缺口——提交历史是代码审查、回溯变更、理解项目演进的基础视图。 + +本期目标:新增一个只读的 Git Log 浏览器,以紧凑列表展示提交记录,点击展开 +查看完整 message 和文件变更统计。复用现有 `DialogShell` / SDK / 路由模式, +不引入写操作。 + +## 目标 + +- 提供 `/log` 斜杠命令和 UI 入口,打开提交历史浏览器。 +- 紧凑列表:短 SHA、subject、作者、相对时间、ref 标签(branch/tag)。 +- 点击展开:完整 commit message body + 文件变更统计(numstat)。 +- 分页加载(Load more),不一次性拉取全部历史。 +- 复用现有架构模式:core git 工具 → daemon 路由 → SDK → Web Shell 组件。 +- 只读,不引入任何写操作。 + +## 非目标 + +- 不做提交图谱(graph / DAG 可视化)。 +- 不做分支筛选 / 搜索(留作后续增量)。 +- 不做任意 commit 间的 diff 查看(留作后续增量,可复用 diff 基础设施)。 +- 不做 commit 详情中的行级 diff(文件统计足够,行级 diff 是后续增量)。 +- 不做 blame / annotate。 +- 不改变 agent 侧的 git 行为。 + +## 方案概述 + +```text +core (gitDiff.ts 扩展) + ├─ fetchGitLog(cwd, { limit, skip }) [新增] 提交列表 + └─ fetchGitCommitDetail(cwd, sha) [新增] 单 commit 详情(message + numstat) + │ + ▼ +daemon (serve) + ├─ GET /workspace/git/log?limit=&skip= [新增] + ├─ GET /workspace/git/log/commit?sha= [新增] + └─ qualified 版本 /workspaces/:workspace/git/log[/commit] + │ + ▼ +SDK (DaemonClient + types) + ├─ DaemonGitLogEntry / DaemonGitLog [新增] + ├─ DaemonGitCommitDetail [新增] + ├─ workspaceGitLog(limit?, skip?) [新增] + └─ workspaceGitCommitDetail(sha) [新增] + │ + ▼ +Web Shell (client) + ├─ GitDialog.tsx [新增] Changes / History 统一容器 + ├─ GitLogDialog.tsx (+ .module.css) [新增] 提交列表 + 展开详情 + ├─ App.tsx [扩展] /log 命令拦截 + dialog view 状态 + └─ i18n.tsx [扩展] gitLog.* 文案 +``` + +## UI 草图 + +`/log` 或 Git chip 打开统一的 `GitDialog`。使用 +`DialogShell size="xl" allowFullscreen`,在同一个 dialog 内切换 Changes / History: + +```text +┌─ History ───────────────────────── 50 commits ─ ✕ ┐ +│ │ +│ a1b2c3d feat(cli): add --json flag 2h ago │ +│ wenshao │ +│ │ +│ e4f5g6h fix(core): handle null config 5h ago │ +│ dev · HEAD -> main, v1.2.0 │ +│ │ +│ ▼ 789abcd refactor: simplify parser 1d ago │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Broke the monolithic parse() into smaller │ │ +│ │ functions for readability. │ │ +│ │ │ │ +│ │ 3 files · +45 −12 │ │ +│ │ +30 −8 src/parser.ts │ │ +│ │ +10 −2 src/utils.ts │ │ +│ │ +5 −2 test/parser.test.ts │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ c0ffee1 chore: bump deps 3d ago │ +│ bot │ +│ │ +│ [ Load more ] │ +└──────────────────────────────────────────────────────┘ +``` + +交互: + +- 列表按时间倒序(最新在前),默认加载 50 条。 +- 每条显示:短 SHA(monospace)、subject(单行截断)、作者名、相对时间。 +- 有 ref(branch/tag)时在 subject 行末或下方显示标签。 +- merge commit 显示 `⎇` 图标区分。 +- 点击展开 → 按需拉取 commit 详情(完整 body + numstat),再次点击折叠。 +- "Load more" 按钮加载下一页(skip += limit),追加到列表末尾。 +- 非 git 仓库 / 空仓库(无 commit)→ 占位文案。 +- 加载失败 → 错误占位文案。 + +## 数据结构 + +### Core 层 + +```ts +// packages/core/src/utils/gitDiff.ts 新增 + +export interface GitLogEntry { + sha: string; // 完整 40 字符 SHA + shortSha: string; // 短 SHA(git 默认缩写) + authorName: string; + authorEmail: string; + authorDate: number; // unix timestamp(秒) + subject: string; + refs: string; // %D 输出,如 "HEAD -> main, origin/main, v1.2.0" + parents: string[]; // parent SHA 列表(length > 1 表示 merge commit) +} + +export interface GitLogResult { + entries: GitLogEntry[]; + hasMore: boolean; // 是否还有更多提交 +} + +export interface GitCommitFileStat { + path: string; + added: number; // 二进制文件为 0 + removed: number; + isBinary: boolean; +} + +export interface GitCommitDetail { + sha: string; + shortSha: string; + authorName: string; + authorEmail: string; + authorDate: number; + subject: string; + body: string; // 完整 message body(可能为空) + refs: string; + parents: string[]; + files: GitCommitFileStat[]; + filesCount: number; + linesAdded: number; + linesRemoved: number; + hiddenCount: number; // 超出 MAX_FILES 的文件数 +} +``` + +### SDK 层(wire format) + +```ts +// packages/sdk-typescript/src/daemon/types.ts 新增 + +export interface DaemonGitLogEntry { + sha: string; + shortSha: string; + authorName: string; + authorEmail: string; + authorDate: number; + subject: string; + refs?: string; + parents: string[]; +} + +export interface DaemonGitLog { + v: 1; + workspaceCwd: string; + available: boolean; + entries: DaemonGitLogEntry[]; + hasMore: boolean; +} + +export interface DaemonGitCommitFileStat { + path: string; + added: number; + removed: number; + isBinary: boolean; +} + +export interface DaemonGitCommitDetail { + v: 1; + workspaceCwd: string; + available: boolean; + sha: string; + shortSha: string; + authorName: string; + authorEmail: string; + authorDate: number; + subject: string; + body: string; + refs?: string; + parents: string[]; + files: DaemonGitCommitFileStat[]; + filesCount: number; + linesAdded: number; + linesRemoved: number; + hiddenCount: number; +} +``` + +## 关键修改点 + +### 1. core:新增 `fetchGitLog` 和 `fetchGitCommitDetail` + +放在 `packages/core/src/utils/gitDiff.ts`,复用已有的 `runGit` 内部函数和 +上限常量。 + +#### `fetchGitLog(cwd, { limit = 50, skip = 0 }): Promise` + +- 执行: + ``` + git --no-optional-locks log -z + --format='%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P' + -n --skip= + ``` + - `\x00`(NUL)分隔字段,`-z` 用 NUL 终止每条记录。 + - Git commit message 不允许 NUL,因此 subject 中的其他控制字符不会与协议冲突。 + - 请求 `limit + 1` 条来判断 `hasMore`,返回时截断到 `limit`。 + - `--no-optional-locks` 避免写锁。 +- 解析:按 NUL 切分后,每 8 个字段组成一条记录。 +- 非仓库 / git 失败返回 `null`。 +- 空仓库(无 commit)返回 `{ entries: [], hasMore: false }`。 +- `limit` 上限 200,超出截断到 200。 + +#### `fetchGitCommitDetail(cwd, sha): Promise` + +- 校验 `sha`:必须匹配 `/^[0-9a-f]{7,40}$/i`(防止注入)。 +- 两次 git 调用: + 1. 元数据: + ``` + git --no-optional-locks log -1 -z + --format='%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P%x00%b' + + ``` + 2. 文件统计: + ``` + git --no-optional-locks diff-tree --no-commit-id --numstat -r -z + ``` + 对 root commit(无 parent)使用 `--root`。 +- numstat 解析复用 `parseGitNumstat` 的逻辑(或提取共享函数),受 + `MAX_FILES` 上限约束。 +- 非仓库 / sha 不存在 / git 失败返回 `null`。 + +### 2. daemon:新增 log 路由 + +新增 `packages/cli/src/serve/routes/workspace-git-log.ts`,遵循 +`workspace-git-diff.ts` 的双注册模式: + +```ts +export function registerWorkspaceGitLogRoutes(app, deps: { + boundWorkspace: string; + sendBridgeError: SendBridgeError; +}): void { + app.get('/workspace/git/log', ...); + app.get('/workspace/git/log/commit', ...); +} + +export function registerWorkspaceQualifiedGitLogRoutes(app, deps: { + workspaceRegistry: WorkspaceRegistry; + sendBridgeError: SendBridgeError; +}): void { + app.get('/workspaces/:workspace/git/log', ...); + app.get('/workspaces/:workspace/git/log/commit', ...); +} +``` + +- `GET /workspace/git/log?limit=50&skip=0`: + - 解析 `limit`(默认 50,max 200)和 `skip`(默认 0)查询参数。 + - 调用 `fetchGitLog(workspaceCwd, { limit, skip })`。 + - 返回 `DaemonGitLog`(`v: 1`,`available` 标记)。 + - `applyReadHeaders(res)` + `sendBridgeError` 错误处理。 + +- `GET /workspace/git/log/commit?sha=`: + - 校验 `sha` 格式。 + - 调用 `fetchGitCommitDetail(workspaceCwd, sha)`。 + - 返回 `DaemonGitCommitDetail`。 + +- qualified 路由复用 `resolveWorkspaceRuntimeFromParam` + + `requireTrustedWorkspaceRuntime` 信任校验。 + +在 `server.ts` 中注册(参照 diff 路由的注册位置)。 + +### 3. SDK:类型 + client 方法 + +- `types.ts`:新增上述 `DaemonGitLogEntry` / `DaemonGitLog` / + `DaemonGitCommitFileStat` / `DaemonGitCommitDetail` 类型,从 + `src/index.ts` 和 `src/daemon/index.ts` 导出。 + +- `DaemonClient`(主 client + workspace-qualified client)新增: + + ```ts + async workspaceGitLog(limit?: number, skip?: number): Promise { + const params = new URLSearchParams(); + if (limit != null) params.set('limit', String(limit)); + if (skip != null) params.set('skip', String(skip)); + const qs = params.toString(); + return await this.jsonRequest( + `/workspace/git/log${qs ? `?${qs}` : ''}`, + 'GET /workspace/git/log', + { mode: 'rest' }, + ); + } + + async workspaceGitCommitDetail(sha: string): Promise { + return await this.jsonRequest( + `/workspace/git/log/commit?sha=${urlEncode(sha)}`, + 'GET /workspace/git/log/commit', + { mode: 'rest' }, + ); + } + ``` + +### 4. Web Shell:GitLogDialog 组件 + +新增 `packages/web-shell/client/components/dialogs/GitLogDialog.tsx` + +`GitLogDialog.module.css`。 + +**Props**(与 GitDiffDialog 对齐): + +```tsx +export function GitLogDialog({ + workspaceCwd, + onClose, +}: { + workspaceCwd: string; + onClose: () => void; +}); +``` + +**数据获取**: + +- 打开时调用 `client.workspaceByCwd(workspaceCwd).workspaceGitLog()` 拉首页。 +- "Load more" 按钮使用独立的服务端消费 offset 调用 + `workspaceGitLog(50, nextSkip)`,追加时按 SHA 去重。 +- 展开单条时调用 `workspaceGitCommitDetail(sha)` 按需拉详情。 +- 取消模式与 GitDiffDialog 一致(effect 用 `let cancelled`,click 用 + `useRef`)。 + +**渲染**: + +- `DialogShell title={t('gitLog.title')} size="xl" allowFullscreen`。 +- subtitle 显示已加载条数。 +- body 状态机:loading / error / unavailable / empty / data。 +- 提交行:短 SHA(monospace,muted 色)、subject、作者名、相对时间。 +- refs 解析:从 `refs` 字符串提取 `HEAD -> branch`、tag 等,渲染为小标签。 +- merge commit:`parents.length > 1` 时显示 merge 图标。 +- 展开详情:完整 body(`
` 渲染,保留换行)+ 文件统计列表(复用
+  GitDiffDialog 的文件行样式语义:`+N −M path`,binary 标记)。
+- 相对时间:简单的 `timeAgo` 工具函数(秒/分/时/天/周/月/年),不引入
+  外部库。
+
+**CSS Module**:
+
+- 复用 GitDiffDialog 的语义变量(`var(--border)`、`var(--muted-foreground)`
+  等)。
+- `.commitRow`、`.commitSha`、`.commitSubject`、`.commitMeta`、`.commitRefs`、
+  `.commitDetail`、`.commitBody`、`.fileStat`、`.loadMore`、`.placeholder`。
+
+### 5. App.tsx 集成
+
+- 新增统一的 `gitDialog` 状态:
+  ```ts
+  { workspaceCwd: string; view: 'diff' | 'log' } | undefined
+  ```
+- `/log` 斜杠命令本地拦截(与 `/diff` 同模式),将 `view` 设为 `log`。
+- Git chip 默认打开 `diff` view;`GitDialog` 内的 Changes / History tabs
+  直接切换 view,不关闭或重新打开 Radix dialog。
+- 条件渲染单个 ``。
+- `dialogOpen` 判断加入 `gitDialog !== undefined`。
+- `getLocalCommands` 补 `log` 补全项。
+
+### 6. i18n
+
+新增 `gitLog.*` 命名空间(en + zh-CN):
+
+| Key                           | EN                                                           | zh-CN                                                         |
+| ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
+| `gitLog.title`                | `History`                                                    | `提交历史`                                                    |
+| `gitLog.subtitle`             | `(v) => \`${v?.count} commits\``                             | `(v) => \`${v?.count} 条提交\``                               |
+| `gitLog.loading`              | `Loading history…`                                           | `加载历史中…`                                                 |
+| `gitLog.empty`                | `No commits yet`                                             | `暂无提交`                                                    |
+| `gitLog.unavailable`          | `Git is not available for this workspace`                    | `此工作区不可用 Git`                                          |
+| `gitLog.error`                | `Failed to load history`                                     | `加载历史失败`                                                |
+| `gitLog.loadMore`             | `Load more`                                                  | `加载更多`                                                    |
+| `gitLog.loadingMore`          | `Loading…`                                                   | `加载中…`                                                     |
+| `gitLog.files`                | `(v) => \`${v?.count} files · +${v?.added} −${v?.removed}\`` | `(v) => \`${v?.count} 个文件 · +${v?.added} −${v?.removed}\`` |
+| `gitLog.detailError`          | `Failed to load commit details`                              | `加载提交详情失败`                                            |
+| `gitLog.hidden`               | `(v) => \`${v?.count} more file(s) not shown\``              | `(v) => \`还有 ${v?.count} 个文件未显示\``                    |
+| `gitLog.copySha`              | `(v) => \`Copy commit ${v?.sha}\``                           | `(v) => \`复制提交 ${v?.sha}\``                               |
+| `localCommand.logNoWorkspace` | `No workspace is available yet to show history for.`         | `当前还没有可用于查看历史的工作区。`                          |
+
+## 兼容性
+
+- 新路由、新 SDK 方法、新组件,全部是增量,不修改任何现有接口。
+- 旧 daemon 没有 `/workspace/git/log` 路由:SDK 调用会 404,前端显示
+  `Failed to load history` 错误占位。
+- 旧 client 不受影响(不请求新路由)。
+- 非 git 仓库 / 空仓库:`fetchGitLog` 返回 `null` 或空列表,前端显示占位。
+- `/log` 在非 Web Shell 客户端不存在(不影响 CLI / ACP)。
+
+## 测试计划
+
+### Unit tests
+
+- **core** `fetchGitLog`:
+  - 正常仓库:返回正确条目数、字段解析正确(SHA/作者/时间/subject/refs/parents)。
+  - 分页:`hasMore` 判断正确;`skip` 偏移正确。
+  - 空仓库(无 commit):返回空列表。
+  - 非仓库:返回 `null`。
+  - `limit` 上限截断(>200 → 200)。
+- **core** `fetchGitCommitDetail`:
+  - 正常 commit:body + numstat 正确。
+  - root commit(无 parent):`--root` 生效,文件统计正确。
+  - merge commit:parents 列表正确。
+  - 非法 sha(注入尝试):被拒绝。
+  - 不存在的 sha:返回 `null`。
+- **daemon** 路由:
+  - `GET /workspace/git/log`:正确映射 core 结果到 wire format。
+  - `GET /workspace/git/log/commit?sha=`:sha 校验 + 映射。
+  - qualified 路由:trusted 校验生效。
+  - 参数校验:limit/skip 非法值处理。
+- **SDK**:
+  - `workspaceGitLog` URL 拼接(有/无参数)。
+  - `workspaceGitCommitDetail` URL 拼接 + sha 编码。
+- **Web Shell** `GitLogDialog`:
+  - 列表渲染(SHA/subject/作者/时间/refs/merge 图标)。
+  - 展开按需拉详情 + 折叠。
+  - Load more 追加。
+  - loading / error / unavailable / empty 占位。
+  - 相对时间格式化。
+- **App.tsx**:
+  - `/log` 本地拦截(有/无 workspace)。
+
+### Integration / browser verification
+
+- 正常仓库打开 `/log`:列表正确,展开详情正确,Load more 正确。
+- 空仓库 / 非 git 目录:占位文案。
+- 大仓库(>200 commits):分页正常,性能可接受。
+
+## 风险和控制
+
+- **风险**:`git log` 在超大仓库(100k+ commits)上 `--skip` 性能退化。
+  **控制**:`--skip` 是 O(skip) 的,但 50 条一页、用户手动翻页的场景下,
+  skip 值通常不会极大。如果后续需要深分页,可改为 `--before=`
+  游标。本期不做。
+- **风险**:`%D`(refs)在大量 tag/branch 时字符串很长。**控制**:UI 只显示
+  前 2-3 个 ref,其余折叠。
+- **风险**:sha 查询参数注入。**控制**:core 层正则校验 `/^[0-9a-f]{7,40}$/i`,
+  daemon 层二次校验。
+- **风险**:跨包新增类型扩大 PR 面积。**控制**:类型最小化,不引入额外依赖。
+
+## 实施计划
+
+| 步骤 | 内容                                                     | 涉及包         |
+| ---- | -------------------------------------------------------- | -------------- |
+| 1    | core:`fetchGitLog` + `fetchGitCommitDetail` + 单测      | core           |
+| 2    | daemon:`workspace-git-log.ts` 路由 + 注册 + 单测        | cli            |
+| 3    | SDK:类型 + client 方法 + 导出                           | sdk-typescript |
+| 4    | Web Shell:`GitLogDialog` + CSS + i18n + App 集成 + 单测 | web-shell      |
+| 5    | build + typecheck + lint + 全量单测验证                  | all            |
diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md
index 351524feb9b..ecb9ca29f3a 100644
--- a/docs/users/features/commands.md
+++ b/docs/users/features/commands.md
@@ -49,6 +49,7 @@ Commands for adjusting interface appearance and work environment.
 | → `detail`           | Show per-item context usage breakdown                                                                                                                                             | `/context detail`                                                                 |
 | `/history`           | Control history display preferences and visibility                                                                                                                                | `/history collapse-on-resume`, `/history expand-on-resume`, `/history expand-now` |
 | `/diff`              | Open an interactive diff viewer showing uncommitted changes and per-turn diffs. Use ←/→ to switch between current git diff and individual conversation turns, ↑/↓ to browse files | `/diff`                                                                           |
+| `/log`               | Open a commit history viewer for the workspace (Web Shell only)                                                                                                                   | `/log`                                                                            |
 | `/theme`             | Change Qwen Code visual theme                                                                                                                                                     | `/theme`                                                                          |
 | `/vim`               | Turn input area Vim editing mode on/off                                                                                                                                           | `/vim`                                                                            |
 | `/voice`             | Toggle voice dictation input                                                                                                                                                      | `/voice`, `/voice hold`, `/voice tap`, `/voice off`, `/voice status`              |
@@ -310,6 +311,59 @@ In headless (`--prompt`) or non-interactive contexts, `/diff` prints a plain-tex
    +3  -2  README.md
 ```
 
+**Web Shell:** In the Web Shell UI (`qwen serve`), `/diff` opens a graphical diff dialog. A tab bar at the top lets you switch between the **Changes** view and the **History** view (`/log`).
+
+#### History Viewer (`/log`) — Web Shell only
+
+The `/log` command opens a commit history browser for the current workspace. It is available only in the Web Shell UI; the CLI/TUI does not have this command.
+
+**How it works:**
+
+`/log` opens a dialog listing commits in reverse chronological order (newest first). Each row shows:
+
+- Short SHA (monospace, with a copy button for the full SHA)
+- Commit subject (single line)
+- Author name and relative time (e.g. "2h ago")
+- Branch/tag ref labels, when present
+- A merge icon (⎇) for merge commits
+
+Click a commit row to expand its details on demand:
+
+- Full commit message body
+- File change statistics (files changed, lines added/removed, per-file breakdown)
+
+Use **Load more** at the bottom to fetch the next page of commits (50 per page).
+
+**Example:**
+
+```
+┌─ History ──────────────────────────── 50 commits ─ ✕ ┐
+│                                                       │
+│  a1b2c3d  feat(cli): add --json flag        2h ago   │
+│           wenshao                                    │
+│                                                       │
+│  e4f5g6h  fix(core): handle null config     5h ago   │
+│           dev · main  v1.2.0                         │
+│                                                       │
+│ ▼ 789abcd  refactor: simplify parser        1d ago   │
+│   ┌─────────────────────────────────────────────┐    │
+│   │  Broke the monolithic parse() into smaller  │    │
+│   │  functions for readability.                 │    │
+│   │                                             │    │
+│   │  3 files · +45 −12                          │    │
+│   │   +30 −8   src/parser.ts                    │    │
+│   │   +10 −2   src/utils.ts                     │    │
+│   │   +5  −2   test/parser.test.ts              │    │
+│   └─────────────────────────────────────────────┘    │
+│                                                       │
+│              [ Load more ]                            │
+└───────────────────────────────────────────────────────┘
+```
+
+> [!note]
+>
+> `/log` requires a git repository workspace. If the workspace is not a git repository or has no commits, the dialog shows a placeholder message.
+
 ### 1.9 Information, Settings, and Help
 
 Commands for obtaining information and performing system settings.
diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md
index 91dd2c2a129..5edd1c8c3c3 100644
--- a/docs/users/qwen-serve.md
+++ b/docs/users/qwen-serve.md
@@ -66,7 +66,7 @@ qwen serve
 
 The default bind is `127.0.0.1:4170`. Bearer auth is **off** on loopback so local development "just works". The daemon registers the current working directory as its primary workspace; use an absolute `--workspace /path/to/dir` to override it, and repeat the flag to register additional isolated runtimes.
 
-**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.
+**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, commit history, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.
 
 ### 2. Sanity-check it
 
diff --git a/packages/cli/src/serve/routes/workspace-git-log.test.ts b/packages/cli/src/serve/routes/workspace-git-log.test.ts
new file mode 100644
index 00000000000..9184d859ebe
--- /dev/null
+++ b/packages/cli/src/serve/routes/workspace-git-log.test.ts
@@ -0,0 +1,331 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import express from 'express';
+import request from 'supertest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fetchGitLog, fetchGitCommitDetail } from '@qwen-code/qwen-code-core';
+import type { AcpSessionBridge } from '../acp-session-bridge.js';
+import { sendBridgeError } from '../server/error-response.js';
+import {
+  createWorkspaceRegistry,
+  type WorkspaceRegistry,
+  type WorkspaceRuntime,
+} from '../workspace-registry.js';
+import {
+  registerWorkspaceGitLogRoutes,
+  registerWorkspaceQualifiedGitLogRoutes,
+} from './workspace-git-log.js';
+
+vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
+  ...(await importOriginal()),
+  fetchGitLog: vi.fn(),
+  fetchGitCommitDetail: vi.fn(),
+}));
+
+const fetchGitLogMock = vi.mocked(fetchGitLog);
+const fetchGitCommitDetailMock = vi.mocked(fetchGitCommitDetail);
+
+function runtime(
+  workspaceId: string,
+  workspaceCwd: string,
+  trusted: boolean,
+): WorkspaceRuntime {
+  return {
+    workspaceId,
+    workspaceCwd,
+    primary: workspaceId === 'primary',
+    trusted,
+    bridge: { publishWorkspaceEvent: vi.fn() } as unknown as AcpSessionBridge,
+  } as WorkspaceRuntime;
+}
+
+function registry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry {
+  return createWorkspaceRegistry(runtimes);
+}
+
+const ENTRY = {
+  sha: 'abcdef1234567890abcdef1234567890abcdef12',
+  shortSha: 'abcdef1',
+  authorName: 'Test',
+  authorEmail: 't@example.com',
+  authorDate: 1_700_000_000,
+  subject: 'do a thing',
+  refs: 'HEAD -> main',
+  parents: ['0000000000000000000000000000000000000000'],
+};
+
+describe('workspace Git log routes', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('returns the commit list for the bound workspace', async () => {
+    fetchGitLogMock.mockResolvedValue({ entries: [ENTRY], hasMore: true });
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get('/workspace/git/log');
+
+    expect(response.status).toBe(200);
+    expect(response.body).toMatchObject({
+      v: 1,
+      workspaceCwd: '/work/main',
+      available: true,
+      hasMore: true,
+      entries: [
+        {
+          sha: ENTRY.sha,
+          shortSha: 'abcdef1',
+          subject: 'do a thing',
+          refs: 'HEAD -> main',
+          parents: ENTRY.parents,
+        },
+      ],
+    });
+  });
+
+  it('reports available=false when the workspace is not a git repo', async () => {
+    fetchGitLogMock.mockResolvedValue(null);
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get('/workspace/git/log');
+
+    expect(response.status).toBe(200);
+    expect(response.body).toMatchObject({
+      available: false,
+      entries: [],
+      hasMore: false,
+    });
+  });
+
+  it('returns a structured error when fetching the log throws', async () => {
+    fetchGitLogMock.mockRejectedValue(new Error('boom'));
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get('/workspace/git/log');
+
+    expect(response.status).toBe(500);
+    expect(response.body).toMatchObject({ error: 'boom' });
+  });
+
+  it('clamps limit to MAX_LOG_LIMIT and passes skip through', async () => {
+    fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    await request(app).get('/workspace/git/log?limit=9999&skip=30');
+
+    expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
+      limit: 200,
+      skip: 30,
+    });
+  });
+
+  it('falls back to the default limit for a non-numeric limit', async () => {
+    fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    await request(app).get('/workspace/git/log?limit=abc');
+
+    expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
+      limit: 50,
+      skip: 0,
+    });
+  });
+
+  it('clamps a zero limit to one', async () => {
+    fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    await request(app).get('/workspace/git/log?limit=0');
+
+    expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
+      limit: 1,
+      skip: 0,
+    });
+  });
+
+  it('returns commit detail for a valid sha', async () => {
+    fetchGitCommitDetailMock.mockResolvedValue({
+      ...ENTRY,
+      body: 'the body',
+      files: [{ path: 'a.ts', added: 3, removed: 1, isBinary: false }],
+      filesCount: 1,
+      linesAdded: 3,
+      linesRemoved: 1,
+      hiddenCount: 0,
+    });
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get(
+      `/workspace/git/log/commit?sha=${ENTRY.shortSha}`,
+    );
+
+    expect(response.status).toBe(200);
+    expect(response.body).toMatchObject({
+      available: true,
+      sha: ENTRY.sha,
+      body: 'the body',
+      files: [{ path: 'a.ts', added: 3, removed: 1, isBinary: false }],
+      filesCount: 1,
+    });
+    expect(fetchGitCommitDetailMock).toHaveBeenCalledWith(
+      '/work/main',
+      ENTRY.shortSha,
+    );
+  });
+
+  it('rejects a missing sha with 400', async () => {
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get('/workspace/git/log/commit');
+
+    expect(response.status).toBe(400);
+    expect(response.body).toMatchObject({ errorKind: 'parse_error' });
+    expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
+  });
+
+  it('rejects a malformed (non-hex) sha with 400, distinct from a valid miss', async () => {
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get(
+      '/workspace/git/log/commit?sha=not-hex',
+    );
+
+    // A 400 (not a 200 available:false) is what lets the client distinguish a
+    // bad request from a valid lookup that found no commit.
+    expect(response.status).toBe(400);
+    expect(response.body).toMatchObject({ errorKind: 'parse_error' });
+    expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
+  });
+
+  it('reports available=false for a valid sha with no matching commit', async () => {
+    fetchGitCommitDetailMock.mockResolvedValue(null);
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get(
+      '/workspace/git/log/commit?sha=deadbee',
+    );
+
+    expect(response.status).toBe(200);
+    expect(response.body).toMatchObject({ available: false });
+  });
+
+  it('returns a structured error when fetching commit detail throws', async () => {
+    fetchGitCommitDetailMock.mockRejectedValue(new Error('boom'));
+    const app = express();
+    registerWorkspaceGitLogRoutes(app, {
+      boundWorkspace: '/work/main',
+      sendBridgeError,
+    });
+
+    const response = await request(app).get(
+      '/workspace/git/log/commit?sha=abcdef1',
+    );
+
+    expect(response.status).toBe(500);
+    expect(response.body).toMatchObject({ error: 'boom' });
+  });
+
+  it('rejects an untrusted workspace on the qualified routes', async () => {
+    const app = express();
+    const primary = runtime('primary', '/work/main', true);
+    const untrusted = runtime('untrusted', '/work/untrusted', false);
+    registerWorkspaceQualifiedGitLogRoutes(app, {
+      workspaceRegistry: registry([primary, untrusted]),
+      sendBridgeError,
+    });
+
+    const list = await request(app).get('/workspaces/untrusted/git/log');
+    const detail = await request(app).get(
+      '/workspaces/untrusted/git/log/commit?sha=abcdef1',
+    );
+
+    expect(list.status).toBe(403);
+    expect(detail.status).toBe(403);
+    expect(fetchGitLogMock).not.toHaveBeenCalled();
+    expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
+  });
+
+  it('uses the selected trusted workspace runtime for the qualified log route', async () => {
+    fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
+    const app = express();
+    const primary = runtime('primary', '/work/main', true);
+    const secondary = runtime('secondary', '/work/secondary', true);
+    registerWorkspaceQualifiedGitLogRoutes(app, {
+      workspaceRegistry: registry([primary, secondary]),
+      sendBridgeError,
+    });
+
+    const response = await request(app).get('/workspaces/secondary/git/log');
+
+    expect(response.status).toBe(200);
+    expect(fetchGitLogMock).toHaveBeenCalledWith('/work/secondary', {
+      limit: 50,
+      skip: 0,
+    });
+  });
+
+  it('uses the selected trusted workspace for qualified commit detail', async () => {
+    fetchGitCommitDetailMock.mockResolvedValue(null);
+    const app = express();
+    const primary = runtime('primary', '/work/main', true);
+    const secondary = runtime('secondary', '/work/secondary', true);
+    registerWorkspaceQualifiedGitLogRoutes(app, {
+      workspaceRegistry: registry([primary, secondary]),
+      sendBridgeError,
+    });
+
+    const response = await request(app).get(
+      '/workspaces/secondary/git/log/commit?sha=abcdef1',
+    );
+
+    expect(response.status).toBe(200);
+    expect(fetchGitCommitDetailMock).toHaveBeenCalledWith(
+      '/work/secondary',
+      'abcdef1',
+    );
+  });
+});
diff --git a/packages/cli/src/serve/routes/workspace-git-log.ts b/packages/cli/src/serve/routes/workspace-git-log.ts
new file mode 100644
index 00000000000..7e69a3873d6
--- /dev/null
+++ b/packages/cli/src/serve/routes/workspace-git-log.ts
@@ -0,0 +1,215 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Application, Request, Response } from 'express';
+import {
+  fetchGitLog,
+  fetchGitCommitDetail,
+  MAX_LOG_LIMIT,
+  DEFAULT_LOG_LIMIT,
+  type GitLogResult,
+  type GitCommitDetail,
+} from '@qwen-code/qwen-code-core';
+import type { SendBridgeError } from '../server/error-response.js';
+import type {
+  WorkspaceRegistry,
+  WorkspaceRuntime,
+} from '../workspace-registry.js';
+import {
+  requireTrustedWorkspaceRuntime,
+  resolveWorkspaceRuntimeFromParam,
+} from '../workspace-route-runtime.js';
+import { applyReadHeaders } from './workspace-file-read.js';
+
+function buildLogList(
+  workspaceCwd: string,
+  result: GitLogResult | null,
+): Record {
+  if (!result) {
+    return {
+      v: 1,
+      workspaceCwd,
+      available: false,
+      entries: [],
+      hasMore: false,
+    };
+  }
+  return {
+    v: 1,
+    workspaceCwd,
+    available: true,
+    entries: result.entries.map((e) => ({
+      sha: e.sha,
+      shortSha: e.shortSha,
+      authorName: e.authorName,
+      authorEmail: e.authorEmail,
+      authorDate: e.authorDate,
+      subject: e.subject,
+      ...(e.refs ? { refs: e.refs } : {}),
+      parents: e.parents,
+    })),
+    hasMore: result.hasMore,
+  };
+}
+
+function buildCommitDetail(
+  workspaceCwd: string,
+  result: GitCommitDetail | null,
+): Record {
+  if (!result) {
+    return { v: 1, workspaceCwd, available: false };
+  }
+  return {
+    v: 1,
+    workspaceCwd,
+    available: true,
+    sha: result.sha,
+    shortSha: result.shortSha,
+    authorName: result.authorName,
+    authorEmail: result.authorEmail,
+    authorDate: result.authorDate,
+    subject: result.subject,
+    body: result.body,
+    ...(result.refs ? { refs: result.refs } : {}),
+    parents: result.parents,
+    files: result.files.map((f) => ({
+      path: f.path,
+      added: f.added,
+      removed: f.removed,
+      isBinary: f.isBinary,
+    })),
+    filesCount: result.filesCount,
+    linesAdded: result.linesAdded,
+    linesRemoved: result.linesRemoved,
+    hiddenCount: result.hiddenCount,
+  };
+}
+
+function parsePagination(req: Request): { limit: number; skip: number } {
+  const rawLimit = req.query['limit'];
+  const rawSkip = req.query['skip'];
+  const parsedLimit =
+    typeof rawLimit === 'string' ? parseInt(rawLimit, 10) : NaN;
+  const parsedSkip = typeof rawSkip === 'string' ? parseInt(rawSkip, 10) : NaN;
+  const limit = Math.min(
+    Math.max(Number.isNaN(parsedLimit) ? DEFAULT_LOG_LIMIT : parsedLimit, 1),
+    MAX_LOG_LIMIT,
+  );
+  const skip = Math.max(Number.isNaN(parsedSkip) ? 0 : parsedSkip, 0);
+  return { limit, skip };
+}
+
+async function handleLogList(
+  req: Request,
+  res: Response,
+  workspaceCwd: string,
+  sendBridgeError: SendBridgeError,
+  route: string,
+): Promise {
+  try {
+    applyReadHeaders(res);
+    const { limit, skip } = parsePagination(req);
+    const result = await fetchGitLog(workspaceCwd, { limit, skip });
+    res.status(200).json(buildLogList(workspaceCwd, result));
+  } catch (err) {
+    sendBridgeError(res, err, { route });
+  }
+}
+
+async function handleCommitDetail(
+  req: Request,
+  res: Response,
+  workspaceCwd: string,
+  sendBridgeError: SendBridgeError,
+  route: string,
+): Promise {
+  const sha = req.query['sha'];
+  if (
+    typeof sha !== 'string' ||
+    sha.length === 0 ||
+    !/^[0-9a-f]{7,40}$/i.test(sha)
+  ) {
+    applyReadHeaders(res);
+    res.status(400).json({
+      errorKind: 'parse_error',
+      error: 'sha query parameter is required',
+      status: 400,
+    });
+    return;
+  }
+  try {
+    applyReadHeaders(res);
+    const result = await fetchGitCommitDetail(workspaceCwd, sha);
+    res.status(200).json(buildCommitDetail(workspaceCwd, result));
+  } catch (err) {
+    sendBridgeError(res, err, { route });
+  }
+}
+
+export function registerWorkspaceGitLogRoutes(
+  app: Application,
+  deps: { boundWorkspace: string; sendBridgeError: SendBridgeError },
+): void {
+  app.get('/workspace/git/log', (req, res) => {
+    void handleLogList(
+      req,
+      res,
+      deps.boundWorkspace,
+      deps.sendBridgeError,
+      'GET /workspace/git/log',
+    );
+  });
+  app.get('/workspace/git/log/commit', (req, res) => {
+    void handleCommitDetail(
+      req,
+      res,
+      deps.boundWorkspace,
+      deps.sendBridgeError,
+      'GET /workspace/git/log/commit',
+    );
+  });
+}
+
+function resolveTrustedRuntime(
+  registry: WorkspaceRegistry,
+  req: Request,
+  res: Response,
+): WorkspaceRuntime | null {
+  const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res);
+  if (!runtime) return null;
+  return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null;
+}
+
+export function registerWorkspaceQualifiedGitLogRoutes(
+  app: Application,
+  deps: {
+    workspaceRegistry: WorkspaceRegistry;
+    sendBridgeError: SendBridgeError;
+  },
+): void {
+  app.get('/workspaces/:workspace/git/log', (req, res) => {
+    const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res);
+    if (!runtime) return;
+    void handleLogList(
+      req,
+      res,
+      runtime.workspaceCwd,
+      deps.sendBridgeError,
+      'GET /workspaces/:workspace/git/log',
+    );
+  });
+  app.get('/workspaces/:workspace/git/log/commit', (req, res) => {
+    const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res);
+    if (!runtime) return;
+    void handleCommitDetail(
+      req,
+      res,
+      runtime.workspaceCwd,
+      deps.sendBridgeError,
+      'GET /workspaces/:workspace/git/log/commit',
+    );
+  });
+}
diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts
index 41fa453b234..6d904fba9fc 100644
--- a/packages/cli/src/serve/server.ts
+++ b/packages/cli/src/serve/server.ts
@@ -195,6 +195,10 @@ import {
   registerWorkspaceGitDiffRoutes,
   registerWorkspaceQualifiedGitDiffRoutes,
 } from './routes/workspace-git-diff.js';
+import {
+  registerWorkspaceGitLogRoutes,
+  registerWorkspaceQualifiedGitLogRoutes,
+} from './routes/workspace-git-log.js';
 import { WorkspaceGitState } from './workspace-git-state.js';
 import {
   registerWorkspaceMcpControlRoutes,
@@ -1171,6 +1175,14 @@ export function createServeApp(
     workspaceRegistry,
     sendBridgeError,
   });
+  registerWorkspaceGitLogRoutes(app, {
+    boundWorkspace: primaryBoundWorkspace,
+    sendBridgeError,
+  });
+  registerWorkspaceQualifiedGitLogRoutes(app, {
+    workspaceRegistry,
+    sendBridgeError,
+  });
 
   // Workspace memory + agents CRUD routes.
   mountWorkspaceMemoryRoutes(app, {
diff --git a/packages/core/src/utils/gitDiff.test.ts b/packages/core/src/utils/gitDiff.test.ts
index fcad20b953b..61e97f18ee3 100644
--- a/packages/core/src/utils/gitDiff.test.ts
+++ b/packages/core/src/utils/gitDiff.test.ts
@@ -14,6 +14,8 @@ import {
   fetchGitDiff,
   fetchGitDiffHunks,
   fetchGitDiffHunksForFile,
+  fetchGitLog,
+  fetchGitCommitDetail,
   getGitWorkingTreeStatus,
   MAX_DIFF_SIZE_BYTES,
   MAX_FILES,
@@ -1922,3 +1924,269 @@ describe('getGitWorkingTreeStatus', () => {
     expect(status).toMatchObject({ operation: 'bisect' });
   });
 });
+
+describe('fetchGitLog', () => {
+  let repo: string;
+
+  beforeEach(async () => {
+    repo = await makeRepo();
+  });
+
+  afterEach(async () => {
+    await fs.rm(repo, { recursive: true, force: true });
+  });
+
+  it('returns null for a non-repo directory', async () => {
+    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-norepo-'));
+    try {
+      expect(await fetchGitLog(dir)).toBeNull();
+    } finally {
+      await fs.rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it('returns empty list for a repo with no commits', async () => {
+    const result = await fetchGitLog(repo);
+    expect(result).toEqual({ entries: [], hasMore: false });
+  });
+
+  it('returns commits newest-first with correct fields', async () => {
+    await fs.writeFile(path.join(repo, 'a.txt'), 'one\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'first commit');
+    await fs.writeFile(path.join(repo, 'b.txt'), 'two\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'second commit');
+
+    const result = await fetchGitLog(repo);
+    expect(result).not.toBeNull();
+    expect(result!.entries).toHaveLength(2);
+    expect(result!.hasMore).toBe(false);
+
+    const newest = result!.entries[0];
+    expect(newest.subject).toBe('second commit');
+    expect(newest.authorName).toBe('Test');
+    expect(newest.authorEmail).toBe('test@example.com');
+    expect(newest.sha).toMatch(/^[0-9a-f]{40}$/);
+    expect(newest.shortSha.length).toBeGreaterThanOrEqual(7);
+    expect(newest.authorDate).toBeGreaterThan(0);
+    expect(newest.parents).toHaveLength(1);
+
+    const oldest = result!.entries[1];
+    expect(oldest.subject).toBe('first commit');
+    expect(oldest.parents).toHaveLength(0);
+  }, 15_000);
+
+  it('paginates with limit and hasMore', async () => {
+    for (let i = 0; i < 5; i++) {
+      await fs.writeFile(path.join(repo, `f${i}.txt`), `${i}\n`);
+      await git(repo, 'add', '.');
+      await git(repo, 'commit', '-q', '-m', `commit ${i}`);
+    }
+
+    const page1 = await fetchGitLog(repo, { limit: 3 });
+    expect(page1!.entries).toHaveLength(3);
+    expect(page1!.hasMore).toBe(true);
+    expect(page1!.entries[0].subject).toBe('commit 4');
+
+    const page2 = await fetchGitLog(repo, { limit: 3, skip: 3 });
+    expect(page2!.entries).toHaveLength(2);
+    expect(page2!.hasMore).toBe(false);
+    expect(page2!.entries[0].subject).toBe('commit 1');
+  }, 15_000);
+
+  it('respects limit and clamps edge values', async () => {
+    for (let i = 0; i < 3; i++) {
+      await fs.writeFile(path.join(repo, `f${i}.txt`), `${i}\n`);
+      await git(repo, 'add', '.');
+      await git(repo, 'commit', '-q', '-m', `c${i}`);
+    }
+
+    const limited = await fetchGitLog(repo, { limit: 2 });
+    expect(limited!.entries).toHaveLength(2);
+    expect(limited!.hasMore).toBe(true);
+
+    const clamped = await fetchGitLog(repo, { limit: 0 });
+    expect(clamped!.entries).toHaveLength(1);
+  }, 15_000);
+
+  it('includes refs for HEAD', async () => {
+    await fs.writeFile(path.join(repo, 'a.txt'), 'x\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'c1');
+
+    const result = await fetchGitLog(repo);
+    expect(result!.entries[0].refs).toContain('HEAD');
+    expect(result!.entries[0].refs).toContain('main');
+  });
+
+  it('preserves a unit separator in the commit subject', async () => {
+    const subject = 'subject\x1ftail';
+    await fs.writeFile(path.join(repo, 'a.txt'), 'x\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', subject);
+
+    const result = await fetchGitLog(repo);
+    expect(result!.entries[0].subject).toBe(subject);
+    expect(result!.entries[0].refs).toContain('HEAD');
+    expect(result!.entries[0].parents).toHaveLength(0);
+  });
+});
+
+describe('fetchGitCommitDetail', () => {
+  let repo: string;
+
+  beforeEach(async () => {
+    repo = await makeRepo();
+  });
+
+  afterEach(async () => {
+    await fs.rm(repo, { recursive: true, force: true });
+  });
+
+  it('returns null for invalid sha', async () => {
+    expect(await fetchGitCommitDetail(repo, 'not-a-sha')).toBeNull();
+    expect(await fetchGitCommitDetail(repo, 'abc')).toBeNull();
+    expect(await fetchGitCommitDetail(repo, '')).toBeNull();
+  });
+
+  it('returns null for a non-repo directory', async () => {
+    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-norepo-'));
+    try {
+      expect(await fetchGitCommitDetail(dir, 'abcdef1')).toBeNull();
+    } finally {
+      await fs.rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it('returns detail with body and file stats', async () => {
+    await fs.writeFile(path.join(repo, 'a.txt'), 'line1\nline2\nline3\n');
+    await git(repo, 'add', '.');
+    await git(
+      repo,
+      'commit',
+      '-q',
+      '-m',
+      'subject line\n\nBody paragraph here.',
+    );
+
+    const log = await fetchGitLog(repo);
+    const sha = log!.entries[0].sha;
+
+    const detail = await fetchGitCommitDetail(repo, sha);
+    expect(detail).not.toBeNull();
+    expect(detail!.sha).toBe(sha);
+    expect(detail!.subject).toBe('subject line');
+    expect(detail!.body).toContain('Body paragraph here.');
+    expect(detail!.authorName).toBe('Test');
+    expect(detail!.filesCount).toBe(1);
+    expect(detail!.linesAdded).toBe(3);
+    expect(detail!.linesRemoved).toBe(0);
+    expect(detail!.files).toHaveLength(1);
+    expect(detail!.files[0].path).toBe('a.txt');
+    expect(detail!.files[0].added).toBe(3);
+    expect(detail!.files[0].isBinary).toBe(false);
+    expect(detail!.hiddenCount).toBe(0);
+  });
+
+  it('preserves unit separators in the subject and body', async () => {
+    const subject = 'subject\x1ftail';
+    const body = 'body\x1ftail';
+    await fs.writeFile(path.join(repo, 'a.txt'), 'x\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', `${subject}\n\n${body}`);
+
+    const log = await fetchGitLog(repo);
+    const detail = await fetchGitCommitDetail(repo, log!.entries[0].sha);
+
+    expect(detail!.subject).toBe(subject);
+    expect(detail!.body).toBe(body);
+    expect(detail!.refs).toContain('HEAD');
+    expect(detail!.parents).toHaveLength(0);
+  });
+
+  it('handles root commit (no parent)', async () => {
+    await fs.writeFile(path.join(repo, 'init.txt'), 'hello\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'root');
+
+    const log = await fetchGitLog(repo);
+    const detail = await fetchGitCommitDetail(repo, log!.entries[0].sha);
+    expect(detail).not.toBeNull();
+    expect(detail!.parents).toHaveLength(0);
+    expect(detail!.filesCount).toBe(1);
+    expect(detail!.files[0].path).toBe('init.txt');
+  });
+
+  it('detects binary files', async () => {
+    // Git needs a reasonable amount of NUL-containing data to classify as binary.
+    const buf = Buffer.alloc(1024);
+    buf.fill(0x89, 0, 512);
+    buf.fill(0x00, 512);
+    await fs.writeFile(path.join(repo, 'img.png'), buf);
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'add image');
+
+    const log = await fetchGitLog(repo);
+    const detail = await fetchGitCommitDetail(repo, log!.entries[0].sha);
+    expect(detail!.files[0].isBinary).toBe(true);
+    expect(detail!.files[0].added).toBe(0);
+  });
+
+  it('returns null for nonexistent sha', async () => {
+    await fs.writeFile(path.join(repo, 'a.txt'), 'x\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'c1');
+
+    const result = await fetchGitCommitDetail(
+      repo,
+      'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
+    );
+    expect(result).toBeNull();
+  });
+
+  it('counts a renamed file by its new path (not dropped, not empty-path)', async () => {
+    await fs.writeFile(path.join(repo, 'old.txt'), 'a\nb\nc\nd\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'seed');
+    // Rename + a small edit so git reports it as a rename with numstat counts.
+    await git(repo, 'mv', 'old.txt', 'new.txt');
+    await fs.writeFile(path.join(repo, 'new.txt'), 'a\nB\nc\nd\ne\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'rename + edit');
+
+    const log = await fetchGitLog(repo);
+    const detail = await fetchGitCommitDetail(repo, log!.entries[0].sha);
+    expect(detail).not.toBeNull();
+    // The rename is one file, keyed by the NEW path — the three-token `-z`
+    // rename sequence must not record an empty path or drop it.
+    expect(detail!.filesCount).toBe(1);
+    expect(detail!.files).toHaveLength(1);
+    expect(detail!.files[0].path).toBe('new.txt');
+    expect(detail!.files[0].added).toBeGreaterThan(0);
+  });
+
+  it('shows the first-parent diff for a merge commit', async () => {
+    await fs.writeFile(path.join(repo, 'base.txt'), 'base\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'base');
+    await git(repo, 'checkout', '-q', '-b', 'feature');
+    await fs.writeFile(path.join(repo, 'feature.txt'), 'feature\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'feature work');
+    await git(repo, 'checkout', '-q', 'main');
+    await fs.writeFile(path.join(repo, 'main.txt'), 'main\n');
+    await git(repo, 'add', '.');
+    await git(repo, 'commit', '-q', '-m', 'main work');
+    await git(repo, 'merge', '-q', '--no-ff', 'feature', '-m', 'merge feature');
+
+    const log = await fetchGitLog(repo);
+    const detail = await fetchGitCommitDetail(repo, log!.entries[0].sha);
+    expect(detail).not.toBeNull();
+    expect(detail!.parents.length).toBe(2);
+    // Plain diff-tree emits nothing for a merge; the first-parent diff must
+    // surface what the merge introduced (feature.txt) — else filesCount is 0.
+    expect(detail!.filesCount).toBeGreaterThan(0);
+    expect(detail!.files.some((f) => f.path === 'feature.txt')).toBe(true);
+  });
+});
diff --git a/packages/core/src/utils/gitDiff.ts b/packages/core/src/utils/gitDiff.ts
index 88e8c3dc4be..a67509715d7 100644
--- a/packages/core/src/utils/gitDiff.ts
+++ b/packages/core/src/utils/gitDiff.ts
@@ -509,21 +509,22 @@ async function synthesizeUntrackedHunk(
  * Binary files use `-` for both counts. Only the first `MAX_FILES` entries are
  * retained in `perFileStats`; totals account for every entry.
  */
-export function parseGitNumstat(stdout: string): GitDiffResult {
-  // Drop the trailing empty chunk from the terminating NUL.
+interface NumstatEntry {
+  path: string;
+  oldPath?: string;
+  added: number;
+  removed: number;
+  isBinary: boolean;
+}
+
+function forEachNumstatEntry(
+  stdout: string,
+  visit: (entry: NumstatEntry) => void,
+): void {
   const tokens = stdout.split('\0');
   if (tokens.length > 0 && tokens[tokens.length - 1] === '') tokens.pop();
 
-  let added = 0;
-  let removed = 0;
-  let validFileCount = 0;
-  const perFileStats = new Map();
-
-  // Rename entries span three tokens ({counts}, oldPath, newPath). When we
-  // see an empty path in the counts token we stash the counts here and
-  // consume the next two tokens as the rename pair.
-  let pending: { added: number; removed: number; isBinary: boolean } | null =
-    null;
+  let pending: Omit | null = null;
   let renameOld: string | null = null;
 
   for (const token of tokens) {
@@ -532,42 +533,46 @@ export function parseGitNumstat(stdout: string): GitDiffResult {
         renameOld = token;
         continue;
       }
-      // Key by the current (post-rename) path so the single-file endpoint can
-      // address it; carry the old path for display. Keying by the synthetic
-      // `old => new` string sent a nonexistent literal path to git, so renamed
-      // rows could never expand.
-      commitEntry(
-        token,
-        pending.added,
-        pending.removed,
-        pending.isBinary,
-        renameOld,
-      );
+      visit({ ...pending, path: token, oldPath: renameOld });
       pending = null;
       renameOld = null;
       continue;
     }
 
-    // Index-based parse — `split('\t')` is unsafe because `-z` preserves
-    // literal tabs inside filenames.
     const firstTab = token.indexOf('\t');
     if (firstTab < 0) continue;
     const secondTab = token.indexOf('\t', firstTab + 1);
     if (secondTab < 0) continue;
     const addStr = token.slice(0, firstTab);
     const remStr = token.slice(firstTab + 1, secondTab);
-    const filePath = token.slice(secondTab + 1);
+    const path = token.slice(secondTab + 1);
     const isBinary = addStr === '-' || remStr === '-';
-    const fileAdded = isBinary ? 0 : parseInt(addStr, 10) || 0;
-    const fileRemoved = isBinary ? 0 : parseInt(remStr, 10) || 0;
+    const added = isBinary ? 0 : parseInt(addStr, 10) || 0;
+    const removed = isBinary ? 0 : parseInt(remStr, 10) || 0;
 
-    if (filePath === '') {
-      // Rename header — wait for oldPath and newPath tokens.
-      pending = { added: fileAdded, removed: fileRemoved, isBinary };
+    if (path === '') {
+      pending = { added, removed, isBinary };
       continue;
     }
-    commitEntry(filePath, fileAdded, fileRemoved, isBinary);
+    visit({ path, added, removed, isBinary });
   }
+}
+
+export function parseGitNumstat(stdout: string): GitDiffResult {
+  let added = 0;
+  let removed = 0;
+  let validFileCount = 0;
+  const perFileStats = new Map();
+
+  forEachNumstatEntry(stdout, (entry) => {
+    commitEntry(
+      entry.path,
+      entry.added,
+      entry.removed,
+      entry.isBinary,
+      entry.oldPath,
+    );
+  });
 
   function commitEntry(
     filePath: string,
@@ -1385,3 +1390,239 @@ async function countStashEntries(gitRoot: string): Promise {
     return 0;
   }
 }
+
+// ---------------------------------------------------------------------------
+// Git log
+// ---------------------------------------------------------------------------
+
+/** Maximum entries per `fetchGitLog` page. */
+export const MAX_LOG_LIMIT = 200;
+/** Default page size for `fetchGitLog`. */
+export const DEFAULT_LOG_LIMIT = 50;
+
+export interface GitLogEntry {
+  sha: string;
+  shortSha: string;
+  authorName: string;
+  authorEmail: string;
+  /** Unix timestamp in seconds. */
+  authorDate: number;
+  subject: string;
+  /** `%D` output, e.g. `"HEAD -> main, origin/main, v1.2.0"`. */
+  refs: string;
+  /** Parent SHAs (length > 1 ⇒ merge commit). */
+  parents: string[];
+}
+
+export interface GitLogResult {
+  entries: GitLogEntry[];
+  hasMore: boolean;
+}
+
+export interface GitCommitFileStat {
+  path: string;
+  added: number;
+  removed: number;
+  isBinary: boolean;
+}
+
+export interface GitCommitDetail {
+  sha: string;
+  shortSha: string;
+  authorName: string;
+  authorEmail: string;
+  authorDate: number;
+  subject: string;
+  body: string;
+  refs: string;
+  parents: string[];
+  files: GitCommitFileStat[];
+  filesCount: number;
+  linesAdded: number;
+  linesRemoved: number;
+  hiddenCount: number;
+}
+
+const LOG_FORMAT = '%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P';
+const LOG_DETAIL_FORMAT =
+  '%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P%x00%b';
+
+function parseLogFields(parts: string[]): GitLogEntry | null {
+  if (parts.length !== 8) return null;
+  return {
+    sha: parts[0],
+    shortSha: parts[1],
+    authorName: parts[2],
+    authorEmail: parts[3],
+    authorDate: parseInt(parts[4], 10) || 0,
+    subject: parts[5],
+    refs: parts[6],
+    parents: parts[7] ? parts[7].split(' ').filter(Boolean) : [],
+  };
+}
+
+/**
+ * Fetch a page of commit log entries (newest first).
+ *
+ * Returns `null` when not inside a git repo or when git fails. An empty
+ * repo (no commits) returns `{ entries: [], hasMore: false }`.
+ */
+export async function fetchGitLog(
+  cwd: string,
+  options?: { limit?: number; skip?: number },
+): Promise {
+  const gitRoot = findGitRoot(cwd);
+  if (!gitRoot) return null;
+
+  const limit = Math.min(
+    Math.max(options?.limit ?? DEFAULT_LOG_LIMIT, 1),
+    MAX_LOG_LIMIT,
+  );
+  const skip = Math.max(options?.skip ?? 0, 0);
+
+  const stdout = await runGit(
+    [
+      '--no-optional-locks',
+      'log',
+      '-z',
+      `--format=${LOG_FORMAT}`,
+      '-n',
+      String(limit + 1),
+      ...(skip > 0 ? ['--skip', String(skip)] : []),
+    ],
+    gitRoot,
+  );
+  if (stdout === null) {
+    // git log fails on an empty repo (no commits yet). Distinguish that from
+    // a real failure by probing HEAD: if HEAD doesn't resolve either, the
+    // repo simply has no commits.
+    const head = await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot);
+    return head === null ? { entries: [], hasMore: false } : null;
+  }
+
+  const fields = stdout.split('\0');
+  if (fields.at(-1) === '') fields.pop();
+  const recordCount = Math.floor(fields.length / 8);
+  const hasMore = recordCount > limit;
+  const pageCount = hasMore ? limit : recordCount;
+
+  const entries: GitLogEntry[] = [];
+  for (let i = 0; i < pageCount; i++) {
+    const entry = parseLogFields(fields.slice(i * 8, i * 8 + 8));
+    if (entry) entries.push(entry);
+  }
+  return { entries, hasMore };
+}
+
+/**
+ * Fetch full detail for a single commit: metadata (including body) plus
+ * per-file numstat.
+ *
+ * Returns `null` when not inside a git repo, the sha is invalid / not found,
+ * or git fails.
+ */
+export async function fetchGitCommitDetail(
+  cwd: string,
+  sha: string,
+): Promise {
+  if (!/^[0-9a-f]{7,40}$/i.test(sha)) return null;
+
+  const gitRoot = findGitRoot(cwd);
+  if (!gitRoot) return null;
+
+  const metaRaw = await runGit(
+    [
+      '--no-optional-locks',
+      'log',
+      '-1',
+      '-z',
+      `--format=${LOG_DETAIL_FORMAT}`,
+      sha,
+    ],
+    gitRoot,
+  );
+  if (metaRaw === null) return null;
+
+  const parts = metaRaw.split('\0');
+  if (parts.at(-1) === '') parts.pop();
+  if (parts.length !== 9) return null;
+
+  const parents = parts[7] ? parts[7].split(' ').filter(Boolean) : [];
+
+  // Per-file stats via diff-tree. `--root` handles the initial commit (no
+  // parent). For merge commits, plain diff-tree outputs nothing; diff against
+  // the first parent to show what the merge introduced.
+  const diffTreeArgs =
+    parents.length > 1
+      ? [
+          '--no-optional-locks',
+          'diff-tree',
+          '--no-commit-id',
+          '--numstat',
+          // Detect renames so `git mv` counts as one file (by its new path),
+          // matching the main `git diff` path — diff-tree is plumbing and does
+          // NOT honour diff.renames, so without -M a rename splits into a
+          // delete + add pair. The three-token `-z` rename sequence it then
+          // emits is handled by the pending-rename state machine below.
+          '-M',
+          '-r',
+          '-z',
+          `${sha}^1`,
+          sha,
+        ]
+      : [
+          '--no-optional-locks',
+          'diff-tree',
+          '--no-commit-id',
+          '--numstat',
+          // Detect renames so `git mv` counts as one file (by its new path),
+          // matching the main `git diff` path — diff-tree is plumbing and does
+          // NOT honour diff.renames, so without -M a rename splits into a
+          // delete + add pair. The three-token `-z` rename sequence it then
+          // emits is handled by the pending-rename state machine below.
+          '-M',
+          '-r',
+          '-z',
+          '--root',
+          sha,
+        ];
+  const numstatRaw = await runGit(diffTreeArgs, gitRoot);
+
+  const files: GitCommitFileStat[] = [];
+  let filesCount = 0;
+  let linesAdded = 0;
+  let linesRemoved = 0;
+
+  if (numstatRaw) {
+    forEachNumstatEntry(numstatRaw, (entry) => {
+      filesCount++;
+      linesAdded += entry.added;
+      linesRemoved += entry.removed;
+      if (files.length < MAX_FILES) {
+        files.push({
+          path: entry.path,
+          added: entry.added,
+          removed: entry.removed,
+          isBinary: entry.isBinary,
+        });
+      }
+    });
+  }
+
+  return {
+    sha: parts[0],
+    shortSha: parts[1],
+    authorName: parts[2],
+    authorEmail: parts[3],
+    authorDate: parseInt(parts[4], 10) || 0,
+    subject: parts[5],
+    refs: parts[6],
+    parents,
+    body: parts[8].replace(/\n$/, ''),
+    files,
+    filesCount,
+    linesAdded,
+    linesRemoved,
+    hiddenCount: Math.max(filesCount - files.length, 0),
+  };
+}
diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts
index 0e262c9491a..d3765b37b70 100644
--- a/packages/sdk-typescript/src/daemon/DaemonClient.ts
+++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts
@@ -72,6 +72,8 @@ import type {
   DaemonWorkspaceGitStatus,
   DaemonWorkspaceGitDiff,
   DaemonWorkspaceGitDiffHunks,
+  DaemonGitLog,
+  DaemonGitCommitDetail,
   DaemonWorkspaceMcpStatus,
   DaemonWorkspaceMcpInitializeResult,
   DaemonWorkspaceMcpToolsStatus,
@@ -1051,6 +1053,26 @@ export class DaemonClient {
     );
   }
 
+  async workspaceGitLog(limit?: number, skip?: number): Promise {
+    const params = new URLSearchParams();
+    if (limit != null) params.set('limit', String(limit));
+    if (skip != null) params.set('skip', String(skip));
+    const qs = params.toString();
+    return await this.jsonRequest(
+      `/workspace/git/log${qs ? `?${qs}` : ''}`,
+      'GET /workspace/git/log',
+      { mode: 'rest' },
+    );
+  }
+
+  async workspaceGitCommitDetail(sha: string): Promise {
+    return await this.jsonRequest(
+      `/workspace/git/log/commit?sha=${urlEncode(sha)}`,
+      'GET /workspace/git/log/commit',
+      { mode: 'rest' },
+    );
+  }
+
   async workspaceMcpTools(
     serverName: string,
   ): Promise {
@@ -4236,6 +4258,28 @@ export class WorkspaceDaemonClient {
     );
   }
 
+  workspaceGitLog(limit?: number, skip?: number): Promise {
+    const params = new URLSearchParams();
+    if (limit != null) params.set('limit', String(limit));
+    if (skip != null) params.set('skip', String(skip));
+    const qs = params.toString();
+    return this.client.workspaceJsonRequest(
+      this.workspaceSelector,
+      `/git/log${qs ? `?${qs}` : ''}`,
+      'GET /workspaces/:workspace/git/log',
+      { mode: 'rest' },
+    );
+  }
+
+  workspaceGitCommitDetail(sha: string): Promise {
+    return this.client.workspaceJsonRequest(
+      this.workspaceSelector,
+      `/git/log/commit?sha=${urlEncode(sha)}`,
+      'GET /workspaces/:workspace/git/log/commit',
+      { mode: 'rest' },
+    );
+  }
+
   workspaceSkills(): Promise {
     return this.get('/skills', 'GET /workspaces/:workspace/skills');
   }
diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts
index d8a9acdc726..ddd4f7d0af9 100644
--- a/packages/sdk-typescript/src/daemon/index.ts
+++ b/packages/sdk-typescript/src/daemon/index.ts
@@ -400,6 +400,10 @@ export type {
   DaemonWorkspaceGitDiffFile,
   DaemonWorkspaceGitDiffHunks,
   DaemonDiffHunk,
+  DaemonGitLogEntry,
+  DaemonGitLog,
+  DaemonGitCommitFileStat,
+  DaemonGitCommitDetail,
   DaemonWorkspaceRemovalActivity,
   DaemonWorkspaceRemovalResult,
   DaemonAvailableCommand,
diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts
index d6847e6b8bd..f70292b6ab6 100644
--- a/packages/sdk-typescript/src/daemon/types.ts
+++ b/packages/sdk-typescript/src/daemon/types.ts
@@ -157,6 +157,61 @@ export interface DaemonWorkspaceGitDiffHunks {
   truncated?: boolean;
 }
 
+/** A single commit entry in the log list. */
+export interface DaemonGitLogEntry {
+  sha: string;
+  shortSha: string;
+  authorName: string;
+  authorEmail: string;
+  /** Unix timestamp in seconds. */
+  authorDate: number;
+  subject: string;
+  /** Ref decorations, e.g. `"HEAD -> main, origin/main, v1.2.0"`. */
+  refs?: string;
+  /** Parent SHAs (length > 1 ⇒ merge commit). */
+  parents: string[];
+}
+
+/** Response from `GET /workspace/git/log`. */
+export interface DaemonGitLog {
+  v: 1;
+  workspaceCwd: string;
+  /** `false` when git is not available for this workspace. */
+  available: boolean;
+  entries: DaemonGitLogEntry[];
+  hasMore: boolean;
+}
+
+/** Per-file numstat entry within a commit detail. */
+export interface DaemonGitCommitFileStat {
+  path: string;
+  added: number;
+  removed: number;
+  isBinary: boolean;
+}
+
+/** Response from `GET /workspace/git/log/commit?sha=`. */
+export interface DaemonGitCommitDetail {
+  v: 1;
+  workspaceCwd: string;
+  /** `false` when the commit was not found or git is unavailable. */
+  available: boolean;
+  sha?: string;
+  shortSha?: string;
+  authorName?: string;
+  authorEmail?: string;
+  authorDate?: number;
+  subject?: string;
+  body?: string;
+  refs?: string;
+  parents?: string[];
+  files?: DaemonGitCommitFileStat[];
+  filesCount?: number;
+  linesAdded?: number;
+  linesRemoved?: number;
+  hiddenCount?: number;
+}
+
 /** Capabilities envelope returned from `GET /capabilities`. */
 export interface DaemonCapabilities {
   v: 1;
diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts
index 865aaaa6771..27413f37d03 100644
--- a/packages/sdk-typescript/src/index.ts
+++ b/packages/sdk-typescript/src/index.ts
@@ -86,6 +86,10 @@ export {
   type DaemonWorkspaceGitDiffFile,
   type DaemonWorkspaceGitDiffHunks,
   type DaemonDiffHunk,
+  type DaemonGitLogEntry,
+  type DaemonGitLog,
+  type DaemonGitCommitFileStat,
+  type DaemonGitCommitDetail,
   type DaemonWorkspaceRemovalActivity,
   type DaemonWorkspaceRemovalResult,
   type DaemonAvailableCommand,
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index e6e9c65ad8b..196da81fcbd 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -458,6 +458,7 @@ vi.mock('./components/dialogs/GitDiffDialog', async () => {
   return {
     GitDiffDialog: () =>
       React.createElement(DialogShell, null, 'changes dialog'),
+    GitDiffContent: () => React.createElement('div', null, 'changes dialog'),
   };
 });
 
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index b8448c59bc2..8dd260268a8 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -75,7 +75,7 @@ import {
 import { MemoryMessage } from './components/messages/MemoryMessage';
 import { AuthMessage } from './components/messages/AuthMessage';
 import { ToolsDialog } from './components/dialogs/ToolsDialog';
-import { GitDiffDialog } from './components/dialogs/GitDiffDialog';
+import { GitDialog, type GitDialogView } from './components/dialogs/GitDialog';
 import { SkillsManagerPage } from './components/skills/SkillsManagerPage';
 import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog';
 import { SessionOverviewPanel } from './components/SessionOverviewPanel';
@@ -2238,12 +2238,12 @@ export function App({
   const [showHelpDialog, setShowHelpDialog] = useState(false);
   const [showThemeDialog, setShowThemeDialog] = useState(false);
   const [showToolsDialog, setShowToolsDialog] = useState(false);
-  // The workspace the Changes dialog reads. Set by whichever entry point opened
+  // The workspace the Git dialog reads. Set by whichever entry point opened
   // it — the composer git chip / `/diff` (current workspace) or a sidebar
   // folder's git chip (that workspace) — so each can target its own repo.
-  const [diffWorkspaceCwd, setDiffWorkspaceCwd] = useState(
-    undefined,
-  );
+  const [gitDialog, setGitDialog] = useState<
+    { workspaceCwd: string; view: GitDialogView } | undefined
+  >(undefined);
   // Main content view. The scheduled-tasks page replaces the chat pane inline
   // (not a modal overlay), mirroring the reference design; creating or opening
   // a chat returns to 'chat'. (Daemon Status is no longer a boolean dialog — it
@@ -2987,7 +2987,7 @@ export function App({
     showHelpDialog ||
     showThemeDialog ||
     showToolsDialog ||
-    diffWorkspaceCwd !== undefined ||
+    gitDialog !== undefined ||
     modelDialogMode !== null ||
     showApprovalModeDialog ||
     tasksDialogMessage !== null ||
@@ -4565,13 +4565,19 @@ export function App({
             return true;
           }
           if (cmd === 'diff') {
-            // Local intercept: open the working-tree Changes dialog instead of
-            // forwarding `/diff` to the agent. Targets the current workspace.
             if (!gitDiffWorkspaceCwd) {
               pushToast('info', t('localCommand.diffNoWorkspace'));
               return true;
             }
-            setDiffWorkspaceCwd(gitDiffWorkspaceCwd);
+            setGitDialog({ workspaceCwd: gitDiffWorkspaceCwd, view: 'diff' });
+            return true;
+          }
+          if (cmd === 'log') {
+            if (!gitDiffWorkspaceCwd) {
+              pushToast('info', t('localCommand.logNoWorkspace'));
+              return true;
+            }
+            setGitDialog({ workspaceCwd: gitDiffWorkspaceCwd, view: 'log' });
             return true;
           }
           if (cmd === 'tasks') {
@@ -6198,10 +6204,12 @@ export function App({
               
             
           )}
-          {diffWorkspaceCwd && (
-             setDiffWorkspaceCwd(undefined)}
+          {gitDialog && (
+             setGitDialog(undefined)}
             />
           )}
           {tasksDialogMessage && (
@@ -6467,7 +6475,9 @@ export function App({
                   sessionListReloadToken={sessionListReloadToken}
                   selectedWorkspaceCwd={selectedWorkspaceCwd}
                   onSelectWorkspace={setSelectedWorkspaceCwd}
-                  onOpenGitDiff={setDiffWorkspaceCwd}
+                  onOpenGitDiff={(workspaceCwd) =>
+                    setGitDialog({ workspaceCwd, view: 'diff' })
+                  }
                   workspaces={workspaces}
                   lockedWorkspaceCwd={lockedWorkspaceCwd}
                   lockedWorkspace={sidebarOptions.lockedWorkspace}
@@ -7274,7 +7284,11 @@ export function App({
                           gitStatus={selectedWorkspaceGitStatus}
                           onOpenGitDiff={
                             gitDiffWorkspaceCwd && !sessionWorktree
-                              ? () => setDiffWorkspaceCwd(gitDiffWorkspaceCwd)
+                              ? () =>
+                                  setGitDialog({
+                                    workspaceCwd: gitDiffWorkspaceCwd,
+                                    view: 'diff',
+                                  })
                               : undefined
                           }
                           chatWidthMode={chatWidthMode}
diff --git a/packages/web-shell/client/components/dialogs/GitDialog.module.css b/packages/web-shell/client/components/dialogs/GitDialog.module.css
new file mode 100644
index 00000000000..405e7f52565
--- /dev/null
+++ b/packages/web-shell/client/components/dialogs/GitDialog.module.css
@@ -0,0 +1,41 @@
+.content {
+  display: flex;
+  min-height: 50vh;
+  flex-direction: column;
+}
+
+.tabBar {
+  display: flex;
+  gap: 2px;
+  padding: 0 0 8px;
+  margin-bottom: 8px;
+  border-bottom: 1px solid var(--border);
+}
+
+.tab {
+  padding: 4px 12px;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  color: var(--muted-foreground);
+  cursor: pointer;
+  font: inherit;
+  font-size: 12px;
+}
+
+.tab:hover,
+.tabActive {
+  background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
+}
+
+.tabActive {
+  color: inherit;
+  font-weight: 500;
+}
+
+.tabPanel {
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+}
diff --git a/packages/web-shell/client/components/dialogs/GitDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitDialog.test.tsx
new file mode 100644
index 00000000000..3109bd04d11
--- /dev/null
+++ b/packages/web-shell/client/components/dialogs/GitDialog.test.tsx
@@ -0,0 +1,156 @@
+// @vitest-environment jsdom
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nProvider } from '../../i18n';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+if (!Element.prototype.scrollIntoView) {
+  Element.prototype.scrollIntoView = () => {};
+}
+
+const { workspaceGitDiff, workspaceGitLog, workspaceClient } = vi.hoisted(
+  () => {
+    const workspaceGitDiff = vi.fn();
+    const workspaceGitLog = vi.fn();
+    const workspaceClient = {
+      workspaceByCwd: () => ({
+        workspaceGitDiff,
+        workspaceGitDiffFile: vi.fn(),
+        workspaceGitLog,
+        workspaceGitCommitDetail: vi.fn(),
+      }),
+    };
+    return { workspaceGitDiff, workspaceGitLog, workspaceClient };
+  },
+);
+
+vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => {
+  const actual =
+    await importOriginal();
+  return {
+    ...actual,
+    useWorkspace: () => ({ client: workspaceClient }),
+  };
+});
+
+const { GitDialog } = await import('./GitDialog');
+
+let container: HTMLDivElement;
+let root: Root;
+
+async function flush() {
+  await act(async () => {
+    await new Promise((resolve) => setTimeout(resolve, 0));
+  });
+}
+
+function mount(initialView: 'diff' | 'log' = 'diff') {
+  container = document.createElement('div');
+  document.body.appendChild(container);
+  root = createRoot(container);
+  act(() => {
+    root.render(
+      
+        
+      ,
+    );
+  });
+}
+
+afterEach(() => {
+  act(() => root.unmount());
+  container.remove();
+  vi.clearAllMocks();
+});
+
+describe('GitDialog', () => {
+  it('switches views inside one dialog with complete tab semantics', async () => {
+    workspaceGitDiff.mockResolvedValue({
+      v: 1,
+      workspaceCwd: '/repo',
+      available: true,
+      filesCount: 0,
+      linesAdded: 0,
+      linesRemoved: 0,
+      files: [],
+      hiddenCount: 0,
+    });
+    workspaceGitLog.mockResolvedValue({
+      v: 1,
+      workspaceCwd: '/repo',
+      available: true,
+      entries: [],
+      hasMore: false,
+    });
+    mount();
+    await flush();
+
+    const dialog = document.body.querySelector('[data-web-shell-dialog]');
+    const historyTab = document.getElementById('git-dialog-tab-log');
+    const panel = document.getElementById('git-dialog-panel');
+    expect(dialog).toBeTruthy();
+    expect(historyTab?.getAttribute('aria-selected')).toBe('false');
+    expect(panel?.getAttribute('role')).toBe('tabpanel');
+    expect(panel?.getAttribute('aria-labelledby')).toBe('git-dialog-tab-diff');
+
+    await act(async () => {
+      historyTab?.click();
+    });
+    await flush();
+
+    expect(
+      document.body.querySelectorAll('[data-web-shell-dialog]'),
+    ).toHaveLength(1);
+    expect(historyTab?.getAttribute('aria-selected')).toBe('true');
+    expect(panel?.getAttribute('aria-labelledby')).toBe('git-dialog-tab-log');
+    expect(workspaceGitLog).toHaveBeenCalledWith(50, 0);
+  });
+
+  it('supports arrow-key tab navigation', async () => {
+    workspaceGitDiff.mockResolvedValue({
+      v: 1,
+      workspaceCwd: '/repo',
+      available: true,
+      filesCount: 0,
+      linesAdded: 0,
+      linesRemoved: 0,
+      files: [],
+      hiddenCount: 0,
+    });
+    workspaceGitLog.mockResolvedValue({
+      v: 1,
+      workspaceCwd: '/repo',
+      available: true,
+      entries: [],
+      hasMore: false,
+    });
+    mount();
+    await flush();
+
+    const diffTab = document.getElementById('git-dialog-tab-diff');
+    await act(async () => {
+      diffTab?.dispatchEvent(
+        new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
+      );
+    });
+    await flush();
+
+    expect(
+      document
+        .getElementById('git-dialog-tab-log')
+        ?.getAttribute('aria-selected'),
+    ).toBe('true');
+  });
+});
diff --git a/packages/web-shell/client/components/dialogs/GitDialog.tsx b/packages/web-shell/client/components/dialogs/GitDialog.tsx
new file mode 100644
index 00000000000..8c17123f314
--- /dev/null
+++ b/packages/web-shell/client/components/dialogs/GitDialog.tsx
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useCallback, useState, type KeyboardEvent } from 'react';
+import { useI18n } from '../../i18n';
+import { DialogShell } from './DialogShell';
+import { GitDiffContent } from './GitDiffDialog';
+import { GitLogContent } from './GitLogDialog';
+import styles from './GitDialog.module.css';
+
+export type GitDialogView = 'diff' | 'log';
+
+export function GitDialog({
+  workspaceCwd,
+  initialView,
+  onClose,
+}: {
+  workspaceCwd: string;
+  initialView: GitDialogView;
+  onClose: () => void;
+}) {
+  const { t } = useI18n();
+  const [view, setView] = useState(initialView);
+  const [subtitle, setSubtitle] = useState();
+
+  const selectView = useCallback((next: GitDialogView) => {
+    setSubtitle(undefined);
+    setView(next);
+  }, []);
+
+  const selectAndFocus = (next: GitDialogView) => {
+    selectView(next);
+    document.getElementById(`git-dialog-tab-${next}`)?.focus();
+  };
+
+  const onTabKeyDown = (event: KeyboardEvent) => {
+    if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+      event.preventDefault();
+      selectAndFocus(view === 'diff' ? 'log' : 'diff');
+      return;
+    }
+    if (event.key === 'Home') {
+      event.preventDefault();
+      selectAndFocus('diff');
+      return;
+    }
+    if (event.key === 'End') {
+      event.preventDefault();
+      selectAndFocus('log');
+    }
+  };
+
+  const title = view === 'diff' ? t('gitDiff.title') : t('gitLog.title');
+
+  return (
+    
+      
+
+ + +
+
+ {view === 'diff' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css b/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css index 97e0fb94dad..bdd95644f93 100644 --- a/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.module.css @@ -1,3 +1,9 @@ +.content { + display: flex; + flex-direction: column; + min-height: 50vh; +} + .placeholder { padding: 24px 12px; text-align: center; diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx index 91dbbd57803..0c4c0810179 100644 --- a/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx @@ -357,12 +357,12 @@ function DiffFileRow({ ); } -export function GitDiffDialog({ +export function GitDiffContent({ workspaceCwd, - onClose, + onSubtitleChange, }: { workspaceCwd: string; - onClose: () => void; + onSubtitleChange?: (subtitle: string | undefined) => void; }) { const { t } = useI18n(); const { client } = useWorkspace(); @@ -400,6 +400,10 @@ export function GitDiffDialog({ }) : undefined; + useEffect(() => { + onSubtitleChange?.(subtitle); + }, [onSubtitleChange, subtitle]); + let body: ReactNode; if (loading) { body =
{t('gitDiff.loading')}
; @@ -431,15 +435,25 @@ export function GitDiffDialog({ ); } + return
{body}
; +} + +export function GitDiffDialog({ + workspaceCwd, + onClose, +}: { + workspaceCwd: string; + onClose: () => void; +}) { + const { t } = useI18n(); return ( - {body} + ); } diff --git a/packages/web-shell/client/components/dialogs/GitLogDialog.module.css b/packages/web-shell/client/components/dialogs/GitLogDialog.module.css new file mode 100644 index 00000000000..bf6ae3456ee --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitLogDialog.module.css @@ -0,0 +1,211 @@ +.content { + display: flex; + flex-direction: column; + min-height: 50vh; +} + +.placeholder { + padding: 24px 12px; + text-align: center; + color: var(--muted-foreground); +} + +.commitList { + display: flex; + flex-direction: column; +} + +.commitRow { + border-bottom: 1px solid var(--border); +} + +.commitHeader { + display: flex; + align-items: stretch; + width: 100%; +} + +.commitHeader:hover { + background: var(--subtle-bg, rgba(128, 128, 128, 0.06)); +} + +.commitToggle { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + padding: 8px 4px 8px 10px; + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-align: left; + color: inherit; +} + +.commitSha { + flex-shrink: 0; + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--muted-foreground); +} + +.copyBtn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + align-self: stretch; + padding: 0 10px 0 6px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.commitHeader:hover .copyBtn, +.copyBtn:focus-visible { + opacity: 1; +} + +.copyBtn:hover { + background: var(--muted); +} + +.commitSubject { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commitMeta { + flex-shrink: 0; + font-size: 11px; + color: var(--muted-foreground); + white-space: nowrap; +} + +.commitRefs { + display: inline-flex; + gap: 4px; + flex-shrink: 0; +} + +.refTag { + padding: 0 5px; + border-radius: 4px; + background: var(--muted); + color: var(--muted-foreground); + font-size: 10px; + line-height: 16px; + white-space: nowrap; +} + +.refHead { + background: var(--success-bg, rgba(76, 175, 80, 0.12)); + color: var(--success-color); +} + +.mergeIcon { + flex-shrink: 0; + color: var(--muted-foreground); + font-size: 12px; +} + +.commitDetail { + padding: 8px 10px 12px; + border-top: 1px solid var(--border); + background: var(--subtle-bg, rgba(128, 128, 128, 0.06)); +} + +.commitBody { + margin: 0 0 8px; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + color: var(--muted-foreground); +} + +.fileStats { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fileStatHeader { + font-size: 11px; + color: var(--muted-foreground); + margin-bottom: 4px; +} + +.fileStatRow { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12px; + padding: 1px 0; +} + +.statNums { + display: inline-flex; + gap: 6px; + flex-shrink: 0; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.statAdd { + color: var(--success-color); +} + +.statDel { + color: var(--error-color); +} + +.fileStatPath { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileBinary { + color: var(--muted-foreground); +} + +.hiddenNote { + padding: 4px 0; + color: var(--muted-foreground); + font-size: 11px; +} + +.detailError { + padding: 8px 0; + color: var(--error-color); + font-size: 12px; +} + +.loadMore { + display: block; + width: 100%; + padding: 10px; + margin-top: 4px; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 12px; + color: var(--muted-foreground); + text-align: center; +} + +.loadMore:hover { + background: var(--subtle-bg, rgba(128, 128, 128, 0.06)); +} diff --git a/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx new file mode 100644 index 00000000000..3818a3f701c --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx @@ -0,0 +1,311 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +// A STABLE client object: the dialog's fetch effect depends on `client`, so a +// fresh object per render would re-fire it in a loop. +const { workspaceGitLog, workspaceGitCommitDetail, workspaceClient } = + vi.hoisted(() => { + const workspaceGitLog = vi.fn(); + const workspaceGitCommitDetail = vi.fn(); + const workspaceClient = { + workspaceByCwd: () => ({ workspaceGitLog, workspaceGitCommitDetail }), + }; + return { workspaceGitLog, workspaceGitCommitDetail, workspaceClient }; + }); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useWorkspace: () => ({ client: workspaceClient }), +})); + +const { GitLogDialog } = await import('./GitLogDialog'); + +let container: HTMLDivElement; +let root: Root; + +function mount(workspaceCwd = '/repo', language: 'en' | 'zh-CN' = 'en') { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +let shaSeq = 0; +function entry(overrides: Record = {}) { + shaSeq += 1; + const sha = String(shaSeq).padStart(40, '0'); + return { + sha, + shortSha: sha.slice(0, 7), + authorName: 'Ada', + authorEmail: 'ada@example.com', + authorDate: Math.floor(Date.now() / 1000) - 120, // ~2 minutes ago + subject: `commit ${shaSeq}`, + refs: '', + parents: ['0000000000000000000000000000000000000000'], + ...overrides, + }; +} + +function logPayload(entries: unknown[], hasMore = false, available = true) { + return { v: 1 as const, workspaceCwd: '/repo', available, entries, hasMore }; +} + +describe('GitLogDialog', () => { + it('renders the commit list with author and relative time', async () => { + workspaceGitLog.mockResolvedValue( + logPayload([entry({ subject: 'first change', authorName: 'Ada' })]), + ); + mount(); + await flush(); + + expect(workspaceGitLog).toHaveBeenCalledWith(50, 0); + expect(document.body.textContent).toContain('first change'); + expect(document.body.textContent).toContain('Ada'); + expect(document.body.textContent).toContain('2 minutes ago'); + }); + + it('localizes relative time and the copy action', async () => { + workspaceGitLog.mockResolvedValue(logPayload([entry()])); + mount('/repo', 'zh-CN'); + await flush(); + + expect(document.body.textContent).toContain('2分钟前'); + expect( + document.body.querySelector('button[aria-label^="复制提交"]'), + ).toBeTruthy(); + }); + + it('shows the loading placeholder before the first page resolves', async () => { + workspaceGitLog.mockReturnValue(new Promise(() => {})); // never resolves + mount(); + // No flush — still pending. + expect(document.body.textContent).toContain('Loading history'); + }); + + it('shows the error placeholder when the list fails to load', async () => { + workspaceGitLog.mockRejectedValue(new Error('boom')); + mount(); + await flush(); + expect(document.body.textContent).toContain('Failed to load history'); + }); + + it('shows the unavailable placeholder when git is unavailable', async () => { + workspaceGitLog.mockResolvedValue(logPayload([], false, false)); + mount(); + await flush(); + expect(document.body.textContent).toContain('Git is not available'); + }); + + it('shows the empty placeholder for a repo with no commits', async () => { + workspaceGitLog.mockResolvedValue(logPayload([], false, true)); + mount(); + await flush(); + expect(document.body.textContent).toContain('No commits yet'); + }); + + it('paginates: Load more fetches the next page at the current offset and appends', async () => { + workspaceGitLog + .mockResolvedValueOnce(logPayload([entry({ subject: 'newest' })], true)) + .mockResolvedValueOnce(logPayload([entry({ subject: 'older' })], false)); + mount(); + await flush(); + + const loadMore = Array.from(document.body.querySelectorAll('button')).find( + (b) => b.textContent === 'Load more', + ) as HTMLButtonElement; + expect(loadMore).toBeTruthy(); + await act(async () => { + loadMore.click(); + }); + await flush(); + + // Second call uses the accumulated offset (1 entry already loaded). + expect(workspaceGitLog).toHaveBeenNthCalledWith(2, 50, 1); + expect(document.body.textContent).toContain('newest'); + expect(document.body.textContent).toContain('older'); + }); + + it('deduplicates overlapping pages while advancing the server offset', async () => { + const duplicate = entry({ subject: 'duplicate' }); + workspaceGitLog + .mockResolvedValueOnce(logPayload([duplicate], true)) + .mockResolvedValueOnce( + logPayload([duplicate, entry({ subject: 'older' })], true), + ) + .mockResolvedValueOnce(logPayload([], false)); + mount(); + await flush(); + + const loadMore = () => + Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent === 'Load more', + ) as HTMLButtonElement; + await act(async () => { + loadMore().click(); + }); + await flush(); + await act(async () => { + loadMore().click(); + }); + await flush(); + + expect(workspaceGitLog).toHaveBeenNthCalledWith(2, 50, 1); + expect(workspaceGitLog).toHaveBeenNthCalledWith(3, 50, 3); + expect(document.body.textContent?.match(/duplicate/g)).toHaveLength(1); + expect(document.body.textContent).toContain('older'); + }); + + it('surfaces a load-more failure instead of failing silently', async () => { + workspaceGitLog + .mockResolvedValueOnce(logPayload([entry()], true)) + .mockRejectedValueOnce(new Error('page 2 down')); + mount(); + await flush(); + + const loadMore = Array.from(document.body.querySelectorAll('button')).find( + (b) => b.textContent === 'Load more', + ) as HTMLButtonElement; + await act(async () => { + loadMore.click(); + }); + await flush(); + + expect(document.body.textContent).toContain('Failed to load history'); + }); + + it('expands a commit and loads its detail (body + file stats)', async () => { + const e = entry({ subject: 'expandable' }); + workspaceGitLog.mockResolvedValue(logPayload([e])); + workspaceGitCommitDetail.mockResolvedValue({ + ...e, + available: true, + body: 'the full body', + files: [{ path: 'src/x.ts', added: 4, removed: 2, isBinary: false }], + filesCount: 1, + linesAdded: 4, + linesRemoved: 2, + hiddenCount: 0, + }); + mount(); + await flush(); + + const row = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + await act(async () => { + row.click(); + }); + await flush(); + + expect(workspaceGitCommitDetail).toHaveBeenCalledWith(e.sha); + expect(document.body.textContent).toContain('the full body'); + expect(document.body.textContent).toContain('src/x.ts'); + }); + + it('shows zero-file stats when an empty commit expands', async () => { + const e = entry({ subject: 'empty commit' }); + workspaceGitLog.mockResolvedValue(logPayload([e])); + workspaceGitCommitDetail.mockResolvedValue({ + ...e, + available: true, + body: '', + files: [], + filesCount: 0, + linesAdded: 0, + linesRemoved: 0, + hiddenCount: 0, + }); + mount(); + await flush(); + + const row = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + await act(async () => { + row.click(); + }); + await flush(); + + expect(document.body.textContent).toContain('0 files · +0 −0'); + }); + + it('shows an error when an expanded commit reports available:false', async () => { + const e = entry(); + workspaceGitLog.mockResolvedValue(logPayload([e])); + // Commit force-pushed away between listing and expanding. + workspaceGitCommitDetail.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + available: false, + }); + mount(); + await flush(); + + const row = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + await act(async () => { + row.click(); + }); + await flush(); + + // Without the !available branch this row would be empty; it must show the + // error instead. + expect(document.body.textContent).toContain( + 'Failed to load commit details', + ); + }); + + it('shows an error when the commit-detail fetch rejects', async () => { + const e = entry(); + workspaceGitLog.mockResolvedValue(logPayload([e])); + workspaceGitCommitDetail.mockRejectedValue(new Error('detail down')); + mount(); + await flush(); + + const row = document.body.querySelector( + 'button[aria-expanded="false"]', + ) as HTMLButtonElement; + await act(async () => { + row.click(); + }); + await flush(); + + expect(document.body.textContent).toContain( + 'Failed to load commit details', + ); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/GitLogDialog.tsx b/packages/web-shell/client/components/dialogs/GitLogDialog.tsx new file mode 100644 index 00000000000..66448db87a6 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GitLogDialog.tsx @@ -0,0 +1,372 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import type { + DaemonGitLog, + DaemonGitLogEntry, + DaemonGitCommitDetail, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import { DialogShell } from './DialogShell'; +import styles from './GitLogDialog.module.css'; + +const PAGE_SIZE = 50; + +function timeAgo(timestamp: number, now: number, language: string): string { + const seconds = Math.max(0, Math.floor(now - timestamp)); + const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' }); + if (seconds < 60) return formatter.format(0, 'second'); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return formatter.format(-minutes, 'minute'); + const hours = Math.floor(minutes / 60); + if (hours < 24) return formatter.format(-hours, 'hour'); + const days = Math.floor(hours / 24); + if (days < 7) return formatter.format(-days, 'day'); + const weeks = Math.floor(days / 7); + if (weeks < 5) return formatter.format(-weeks, 'week'); + const months = Math.floor(days / 30); + if (months < 12) return formatter.format(-months, 'month'); + return formatter.format(-Math.max(1, Math.floor(days / 365)), 'year'); +} + +function parseRefs(refs: string): { label: string; isHead: boolean }[] { + if (!refs) return []; + return refs + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + .slice(0, 3) + .map((r) => { + const isHead = r.startsWith('HEAD ->'); + const label = isHead ? r.replace('HEAD -> ', '') : r; + return { label, isHead }; + }); +} + +function CommitRow({ + entry, + workspaceCwd, + now, +}: { + entry: DaemonGitLogEntry; + workspaceCwd: string; + now: number; +}) { + const { client } = useWorkspace(); + const { language, t } = useI18n(); + const [open, setOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [copied, setCopied] = useState(false); + const cancelledRef = useRef(false); + + const copySha = () => { + void navigator.clipboard + .writeText(entry.sha) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }) + .catch(() => {}); + }; + + useEffect(() => { + cancelledRef.current = false; + return () => { + cancelledRef.current = true; + }; + }, []); + + const toggle = () => { + const next = !open; + setOpen(next); + if (next && detail === null && !loading) { + setLoading(true); + setError(false); + client + .workspaceByCwd(workspaceCwd) + .workspaceGitCommitDetail(entry.sha) + .then((result) => { + if (cancelledRef.current) return; + setDetail(result); + }) + .catch(() => { + if (cancelledRef.current) return; + setError(true); + }) + .finally(() => { + if (cancelledRef.current) return; + setLoading(false); + }); + } + }; + + const refs = parseRefs(entry.refs ?? ''); + const isMerge = entry.parents.length > 1; + + let detailBody: ReactNode; + if (open) { + if (loading) { + detailBody = ( +
+ {t('gitLog.loading')} +
+ ); + } else if (error) { + detailBody = ( +
+ {t('gitLog.detailError')} +
+ ); + } else if (detail && detail.available) { + detailBody = ( +
+ {detail.body && ( +
{detail.body}
+ )} + {detail.files && ( +
+
+ {t('gitLog.files', { + count: detail.filesCount ?? 0, + added: detail.linesAdded ?? 0, + removed: detail.linesRemoved ?? 0, + })} +
+ {detail.files.map((f) => ( +
+ {f.isBinary ? ( + ~ + ) : ( + + +{f.added} + −{f.removed} + + )} + {f.path} +
+ ))} + {(detail.hiddenCount ?? 0) > 0 && ( +
+ {t('gitLog.hidden', { count: detail.hiddenCount ?? 0 })} +
+ )} +
+ )} +
+ ); + } else if (detail && !detail.available) { + detailBody = ( +
+ {t('gitLog.detailError')} +
+ ); + } + } + + return ( +
+
+ + +
+ {detailBody} +
+ ); +} + +export function GitLogContent({ + workspaceCwd, + onSubtitleChange, +}: { + workspaceCwd: string; + onSubtitleChange?: (subtitle: string | undefined) => void; +}) { + const { client } = useWorkspace(); + const { t } = useI18n(); + const [log, setLog] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(false); + const [now, setNow] = useState(Date.now() / 1000); + const nextSkipRef = useRef(0); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now() / 1000), 60_000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(false); + setLoadMoreError(false); + nextSkipRef.current = 0; + client + .workspaceByCwd(workspaceCwd) + .workspaceGitLog(PAGE_SIZE, 0) + .then((result) => { + if (!cancelled) { + nextSkipRef.current = result.entries.length; + setLog(result); + } + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, workspaceCwd]); + + const loadMore = useCallback(() => { + if (!log || loadingMore) return; + setLoadingMore(true); + client + .workspaceByCwd(workspaceCwd) + .workspaceGitLog(PAGE_SIZE, nextSkipRef.current) + .then((result) => { + nextSkipRef.current += result.entries.length; + setLog((prev) => { + if (!prev) return result; + const existing = new Set(prev.entries.map((entry) => entry.sha)); + return { + ...prev, + entries: [ + ...prev.entries, + ...result.entries.filter((entry) => !existing.has(entry.sha)), + ], + hasMore: result.hasMore, + }; + }); + }) + .catch(() => { + setLoadMoreError(true); + }) + .finally(() => { + setLoadingMore(false); + }); + }, [client, workspaceCwd, log, loadingMore]); + + const subtitle = log?.available + ? t('gitLog.subtitle', { count: log.entries.length }) + : undefined; + + useEffect(() => { + onSubtitleChange?.(subtitle); + }, [onSubtitleChange, subtitle]); + + let body: ReactNode; + if (loading) { + body =
{t('gitLog.loading')}
; + } else if (error) { + body =
{t('gitLog.error')}
; + } else if (!log || !log.available) { + body =
{t('gitLog.unavailable')}
; + } else if (log.entries.length === 0) { + body =
{t('gitLog.empty')}
; + } else { + body = ( + <> +
+ {log.entries.map((entry) => ( + + ))} +
+ {loadMoreError && ( +
{t('gitLog.error')}
+ )} + {log.hasMore && ( + + )} + + ); + } + + return
{body}
; +} + +export function GitLogDialog({ + workspaceCwd, + onClose, +}: { + workspaceCwd: string; + onClose: () => void; +}) { + const { t } = useI18n(); + return ( + + + + ); +} diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index 909b39f943d..6780e3b990c 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -90,6 +90,7 @@ export function getLocalCommands(t: Translate): CommandInfo[] { argumentHint: '[]', }, { name: 'diff', description: t('local.diff') }, + { name: 'log', description: t('local.log') }, { name: 'fork', description: t('local.fork'), diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index b702b637b59..428fe5987c0 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -49,7 +49,20 @@ const EN: Messages = { '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', + "Changes are made in a separate copy of the repo and won't affect your main branch", + 'gitLog.title': 'History', + 'gitLog.subtitle': (v) => `${v?.count ?? 0} commits`, + 'gitLog.loading': 'Loading history…', + 'gitLog.empty': 'No commits yet', + 'gitLog.unavailable': 'Git is not available for this workspace', + 'gitLog.error': 'Failed to load history', + 'gitLog.loadMore': 'Load more', + 'gitLog.loadingMore': 'Loading…', + 'gitLog.files': (v) => + `${v?.count ?? 0} files · +${v?.added ?? 0} −${v?.removed ?? 0}`, + 'gitLog.detailError': 'Failed to load commit details', + 'gitLog.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`, + 'gitLog.copySha': (v) => `Copy commit ${v?.sha ?? ''}`, 'workspace.paneLabel': (v) => `Workspace: ${v?.name ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', @@ -1106,12 +1119,15 @@ const EN: Messages = { 'No active session yet. Send your first message before using this command.', 'localCommand.diffNoWorkspace': 'No workspace is available yet to show changes for.', + 'localCommand.logNoWorkspace': + 'No workspace is available yet to show history for.', 'local.agents': 'Manage subagents', 'local.bug': 'Submit a bug report', 'local.compress': 'Compress the context into a summary', 'local.compressFast': 'Fast context compression without AI', 'local.config': 'Get or set any setting by dot-path key', 'local.diff': 'Show working-tree change stats versus HEAD', + 'local.log': 'Show commit history for the workspace', 'local.directory': 'Manage workspace directories', 'local.docs': 'Open the full Qwen Code documentation', 'local.doctor': 'Run installation and environment diagnostics', @@ -2186,6 +2202,19 @@ const ZH: Messages = { 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, 'worktree.welcomeTitle': 'Worktree 隔离会话', 'worktree.welcomeDesc': '变更在仓库的独立副本中进行,不会影响主分支', + 'gitLog.title': '提交历史', + 'gitLog.subtitle': (v) => `${v?.count ?? 0} 条提交`, + 'gitLog.loading': '加载历史中…', + 'gitLog.empty': '暂无提交', + 'gitLog.unavailable': '此工作区不可用 Git', + 'gitLog.error': '加载历史失败', + 'gitLog.loadMore': '加载更多', + 'gitLog.loadingMore': '加载中…', + 'gitLog.files': (v) => + `${v?.count ?? 0} 个文件 · +${v?.added ?? 0} −${v?.removed ?? 0}`, + 'gitLog.detailError': '加载提交详情失败', + 'gitLog.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`, + 'gitLog.copySha': (v) => `复制提交 ${v?.sha ?? ''}`, '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 @@ -3238,12 +3267,14 @@ const ZH: Messages = { 'localCommand.noSession': '当前还没有会话。请先发送第一条消息,再使用这个命令。', 'localCommand.diffNoWorkspace': '当前还没有可用于查看变更的工作区。', + 'localCommand.logNoWorkspace': '当前还没有可用于查看历史的工作区。', 'local.agents': '管理智能体', 'local.bug': '提交错误报告', 'local.compress': '将上下文压缩为摘要', 'local.compressFast': '无需 AI 的快速上下文压缩', 'local.config': '通过点分路径键获取或设置配置项', 'local.diff': '显示工作区相对 HEAD 的改动统计', + 'local.log': '显示工作区的提交历史', 'local.directory': '管理工作区目录', 'local.docs': '打开完整的 Qwen Code 文档', 'local.doctor': '运行安装和环境诊断',