Skip to content

Commit b66257c

Browse files
fengzhi09s39-dev
andauthored
feat(webui): the title bar says which checkout you are looking at (#130)
Branch, short sha, and when it landed — a left pair with the title: the title answers "what am I reading", this answers "which build". headSha uses git's own abbreviation rather than a fixed seven, so a large repository lengthens it instead of truncating away the digits you would paste into git show. headCommittedAt is the committer time, not the author time: rebase, amend and cherry-pick push the committer time to now while the author time stays at the first write, so an author timestamp would show a just-rebased branch as months old. No polling. One call is one real index refresh, and a checkout's identity changes on commit or branch switch rather than on a timer; the relative-time half rides the ticker the title bar already had. Nothing renders for a non-git directory, an unborn HEAD, or a failed request — an empty pill is a control that looks useful and says nothing. Co-authored-by: s39-dev <s39-dev@local>
1 parent 80c6007 commit b66257c

17 files changed

Lines changed: 1181 additions & 34 deletions

File tree

‎docs/webui.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,52 @@ batch. The Agent Team badge, the `workspaceDir` secondary line and the
438438
reference's own shell, so they are not implemented here either. A later
439439
agent must not mistake any of these for "implemented but broken".
440440

441+
## Conversation toolbar: the version badge (webui-parity 89)
442+
443+
The conversation toolbar's right end of the title row carries a version badge:
444+
the branch name, the abbreviated commit id, and how long ago that commit
445+
landed. It answers "which checkout am I looking at" without opening a terminal,
446+
which is the question a user has when a build behaves unexpectedly and there
447+
is more than one checkout in play.
448+
449+
| State | What renders |
450+
| --- | --- |
451+
| Repository with at least one commit | `branch` + `headSha` + relative commit time |
452+
| Directory that is not a repository | nothing — the whole element is absent from the DOM |
453+
| Repository with an unborn HEAD (`git init`, nothing committed) | nothing; there is no commit to name |
454+
| Detached HEAD | the sha alone, with no placeholder word standing in for a branch |
455+
| Request failed or has not answered yet | nothing |
456+
457+
The absent cases are the contract, not an afterthought: an empty pill would be
458+
a control that looks live and carries no information, so
459+
`resolveVersionBadge` returns `null` and the component renders an empty
460+
string. `webapp/test/toolbar-version-badge.test.ts` pins the rendered output
461+
in both directions.
462+
463+
**Placement.** The badge sits at the far end of the title row (`ml-auto`),
464+
opposite the session title it qualifies, and before that row's `pr-20` reserve
465+
— so the `fixed right-4` launcher cluster can never overlap it. It follows
466+
the running-turn indicator when one is showing. The relative time is the only
467+
part with a narrow-width rule (`hidden lg:inline`): the branch name and the
468+
sha are what identify a build, so they stay and the time gives way. Long
469+
branch names truncate rather than pushing the bar wider.
470+
471+
**The click copies the short sha.** It is a real `<button>` with an
472+
`aria-label` and a transient 「已复制」 confirmation, not decorative text. A
473+
denied clipboard shows no confirmation rather than a confirmation the user
474+
acts on.
475+
476+
**Data and cost.** One `GET /api/git/status` on workspace change — the same
477+
endpoint the right-panel Git panel reads, not a second source of truth. It
478+
does not poll: a `git status` on a large tree is a real index refresh, a
479+
version identity changes when the user commits or checks out a branch rather
480+
than on a schedule, and the Git panel already sets the precedent of fetching
481+
on workspace change plus an explicit Refresh. The relative-time half needs no
482+
refetch at all — it ticks off the toolbar's existing 1s ticker, which the
483+
elapsed-timer already pays for. While the Git panel is open, two requests for
484+
the endpoint are in flight; that is accepted rather than hoisting panel state
485+
into a provider above the shell for a panel the badge does not render.
486+
441487
## Context window (what the picker shows, and what a pick does today)
442488

443489
The model picker's settings detail renders at **two levels** (ticket 49 batch 2). The panel-bottom area always describes the ACTIVE model; when a provider cascade is open, the fly-out renders as the reference picker's two-column popover — the provider's model rows on the left, a **follow-focus settings column** on the right. Hovering or keyboard-focusing a model row switches that column to the model without picking it; a cascade that just opened (nothing focused yet) falls back to the active model, mirroring the reference. Both areas read the same draft mirror (below), so a window pick made in either place highlights in both.
@@ -475,6 +521,7 @@ below cites the component file and one `data-testid` per surface.
475521
| Home quick-capability capsules (ticket 55c) | `components/chat.tsx#HomeState` | `home-quick-capabilities` |
476522
| Sidebar inbox (alerts flyout) | `components/inbox.tsx` | `inbox-flyout` |
477523
| Toolbar (top bar with model selector) | `components/toolbar.tsx` | `toolbar-session-status` |
524+
| Toolbar version badge (branch + short sha + commit time, webui-parity 89) | `components/version-badge.tsx` | `toolbar-version-badge` |
478525
| Composer + drop overlay | `components/composer.tsx` | `composer-drop-overlay`, `composer-send-button` |
479526
| Chat (virtual list ≥ 200 messages) | `components/chat.tsx` + `chat-virtual-list.tsx` | `chat-virtual-top-spacer` |
480527
| Turn process bar (composite summary + output rate since ticket 46 PR3) | `components/activity-group.tsx#TurnProcessDisclosure` | `turn-process-disclosure` |

‎docs/webui.zh-CN.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,26 @@ ACP 握手是双向的,两个方向都由同一份 `initialize` 载荷决定
320320

321321
参照实现的右键上下文菜单(置顶 / 归档 / 复制为新会话 / 删除)、置顶与归档的本地 overlay 排序、Agent Team 徽标与工作区目录副行、「最近任务」小节——**本轮均未实现**。前两项依赖服务端契约(置顶、归档、复制均无端点),需要与后续工单统一排期;后两项在参照自己的壳里也没有渲染点。后续 agent 不应把它们误判为「已实现但坏了」。
322322

323+
## 会话标题栏的版本标识(webui-parity 89)
324+
325+
会话标题栏这一行的右端有一个版本标识:**分支名、commit 短编号、这次提交距今多久**。它的用处是不开终端就能回答「我现在看的是哪个检出」——当构建表现异常而手上有不止一个检出时,这正是要问的问题。
326+
327+
| 状态 | 渲染结果 |
328+
| --- | --- |
329+
| 至少有一次提交的仓库 | `分支 + 短编号 + 相对提交时间` |
330+
| 不是仓库的目录 | **什么都不渲染**,整个元素不在 DOM 里 |
331+
| unborn HEAD 的仓库(`git init` 后没有提交) | 什么都不渲染——没有可指名的提交 |
332+
| detached HEAD | 只显示短编号,不用占位词假装有分支 |
333+
| 请求失败或尚未返回 | 什么都不渲染 |
334+
335+
「不渲染」的那些情况才是契约本身,不是事后补丁:一个空壳胶囊是一个看起来能用、实际什么都不说的控件。所以 `resolveVersionBadge` 返回 `null`,组件渲染出空字符串,`webapp/test/toolbar-version-badge.test.ts` 双向钉住了渲染结果。
336+
337+
**放在哪。** 标识落在标题行的最右端(`ml-auto`),与它所限定的会话标题分居一行两端,并且在该行 `pr-20` 预留位**之前**,所以 `fixed right-4` 的启动器按钮组永远不会压到它。正在运行时它排在运行状态指示之后。窄屏下只有相对时间有专门规则(`hidden lg:inline`):分支名和短编号才是标识一个构建的东西,所以留下它们、时间让位;过长的分支名截断,而不是把标题栏撑宽。
338+
339+
**点击复制短编号。** 它是一个真的 `<button>`,带 `aria-label` 和一闪而过的「已复制」提示,不是装饰性文字。剪贴板被拒绝时不显示提示,而不是给一个用户会照着信的假确认。
340+
341+
**数据与代价。** 工作区变化时读一次 `GET /api/git/status`——和右栏 Git 面板读的是同一个端点,不是第二份真相源。它**不轮询**:大仓库上一次 `git status` 是一次真实的索引刷新,版本身份是在用户提交或切分支时变化的而不是按时间表变化,Git 面板也已经定下了「工作区变化 + 显式刷新」的先例。相对时间那半完全不需要重新请求——它挂在标题栏本来就有的 1 秒计时器上,而那个计时器本来就在为耗时计时付费。Git 面板打开时会有两个请求同时在飞;这是被接受的,而不是为了一个自己并不渲染的面板把面板状态提升到壳之上的 provider 里。
342+
323343
## 上下文窗口(模型选择器里能调什么、切了之后发生什么)
324344

325345
模型选择器里的设置详情有**两级**。面板底部有一个详情区,**恒显示当前模型**的设置(等级徽标 + 上下文窗口单选组);打开某个供应商的级联时,浮层变成参照式的**两列**:左列是该供应商的模型行,右列是一个**跟随聚焦行**的设置列——鼠标悬停或键盘聚焦到某个模型行时,设置列立即切到那个模型,不必点选;级联刚打开、还没有聚焦任何行时,设置列回退显示当前模型。两处详情读同一份草稿镜像(见下),在任何一处点窗口档位,两处的高亮同步移动。
@@ -361,6 +381,7 @@ ACP 握手是双向的,两个方向都由同一份 `initialize` 载荷决定
361381
| 主页快捷能力胶囊(工单 55c) | `components/chat.tsx#HomeState` | `home-quick-capabilities` |
362382
| 侧栏 inbox(告警浮层) | `components/inbox.tsx` | `inbox-flyout` |
363383
| 顶栏(带模型选择器) | `components/toolbar.tsx` | `toolbar-session-status` |
384+
| 顶栏版本标识(分支 + 短编号 + 提交时间,webui-parity 89) | `components/version-badge.tsx` | `toolbar-version-badge` |
364385
| 录入区与拖放浮层 | `components/composer.tsx` | `composer-drop-overlay`、`composer-send-button` |
365386
| 对话(≥ 200 条时虚拟滚动) | `components/chat.tsx` + `chat-virtual-list.tsx` | `chat-virtual-top-spacer` |
366387
| 轮次耗时条(工单 46 PR3 起复合文案与输出速度) | `components/activity-group.tsx#TurnProcessDisclosure` | `turn-process-disclosure` |

‎packages/webui/docs/API.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,13 +1068,17 @@ walker runs.
10681068

10691069
### `GET /api/git/status?dir=<workspace>`
10701070

1071-
Workspace status for the panel header. `dir` is required.
1071+
Workspace status for the panel header and the conversation toolbar's
1072+
version badge. `dir` is required.
10721073

10731074
`status --porcelain=v1 -b` gives a deterministic stream: one header
10741075
line (`## <branch>[...<upstream>] [ahead N, behind M]`) followed by
10751076
the per-file entries. The route parses both halves; a detached HEAD
10761077
or a branch with no upstream simply produces a `null` upstream /
1077-
zero ahead/behind without an error.
1078+
zero ahead/behind without an error. A second `git log -1` runs
1079+
concurrently against the same already-gated `dir` for the two HEAD
1080+
identity fields below, so the route still costs one `git` round trip
1081+
in wall-clock terms.
10781082

10791083
**Response 200**
10801084
```json
@@ -1085,6 +1089,8 @@ zero ahead/behind without an error.
10851089
"upstream": "origin/feat/git-panel",
10861090
"ahead": 0,
10871091
"behind": 0,
1092+
"headSha": "0e99b45",
1093+
"headCommittedAt": "2026-10-01T09:12:33+08:00",
10881094
"files": [
10891095
{ "x": "M", "y": " ", "path": "README.md", "origPath": null, "staged": true },
10901096
{ "x": "?", "y": "?", "path": "untracked.txt", "origPath": null, "staged": false }
@@ -1098,6 +1104,22 @@ zero ahead/behind without an error.
10981104
carry `origPath` (the pre-rename path) alongside `path` (the new
10991105
path). `isRepo:false` answers a non-git directory without an error.
11001106

1107+
| Field | Meaning |
1108+
| --- | --- |
1109+
| `headSha` | `git log -1 --format=%h` — the abbreviated commit id at **git's own** abbreviation length (7 by default, longer where 7 would be ambiguous). Consumers must not hard-code 7. |
1110+
| `headCommittedAt` | `git log -1 --format=%cI` — HEAD's **committer** time, strict ISO 8601. Committer, not author: a rebase, amend or cherry-pick moves the committer time forward while the author time stays at the original write, so the author time would report a freshly rebased branch as months old. |
1111+
1112+
A repository with an unborn HEAD (`git init`, nothing committed yet)
1113+
answers `ok:true, isRepo:true` with `headSha: null` and
1114+
`headCommittedAt: null` — the workspace is a healthy repository, it
1115+
simply has no commit to name. A directory that is not a repository at
1116+
all omits both fields entirely, and so does a `dir` the containment
1117+
gate refuses, which lets the toolbar's version badge treat "absent or
1118+
null" as a single "render nothing" case.
1119+
1120+
A detached HEAD still reports `headSha`; `branch` is `null` and the
1121+
badge shows the sha alone.
1122+
11011123
**Errors** — 400 missing `dir`; the body is `{ok:false, error}` and
11021124
the status stays `200` (the panel reads `ok` rather than the HTTP
11031125
code, so a non-git directory is a normal state).

‎packages/webui/docs/API.zh-CN.md‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,12 +1008,15 @@ git 端点驱动右栏 Git 面板(slice 03 ——
10081008

10091009
### `GET /api/git/status?dir=<workspace>`
10101010

1011-
面板头部用的工作区状态。`dir` 必填。
1011+
面板头部与会话标题栏版本标识用的工作区状态。`dir` 必填。
10121012

10131013
`status --porcelain=v1 -b` 给出确定性输出:一行头部
10141014
(`## <branch>[...<upstream>] [ahead N, behind M]`),随后是逐
10151015
文件条目。路由解析两半;detached HEAD 或没有 upstream 的分支
1016-
只是得到 `null` upstream 与 0 ahead/behind,不算错误。
1016+
只是得到 `null` upstream 与 0 ahead/behind,不算错误。下面两个
1017+
HEAD 身份字段由第二次 `git log -1` 提供,它与上面这次查询并发
1018+
执行、且跑在同一个已过闸的 `dir` 上,因此整条路由的墙钟耗时
1019+
仍是一次 git 往返。
10171020

10181021
**响应 200**
10191022
```json
@@ -1024,6 +1027,8 @@ git 端点驱动右栏 Git 面板(slice 03 ——
10241027
"upstream": "origin/feat/git-panel",
10251028
"ahead": 0,
10261029
"behind": 0,
1030+
"headSha": "0e99b45",
1031+
"headCommittedAt": "2026-10-01T09:12:33+08:00",
10271032
"files": [
10281033
{ "x": "M", "y": " ", "path": "README.md", "origPath": null, "staged": true },
10291034
{ "x": "?", "y": "?", "path": "untracked.txt", "origPath": null, "staged": false }
@@ -1037,6 +1042,21 @@ git 端点驱动右栏 Git 面板(slice 03 ——
10371042
`origPath`(改名前路径)与 `path`(改名后路径)。
10381043
`isRepo:false` 是对非 git 目录的无错应答。
10391044

1045+
| 字段 | 含义 |
1046+
| --- | --- |
1047+
| `headSha` | `git log -1 --format=%h` —— 按 **git 自己的** 缩写长度给出的短编号(默认 7 位,7 位会歧义时更长)。消费方不得写死 7。 |
1048+
| `headCommittedAt` | `git log -1 --format=%cI` —— HEAD 的**提交者**时间,严格 ISO 8601。取提交者时间而非作者时间:rebase、amend、cherry-pick 都会把提交者时间推后而作者时间停在最初那次写入,用作者时间会把刚 rebase 过的分支显示成几个月前。 |
1049+
1050+
unborn HEAD(`git init` 后还没有任何提交)的仓库返回
1051+
`ok:true, isRepo:true` 加 `headSha: null` 与 `headCommittedAt: null`
1052+
—— 工作区是一个健康的仓库,只是还没有可指名的提交。根本不是
1053+
仓库的目录两个字段都不出现,被越权门拒绝的 `dir` 同样不出现,
1054+
所以标题栏的版本标识可以把「缺失」与「为 null」当成同一个
1055+
「什么都不渲染」。
1056+
1057+
detached HEAD 仍然返回 `headSha`;此时 `branch` 为 `null`,标识
1058+
只显示短编号。
1059+
10401060
**错误** —— 400:缺 `dir`;请求体是 `{ok:false, error}`,而 HTTP
10411061
状态**保持 200**(面板读 `ok` 而不是 HTTP 码,所以非 git 目录
10421062
是一个正常状态)。

‎packages/webui/server/lib/git.js‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// server/lib/git.js — git CLI wrapper (zero npm deps, uses the OS `git` binary).
22
//
33
// Used by the right-panel Git panel (slice 03 of webui-parity): workspace
4-
// status (porcelain v1 + branch / upstream), local-branch list,
4+
// status (porcelain v1 + branch / upstream + the HEAD sha / commit time
5+
// the conversation toolbar's version badge renders), local-branch list,
56
// single-file diff against HEAD with a no-index fallback for untracked
67
// files, and a destructive branch-switch gated by a local-branch
78
// allow-list.
@@ -231,7 +232,48 @@ function gateFile(dir, file) {
231232
return null
232233
}
233234

234-
// Workspace status: branch + upstream + ahead/behind + changed files.
235+
// HEAD's identity: the abbreviated commit id and the moment the
236+
// commit landed in THIS working tree. The conversation toolbar's
237+
// version badge renders these so a user can tell which checkout they
238+
// are looking at without opening a terminal.
239+
//
240+
// - `headSha` is `git log -1 --format=%h`, i.e. git's own
241+
// abbreviation (`core.abbrev`, which auto-scales with repository
242+
// size) rather than a hard-coded 7 — a 7-char cut is ambiguous in
243+
// a large repository, and the user is copying this string to paste
244+
// into `git show`.
245+
// - `headCommittedAt` is `%cI`, the **committer** time in strict
246+
// ISO 8601. Author time is the wrong half for this question: a
247+
// rebase, amend or cherry-pick moves the committer time forward
248+
// while the author time stays at the original write, so an
249+
// author-time badge would report a freshly rebased branch as
250+
// months old. The committer time is "when did this exact content
251+
// enter this tree", which is what "which version am I running"
252+
// means.
253+
//
254+
// Both fields are `null` for a repository with no commits yet (an
255+
// unborn HEAD). A brand-new `git init` is a normal state for a user
256+
// who has not committed, so it is answered with empty values rather
257+
// than an error: the badge simply does not render.
258+
//
259+
// Runs through the same `run()` execFile boundary as everything else
260+
// in this module and only ever against the already-gated `abs` dir —
261+
// no shell, and no second containment decision to keep in sync.
262+
async function readHeadIdentity(abs) {
263+
const res = await run(abs, ['log', '-1', '--format=%h%x00%cI'])
264+
if (!res.ok) return { headSha: null, headCommittedAt: null }
265+
// NUL is the only byte that cannot appear in either field, so the
266+
// split is unambiguous even for an unusual commit message or a
267+
// timezone the local libc renders unusually.
268+
const [headSha, headCommittedAt] = res.stdout.trim().split('\0')
269+
return {
270+
headSha: headSha || null,
271+
headCommittedAt: headCommittedAt || null,
272+
}
273+
}
274+
275+
// Workspace status: branch + upstream + ahead/behind + HEAD identity
276+
// + changed files.
235277
// `git status --porcelain=v1 -b` gives a single deterministic stream
236278
// (one header line `## <branch>[...<upstream>] [ahead N, behind M]`
237279
// followed by the per-file entries). Not-a-git-repo is not an error:
@@ -244,6 +286,10 @@ export async function gitStatus(dir) {
244286
const notRepo = /not a git repository|不是 git 仓库/i.test(res.error || '')
245287
return { ok: false, isRepo: !notRepo, error: notRepo ? '不是 git 仓库' : res.error }
246288
}
289+
// The HEAD probe is a second `git` process, so it runs concurrently
290+
// with the status call rather than after it — the wall clock of a
291+
// `/api/git/status` is still one `git` round trip, not two.
292+
const headPromise = readHeadIdentity(abs)
247293
const lines = res.stdout.split('\n').filter((l) => l !== '')
248294
let branch = null
249295
let upstream = null
@@ -253,6 +299,16 @@ export async function gitStatus(dir) {
253299
for (const line of lines) {
254300
if (line.startsWith('## ')) {
255301
const head = line.slice(3)
302+
// An unborn HEAD (a fresh `git init` with nothing committed yet)
303+
// emits `## No commits yet on <branch>`. The generic regex below
304+
// would read the word "No" as the branch name, so this prefix is
305+
// peeled off first — the version badge would otherwise print a
306+
// branch called "No".
307+
const unborn = /^No commits yet on (.+)$/.exec(head)
308+
if (unborn) {
309+
branch = unborn[1] || null
310+
continue
311+
}
256312
// Branch header regex (deliberately permissive — see pr-22 § gitStatus):
257313
// <localBranch>[...<upstream>] [ahead N, behind M]
258314
// Both halves are optional (detached HEAD, brand-new branch with no
@@ -277,7 +333,7 @@ export async function gitStatus(dir) {
277333
}
278334
files.push({ x, y, path, origPath, staged: x !== ' ' && x !== '?' })
279335
}
280-
return { ok: true, isRepo: true, branch, upstream, ahead, behind, files }
336+
return { ok: true, isRepo: true, branch, upstream, ahead, behind, ...(await headPromise), files }
281337
}
282338

283339
// Local branch list + current marker. We deliberately do NOT use

0 commit comments

Comments
 (0)