Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/desktop/electron/main/git-branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";

async function resolveGitDirectory(workspacePath: string): Promise<string> {
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<T> {
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 };
}
}
19 changes: 1 addition & 18 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1059,24 +1060,6 @@ const {
isStaleTerminalEvent,
} = sessionCoordination;

async function withGitBranch<T extends { path?: string; name?: string } | null | undefined>(
workspace: T,
): Promise<T> {
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 —
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/components/ConversationTopbar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -76,6 +91,16 @@ export function ConversationTopbar({
title={project ? `${project} · ${fullTaskTitle}` : fullTaskTitle}
>
<span className="ct-title">{fullTaskTitle}</span>
{branch ? (
<span
className="ct-branch"
aria-label={t("nav.hoverCardBranchAria", { name: branch })}
title={branch}
>
<IconBranch size={12} aria-hidden />
<span>{branch}</span>
</span>
) : null}
</div>
</div>

Expand Down
29 changes: 27 additions & 2 deletions apps/desktop/src/styles/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/test/app-store-sidebar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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, /<IconBranch/);
assert.match(stylesSource, /\.conversation-topbar \.ct-branch\s*\{/);
assert.match(stylesSource, /text-overflow:\s*ellipsis/);
});

test("project rows expose press-and-move title drag and keyboard reorder behavior", () => {
assert.doesNotMatch(sidebarSource, /sidebar-project-drag-handle/);
assert.doesNotMatch(sidebarSource, /IconGripVertical/);
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/test/git-branch.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
37 changes: 22 additions & 15 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions docs/zh-CN/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 布局

Expand All @@ -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`
- 项目:左对齐控件、右对齐操作
Expand Down Expand Up @@ -258,6 +259,7 @@ Composer 拥有 Agent/Plan/Goal 控件以及组合的模型 × 推理选择(§
| 元素 | 默认 | 跑步 | 错误 | 没有工作空间 |
|---|---|---|---|---|
| 任务标题 | 会话标题(或无标题),使用可用宽度,仅在溢出时显示省略号 | 相同,加上一个紧凑的脉冲状态点 | 一样 | 一样 |
| Git 分支 | 当前活动项目分支 | 相同,窗口聚焦时刷新 | 一样 | 省略 |
| 新任务/搜索 | 图标按钮 | 一样 | 一样 | 一样 |
| Composer 停止控件 | 隐藏的 | 仅在运行中的草稿为空时可见 | 隐藏的 | 隐藏的 |
| 项目名称 | 仅标题工具提示 | 一样 | 一样 | 省略 |
Expand Down
Loading
Loading