From af829f68cf4482ec8b2e6a41a77528c5c1e2e27b Mon Sep 17 00:00:00 2001 From: xjx <2869418079@qq.com> Date: Sun, 20 Sep 2026 23:27:10 +0800 Subject: [PATCH] feat(chat): show the active git branch in the topbar Refresh branch metadata when the window regains focus and support both standard repositories and linked worktrees.\n\nFixes #679 --- apps/desktop/electron/main/git-branch.ts | 36 +++++++++++++ apps/desktop/electron/main/index.ts | 19 +------ .../src/components/ConversationTopbar.tsx | 25 +++++++++ apps/desktop/src/styles/chrome.css | 29 ++++++++++- apps/desktop/test/app-store-sidebar.test.mjs | 15 ++++++ apps/desktop/test/git-branch.test.mjs | 52 +++++++++++++++++++ docs/spec/04-ux/08-component-spec.md | 37 +++++++------ docs/spec/06-delivery/04-e2e-test-plan.md | 20 +++++++ docs/zh-CN/spec/04-ux/08-component-spec.md | 16 +++--- .../spec/06-delivery/04-e2e-test-plan.md | 17 ++++++ 10 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/electron/main/git-branch.ts create mode 100644 apps/desktop/test/git-branch.test.mjs diff --git a/apps/desktop/electron/main/git-branch.ts b/apps/desktop/electron/main/git-branch.ts new file mode 100644 index 0000000000..3a65ec3884 --- /dev/null +++ b/apps/desktop/electron/main/git-branch.ts @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +async function resolveGitDirectory(workspacePath: string): Promise { + const dotGitPath = join(workspacePath, ".git"); + + try { + const pointer = await readFile(dotGitPath, "utf8"); + const match = pointer.match(/^gitdir:\s*(.+)$/m); + if (match?.[1]) { + return resolve(workspacePath, match[1].trim()); + } + } catch { + // Standard repositories store .git as a directory, so reading it fails. + } + + return dotGitPath; +} + +export async function withGitBranch< + T extends { path?: string; name?: string } | null | undefined, +>(workspace: T): Promise { + if (!workspace || !workspace.path) return workspace; + + try { + const gitDirectory = await resolveGitDirectory(workspace.path); + const head = await readFile(join(gitDirectory, "HEAD"), "utf8"); + const match = head.match(/ref:\s*refs\/heads\/(.+)$/m); + return { + ...workspace, + branch: match?.[1]?.trim() || "detached", + }; + } catch { + return { ...workspace, branch: undefined }; + } +} diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 472cc63e71..5dd2d81288 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -52,6 +52,7 @@ import { PersistenceOutbox } from "./persistence-outbox"; import { AgentSidecar } from "./agent-sidecar"; import { Logger, ignoreBrokenStdio } from "./logger"; import { installMainProcessErrorHandlers } from "./main-process-errors"; +import { withGitBranch } from "./git-branch"; import { isDbSchemaTooNewError, } from "./host-boot-diagnostics"; @@ -1059,24 +1060,6 @@ const { isStaleTerminalEvent, } = sessionCoordination; -async function withGitBranch( - workspace: T, -): Promise { - if (!workspace || !workspace.path) return workspace; - try { - const { readFile } = await import("node:fs/promises"); - const { join } = await import("node:path"); - const head = await readFile(join(workspace.path, ".git/HEAD"), "utf8"); - const match = head.match(/ref:\s*refs\/heads\/(.+)$/m); - return { - ...workspace, - branch: match?.[1]?.trim() || "detached", - }; - } catch { - return { ...workspace, branch: undefined }; - } -} - /** * Applies a close-behavior choice. The tray icon is owned by D216 and stays * resident on every platform, so switching to "quit" must not destroy it — diff --git a/apps/desktop/src/components/ConversationTopbar.tsx b/apps/desktop/src/components/ConversationTopbar.tsx index 7790abaed0..f102703d2b 100644 --- a/apps/desktop/src/components/ConversationTopbar.tsx +++ b/apps/desktop/src/components/ConversationTopbar.tsx @@ -1,7 +1,9 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useAppStore } from "../stores/app-store"; import { IconSidebar, + IconBranch, IconNewSession, IconSearch, } from "./icons"; @@ -37,13 +39,26 @@ export function ConversationTopbar({ const activeSessionId = useAppStore((s) => s.activeSessionId); const sessions = useAppStore((s) => s.sessions); const workspace = useAppStore((s) => s.workspace); + const refreshProject = useAppStore((s) => s.refreshProject); const activeSession = sessions.find((session) => session.id === activeSessionId); + const projectPath = workspace?.path; + + useEffect(() => { + if (!projectPath) return; + const refreshBranch = () => { + void refreshProject(projectPath); + }; + refreshBranch(); + window.addEventListener("focus", refreshBranch); + return () => window.removeEventListener("focus", refreshBranch); + }, [activeSessionId, projectPath, refreshProject]); const fullTaskTitle = isDefaultSessionTitle(activeSession?.title) ? t("chat.untitledTask") : activeSession?.title || t("chat.untitledTask"); const project = projectName(workspace?.path, workspace?.name); + const branch = workspace?.branch?.trim(); return (
{fullTaskTitle} + {branch ? ( + + + {branch} + + ) : null}
diff --git a/apps/desktop/src/styles/chrome.css b/apps/desktop/src/styles/chrome.css index ea7e922f9e..5ccd788e9e 100644 --- a/apps/desktop/src/styles/chrome.css +++ b/apps/desktop/src/styles/chrome.css @@ -487,7 +487,7 @@ .conversation-topbar .ct-title-wrap { display: flex; align-items: center; - gap: 0; + gap: 8px; flex: 1 1 auto; min-width: 0; overflow: hidden; @@ -498,7 +498,7 @@ .conversation-topbar .ct-title { min-width: 0; - flex: 1 1 auto; + flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -509,6 +509,31 @@ line-height: var(--leading-compact); } +.conversation-topbar .ct-branch { + display: inline-flex; + height: 20px; + max-width: min(180px, 32vw); + min-width: 0; + flex: 0 1 auto; + align-items: center; + gap: 4px; + overflow: hidden; + border: 1px solid var(--ds-border-subtle); + border-radius: var(--radius-pill); + padding: 0 7px; + background: var(--ds-bg-secondary); + color: var(--ds-text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-compact); +} + +.conversation-topbar .ct-branch > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* One geometry and one seat for every chrome icon control: the topbar's dock toggle, the viewport-fixed work-panel toggle, the preview/route-band lane actions, and the work-panel header's create and maximize controls. The seat is diff --git a/apps/desktop/test/app-store-sidebar.test.mjs b/apps/desktop/test/app-store-sidebar.test.mjs index 59771a1971..aaa0947069 100644 --- a/apps/desktop/test/app-store-sidebar.test.mjs +++ b/apps/desktop/test/app-store-sidebar.test.mjs @@ -17,6 +17,10 @@ const topbarSource = await readFile( new URL("../src/components/ConversationTopbar.tsx", import.meta.url), "utf8", ); +const stylesSource = await readFile( + new URL("../src/styles/chrome.css", import.meta.url), + "utf8", +); test("project activation separates visible transcript state from background run state", () => { const activationBlock = storeSource.match( @@ -97,6 +101,17 @@ test("global search stays on the conversation topbar, not the sidebar header", ( assert.match(topbarSource, /ariaLabel=\{t\("nav\.search"\)\}/); }); +test("conversation topbar shows and refreshes the active project's git branch", () => { + assert.match(topbarSource, /const branch = workspace\?\.branch\?\.trim\(\)/); + assert.match(topbarSource, /refreshProject\(projectPath\)/); + assert.match(topbarSource, /window\.addEventListener\("focus", refreshBranch\)/); + assert.match(topbarSource, /className="ct-branch"/); + assert.match(topbarSource, /hoverCardBranchAria/); + assert.match(topbarSource, / { assert.doesNotMatch(sidebarSource, /sidebar-project-drag-handle/); assert.doesNotMatch(sidebarSource, /IconGripVertical/); diff --git a/apps/desktop/test/git-branch.test.mjs b/apps/desktop/test/git-branch.test.mjs new file mode 100644 index 0000000000..ae7e8cbce5 --- /dev/null +++ b/apps/desktop/test/git-branch.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +const { withGitBranch } = await import("../electron/main/git-branch.ts"); + +test("reads branches from standard repositories and git worktrees", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-git-branch-")); + try { + const standard = join(root, "standard"); + await mkdir(join(standard, ".git"), { recursive: true }); + await writeFile(join(standard, ".git", "HEAD"), "ref: refs/heads/feature/standard\n"); + assert.deepEqual( + await withGitBranch({ path: standard, name: "standard" }), + { path: standard, name: "standard", branch: "feature/standard" }, + ); + + const worktree = join(root, "worktree"); + const metadata = join(root, "metadata"); + await mkdir(worktree); + await mkdir(metadata); + await writeFile(join(worktree, ".git"), "gitdir: ../metadata\n"); + await writeFile(join(metadata, "HEAD"), "ref: refs/heads/fix/worktree\n"); + assert.deepEqual( + await withGitBranch({ path: worktree, name: "worktree" }), + { path: worktree, name: "worktree", branch: "fix/worktree" }, + ); + + await writeFile(join(metadata, "HEAD"), "0123456789abcdef\n"); + assert.equal((await withGitBranch({ path: worktree }))?.branch, "detached"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("keeps non-git workspaces usable without a branch", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-no-git-branch-")); + try { + assert.deepEqual( + await withGitBranch({ path: root, name: "folder" }), + { path: root, name: "folder", branch: undefined }, + ); + assert.equal(await withGitBranch(null), null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index dcbb67c58b..3bd96bf88a 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -224,15 +224,16 @@ See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). ### 2.1 Purpose -Global controls bar: task title and window actions. Project scope remains -available in the title tooltip. The active session's Agent/Plan/Goal control and -model selection belong to the Composer. (Settings is reached from the command -palette / application menu, not the top bar.) +Global controls bar: task title, the active Git branch when available, and +window actions. Project scope remains available in the title tooltip. The active +session's Agent/Plan/Goal control and model selection belong to the Composer. +(Settings is reached from the command palette / application menu, not the top +bar.) ### 2.2 Anatomy ```text -[☰ Sidebar] [Task title] [+ New] [🔍 Search] +[☰ Sidebar] [Task title] [⑂ branch] [+ New] [🔍 Search] ``` (Icons described functionally; actual render uses Lucide SVGs. The `[☰ Sidebar]` @@ -242,10 +243,13 @@ expanded it owns that control, so the top bar does not duplicate it. The does not duplicate it. Keyboard shortcuts and the application menu remain available.) -The conversation top bar renders for the chat route only; Pull requests, Scheduled, -Plugins, and Settings keep the frameless drag band. It owns the task title and -window actions only. Project scope remains in the title tooltip instead of adding -another visible label. The Composer owns the Agent/Plan/Goal control and the +The conversation top bar renders for the chat route only; Pull requests, +Scheduled, Plugins, and Settings keep the frameless drag band. It owns the task +title, a compact Git branch badge for the active project, and window actions. +The full project scope remains in the title tooltip. The branch badge is omitted +for temporary sessions and non-Git folders. It refreshes when a session is +selected and when the window regains focus, so a branch changed outside the app +does not remain stale. The Composer owns the Agent/Plan/Goal control and the combined model × reasoning selection (§11). ### 2.3 Layout @@ -271,15 +275,17 @@ combined model × reasoning selection (§11). overlay hiding tabs or panel actions. Resource close actions stay in their tabs so a second header `×` does not echo the native Windows close control (D357). -- Title cluster (task title) flexes to the remaining width after toolbar - reservations (sidebar lead-in, action icons, work-panel toggle, and - platform window controls). The visible title uses CSS ellipsis only when - that width overflows; the full title remains in the native tooltip. +- Title cluster (task title plus optional branch badge) flexes to the remaining + width after toolbar reservations (sidebar lead-in, action icons, work-panel + toggle, and platform window controls). The visible task title uses CSS + ellipsis only when that width overflows; the full title remains in the native + tooltip. The branch badge keeps its icon and ellipsizes long branch names + within 180px. The right cluster (action icons) is `flex: 0 0 auto` and is never squeezed by a long title. The conversation surface keeps a `min-width` so its content is not crushed on narrow windows. -- Project scope is available from the title tooltip but is not rendered as a - second visible label. +- Project scope is available from the title tooltip. Only its current Git branch + is repeated visibly, as a compact badge rather than a second project label. - macOS fullscreen resets the left reserve to 8px (mirrors the sidebar header). - Sticky: `z-sticky` - Items: left-aligned controls, right-aligned actions @@ -322,6 +328,7 @@ combined model × reasoning selection (§11). | Element | Default | Running | Error | No workspace | |---|---|---|---|---| | Task title | session title (or untitled), uses the available width, with an ellipsis only on overflow | same | same | same | +| Git branch | current active-project branch | same, refreshed on window focus | same | omitted | | New task / Search | icon buttons | same | same | same | | Composer stop control | hidden | visible only when the running composer draft is empty | hidden | hidden | | Project name | title tooltip only | same | same | omitted | diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 1f9a9f6b7a..204cd0f894 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -14129,3 +14129,23 @@ the latest destination. These assertions measure work counts, not device FPS. - **Milestone**: Maintenance. - **Status**: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required. +### E2E-CHAT-active-git-branch + +- **Preconditions:** An isolated Git project (including a linked worktree) with + one project-bound session and one temporary session; no provider credentials. +- **Steps:** Open the project session, switch its branch outside PI-Desktop, + refocus the app, then open the temporary session. Repeat with a long branch + name and a non-Git folder. +- **Expected:** The conversation topbar shows the active project's current + branch beside the task title, refreshes after focus, and ellipsizes long names + without displacing New/Search. Temporary sessions and non-Git folders show no + branch badge. The Composer keeps its existing no-workspace-rail contract. +- **Specs:** 04-ux/08-component-spec.md, Topbar. +- **Acceptance:** The project session exposes enough branch context to avoid + operating in the wrong checkout. +- **Milestone:** Post-MVP maintenance. +- **Automation:** apps/desktop/test/app-store-sidebar.test.mjs covers the + renderer/store contract; apps/desktop/test/git-branch.test.mjs covers standard + repositories, linked worktrees, detached HEAD, and non-Git folders. +- **Status:** Implemented. Native macOS project/worktree badge and truncation + visually qualified; Windows/Linux not qualified. diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index dd447e675c..892c0338dd 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -210,10 +210,11 @@ See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). chrome 搜索入口;展开的侧边栏标题不再重复该控件。键盘快捷键 和应用菜单仍然可用。) -对话顶部栏仅针对聊天路线呈现;拉取请求、已计划、 -插件和设置保留无框拖带。它仅拥有任务标题和窗口操作。 -项目范围仍通过标题工具提示提供,而不是添加另一个可见标签。 -Composer 拥有 Agent/Plan/Goal 控件以及组合的模型 × 推理选择(§11)。 +对话顶部栏仅针对聊天路线呈现;拉取请求、已计划、插件和设置保留无框拖带。 +它包含任务标题、当前项目的紧凑 Git 分支标记和窗口操作。完整项目范围仍通过 +标题工具提示提供。临时会话和非 Git 文件夹不显示分支标记;选择会话以及窗口 +重新获得焦点时会刷新分支,避免应用外切换分支后继续显示旧值。Composer 拥有 +Agent/Plan/Goal 控件以及组合的模型 × 推理选择(§11)。 ### 2.3 布局 @@ -224,12 +225,12 @@ Composer 拥有 Agent/Plan/Goal 控件以及组合的模型 × 推理选择(§ 交互式控件上的 `no-drag`; macOS 仅在侧边栏折叠时保留左侧 88px 的交通 灯空间,Windows/Linux保留权利 本机窗口控件为 112px -- 标题簇(任务标题)在预留侧边栏引导、操作图标、工作面板开关和平台窗口控件之后,占用剩余宽度。可见标题仅在该宽度溢出时使用 CSS 省略号;完整标题保留在本机工具提示中。 +- 标题簇包含任务标题和可选分支标记,在预留侧边栏引导、操作图标、工作面板开关和平台窗口控件之后,占用剩余宽度。可见任务标题仅在该宽度溢出时使用 CSS 省略号;完整标题保留在本机工具提示中。长分支名在 180px 内省略。 右侧集群(操作图标)是 `flex: 0 0 auto` 并且永远不会被长标题所挤压。对话表面保持 `min-width` 因此其内容不会在狭窄的窗口上被压垮。 -- 项目范围可从标题工具提示中获取,但不会呈现为 - 第二个可见标签。 +- 项目范围可从标题工具提示中获取;只有当前 Git 分支会以紧凑标记重复显示, + 不额外显示第二个项目标签。 - macOS 全屏将左侧保留重置为 8px(镜像侧边栏标题)。 - 置顶:`z-sticky` - 项目:左对齐控件、右对齐操作 @@ -258,6 +259,7 @@ Composer 拥有 Agent/Plan/Goal 控件以及组合的模型 × 推理选择(§ | 元素 | 默认 | 跑步 | 错误 | 没有工作空间 | |---|---|---|---|---| | 任务标题 | 会话标题(或无标题),使用可用宽度,仅在溢出时显示省略号 | 相同,加上一个紧凑的脉冲状态点 | 一样 | 一样 | +| Git 分支 | 当前活动项目分支 | 相同,窗口聚焦时刷新 | 一样 | 省略 | | 新任务/搜索 | 图标按钮 | 一样 | 一样 | 一样 | | Composer 停止控件 | 隐藏的 | 仅在运行中的草稿为空时可见 | 隐藏的 | 隐藏的 | | 项目名称 | 仅标题工具提示 | 一样 | 一样 | 省略 | diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 94a4a91e01..7033243b9e 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8349,3 +8349,20 @@ the latest destination. These assertions measure work counts, not device FPS. - **自动化:** `apps/desktop/test/plugin-services.test.mjs` 真实 fork 宿主进程、以夹具退出码杀死它,并断言服务状态与审计记录上的 退出码与 stderr 行;`plugin-isolation.test.mjs` 与关闭用例覆盖"退出不是崩溃"那一半。 - **状态:** 运行时层已自动化;无 UI 驱动读取插件页的错误文本。 +### E2E-CHAT-active-git-branch + +- **前提:** 独立 Git 项目(包括 linked worktree)中有一个项目会话和一个临时 + 会话,不使用提供商凭据。 +- **步骤:** 打开项目会话,在 PI-Desktop 外切换 Git 分支,重新聚焦应用,再打开 + 临时会话;使用长分支名和非 Git 文件夹重复验证。 +- **预期:** 对话顶部栏在任务标题旁显示活动项目的当前分支,窗口重新聚焦后更新; + 长名称会省略且不挤走新建和搜索按钮。临时会话及非 Git 文件夹不显示分支标记。 + Composer 继续保持无工作区信息栏的现有契约。 +- **规格:** 04-ux/08-component-spec.md 的顶部栏章节。 +- **验收:** 项目会话提供足够的分支上下文,降低在错误检出目录中操作的风险。 +- **阶段:** 发布后维护。 +- **自动化:** apps/desktop/test/app-store-sidebar.test.mjs 覆盖渲染器与状态契约; + apps/desktop/test/git-branch.test.mjs 覆盖普通仓库、linked worktree、detached HEAD + 和非 Git 文件夹。 +- **状态:** 已实现;原生 macOS 已验证项目/worktree 分支标记及长名称省略, + Windows/Linux 尚未实机验证。