diff --git a/apps/desktop/src/main/__tests__/composer-git-branch.test.ts b/apps/desktop/src/main/__tests__/composer-git-branch.test.ts
new file mode 100644
index 0000000000..661f793c7b
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/composer-git-branch.test.ts
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { test, type TestContext } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { act, createElement, type ReactElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { GitBranchReadResult } from '@maka/core/git-review';
+import {
+ createFakeWorkbarServices,
+ useComposerGitBranch,
+ WorkbarServicesProvider,
+ type ComposerGitBranch,
+ type WorkbarReviewService,
+ type WorkbarServices,
+} from '../../renderer/features/workbar/testing.js';
+
+function installRenderer(t: TestContext) {
+ const original = {
+ document: globalThis.document,
+ window: globalThis.window,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+ }).IS_REACT_ACT_ENVIRONMENT,
+ };
+ const { document, window } = parseHTML('
');
+ window.getComputedStyle = () =>
+ ({ direction: 'ltr', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration;
+ Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
+ t.after(() => {
+ Object.assign(globalThis, original);
+ });
+ return document;
+}
+
+/** A review port whose `branch()` answers from a mutable value, counting calls. */
+function reviewPort(answer: () => GitBranchReadResult): {
+ service: WorkbarReviewService;
+ reads: () => number;
+} {
+ let calls = 0;
+ return {
+ reads: () => calls,
+ service: {
+ read: async () => {
+ throw new Error('not used');
+ },
+ branch: async () => {
+ calls += 1;
+ return answer();
+ },
+ subscribeSessionEvents: () => () => undefined,
+ },
+ };
+}
+
+function servicesWithReview(
+ review: WorkbarReviewService,
+ pty: { emit(sessionId: string): void } = { emit: () => undefined },
+): WorkbarServices {
+ // The fake base satisfies every other port; `review` and the PTY stream are
+ // the two this hook consumes.
+ return createFakeWorkbarServices({
+ review,
+ terminal: {
+ ...createFakeWorkbarServices().terminal,
+ subscribePtyData: (handler) => {
+ pty.emit = (sessionId) => handler({ sessionId, ref: 'r', sequence: 0, data: 'x' });
+ return () => undefined;
+ },
+ },
+ });
+}
+
+test('useComposerGitBranch re-reads on focus and visibility, and follows a branch change', async (t) => {
+ const document = installRenderer(t);
+ let current: GitBranchReadResult = {
+ ok: true,
+ snapshot: { branch: 'feature/old', shortSha: null },
+ };
+ const port = reviewPort(() => current);
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+
+ let observed: ComposerGitBranch | undefined;
+ function Probe(): ReactElement {
+ observed = useComposerGitBranch('session-1');
+ return createElement('span', null, observed?.name ?? observed?.shortSha ?? '');
+ }
+
+ await act(() =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services: servicesWithReview(port.service),
+ children: createElement(Probe),
+ }),
+ ),
+ );
+ assert.equal(observed?.name, 'feature/old', 'the first read must land');
+ assert.equal(port.reads(), 1);
+
+ // The branch changes under the app (e.g. the integrated terminal), and the
+ // user returns to the window — the chip must follow, not stay silently stale.
+ current = { ok: true, snapshot: { branch: 'feature/new', shortSha: null } };
+ await act(() => {
+ window.dispatchEvent(new window.Event('focus'));
+ });
+ await act(async () => {});
+ assert.equal(observed?.name, 'feature/new', 'a focus must re-read the branch');
+ assert.equal(port.reads(), 2);
+
+ // A detached HEAD after a checkout is reported as the short sha.
+ current = { ok: true, snapshot: { branch: null, shortSha: 'abc1234' } };
+ await act(() => {
+ document.dispatchEvent(new window.Event('visibilitychange'));
+ });
+ await act(async () => {});
+ assert.equal(observed?.shortSha, 'abc1234', 'a visibility change must re-read too');
+
+ await act(() => root.unmount());
+});
+
+test('useComposerGitBranch renders nothing (undefined), never an empty chip', async (t) => {
+ const document = installRenderer(t);
+ const port = reviewPort(() => ({ ok: false, isGitRepo: false }));
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+
+ let observed: ComposerGitBranch | undefined | null = null;
+ function Probe(): ReactElement {
+ observed = useComposerGitBranch('session-1');
+ return createElement('span');
+ }
+ await act(() =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services: servicesWithReview(port.service),
+ children: createElement(Probe),
+ }),
+ ),
+ );
+ assert.equal(observed, undefined, 'a non-repository must yield undefined, not a husk');
+
+ // No session at all: still undefined, and no read is issued.
+ function NoSessionProbe(): ReactElement {
+ observed = useComposerGitBranch(undefined);
+ return createElement('span');
+ }
+ await act(() =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services: servicesWithReview(port.service),
+ children: createElement(NoSessionProbe),
+ }),
+ ),
+ );
+ assert.equal(observed, undefined);
+ // The non-repository read above already ran once; no session must add none.
+ assert.equal(port.reads(), 1, 'no session must not issue a read');
+
+ await act(() => root.unmount());
+});
+
+test('useComposerGitBranch re-reads after the session terminal goes quiet (the in-app case)', async (t) => {
+ const document = installRenderer(t);
+ let current: GitBranchReadResult = {
+ ok: true,
+ snapshot: { branch: 'feature/before', shortSha: null },
+ };
+ const port = reviewPort(() => current);
+ const pty = { emit: (_sessionId: string) => undefined };
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+
+ let observed: ComposerGitBranch | undefined;
+ function Probe(): ReactElement {
+ observed = useComposerGitBranch('session-1');
+ return createElement('span');
+ }
+ await act(() =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services: servicesWithReview(port.service, pty),
+ children: createElement(Probe),
+ }),
+ ),
+ );
+ assert.equal(observed?.name, 'feature/before');
+ assert.equal(port.reads(), 1);
+
+ // A command typed in this session's integrated terminal: output arrives with
+ // no window blur and no new shell run, so only the PTY signal can see it.
+ current = { ok: true, snapshot: { branch: 'feature/after', shortSha: null } };
+ await act(() => {
+ pty.emit('session-1');
+ });
+ // The read is debounced until the output settles.
+ await act(() => new Promise((resolve) => setTimeout(resolve, 600)));
+ assert.equal(observed?.name, 'feature/after', 'the chip must follow an in-app branch change');
+ assert.equal(port.reads(), 2);
+
+ // Output from ANOTHER session must not touch this chip.
+ current = { ok: true, snapshot: { branch: 'feature/other', shortSha: null } };
+ await act(() => {
+ pty.emit('session-2');
+ });
+ await act(() => new Promise((resolve) => setTimeout(resolve, 600)));
+ assert.equal(observed?.name, 'feature/after', 'another session\'s terminal must not move this chip');
+ assert.equal(port.reads(), 2);
+
+ await act(() => root.unmount());
+});
+
+test('an unchanged branch costs no re-render when the terminal re-reads', async (t) => {
+ const document = installRenderer(t);
+ const port = reviewPort(() => ({
+ ok: true,
+ snapshot: { branch: 'feature/same', shortSha: null },
+ }));
+ const pty = { emit: (_sessionId: string) => undefined };
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+
+ let renders = 0;
+ function Probe(): ReactElement {
+ renders += 1;
+ useComposerGitBranch('session-1');
+ return createElement('span');
+ }
+ await act(() =>
+ root.render(
+ createElement(WorkbarServicesProvider, {
+ services: servicesWithReview(port.service, pty),
+ children: createElement(Probe),
+ }),
+ ),
+ );
+ const afterFirst = renders;
+
+ // A busy terminal re-reads on each quiet gap; the answer is identical, so the
+ // hook must not repaint the composer for it.
+ await act(() => {
+ pty.emit('session-1');
+ });
+ await act(() => new Promise((resolve) => setTimeout(resolve, 600)));
+ assert.ok(port.reads() > 1, 'the quiet gap did re-read');
+ assert.equal(renders, afterFirst, 'an unchanged branch must not re-render');
+
+ await act(() => root.unmount());
+});
diff --git a/apps/desktop/src/main/__tests__/git-review-main.test.ts b/apps/desktop/src/main/__tests__/git-review-main.test.ts
index b41b7c65b8..cf66e10b07 100644
--- a/apps/desktop/src/main/__tests__/git-review-main.test.ts
+++ b/apps/desktop/src/main/__tests__/git-review-main.test.ts
@@ -24,7 +24,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { after, describe, it } from 'node:test';
-import { readGitReview } from '../git-review-main.js';
+import { readGitBranch, readGitReview } from '../git-review-main.js';
const execFileAsync = promisify(execFile);
const roots = new Set();
@@ -170,3 +170,29 @@ async function git(root: string, ...args: string[]): Promise {
timeout: 10_000,
});
}
+
+describe('Git branch read (composer chip)', () => {
+ it('reads the checked-out branch', async () => {
+ const root = await repository();
+ await git(root, 'checkout', '-b', 'feature/chip');
+ assert.deepEqual(await readGitBranch(root), {
+ ok: true,
+ snapshot: { branch: 'feature/chip', shortSha: null },
+ });
+ });
+
+ it('falls back to the short sha on a detached HEAD', async () => {
+ const root = await repository();
+ await git(root, 'checkout', '--detach', 'HEAD');
+ const result = await readGitBranch(root);
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.equal(result.snapshot.branch, null);
+ assert.match(result.snapshot.shortSha ?? '', /^[0-9a-f]{7,}$/u);
+ });
+
+ it('reports a non-repository as isGitRepo: false, so no chip is drawn', async () => {
+ const root = await temporaryRoot();
+ assert.deepEqual(await readGitBranch(root), { ok: false, isGitRepo: false });
+ });
+});
diff --git a/apps/desktop/src/main/git-review-main.ts b/apps/desktop/src/main/git-review-main.ts
index a75b209c13..98fbc78d68 100644
--- a/apps/desktop/src/main/git-review-main.ts
+++ b/apps/desktop/src/main/git-review-main.ts
@@ -24,6 +24,8 @@ import { isAbsolute, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import { countDiffLineStats } from '@maka/core/unified-diff';
import {
+ type GitBranchReadResult,
+ type GitBranchSnapshot,
type GitReviewFile,
type GitReviewFileStatus,
type GitReviewReadResult,
@@ -41,6 +43,35 @@ export interface GitReviewCommandRunner {
(root: string, args: readonly string[]): Promise;
}
+/**
+ * The working tree's Git branch, for the composer's branch chip. Three states:
+ * not a repository (`ok: false, isGitRepo: false` — the caller renders nothing),
+ * a detached HEAD (`branch: null`, `shortSha` set), or a named branch.
+ *
+ * Read-only and deliberately cheap: the review reader withholds the bulk of its
+ * work, so this asks only what the chip prints.
+ */
+export async function readGitBranch(
+ cwd: string,
+ runGit: GitReviewCommandRunner = runGitCommand,
+): Promise {
+ try {
+ const repositoryRoot = await resolveProjectRoot([cwd]);
+ if (!(await resolveProjectGitInfo(repositoryRoot)).isGitRepo) {
+ return { ok: false, isGitRepo: false };
+ }
+ const branch = cleanLine(await runGit(repositoryRoot, ['branch', '--show-current']));
+ // A detached HEAD yields no branch name; its short commit is the honest
+ // answer, and a repository with no commits yet has neither.
+ const shortSha = branch === null
+ ? cleanLine(await runGit(repositoryRoot, ['rev-parse', '--short', 'HEAD']).catch(() => ''))
+ : null;
+ return { ok: true, snapshot: { branch, shortSha } };
+ } catch {
+ return { ok: false, isGitRepo: true };
+ }
+}
+
export async function readGitReview(
cwd: string,
source: GitReviewSource,
diff --git a/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts b/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
index f2f2d91ca6..af647a36e2 100644
--- a/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
@@ -20,7 +20,7 @@
import { stat } from 'node:fs/promises';
import type { GitReviewSource } from '@maka/core/git-review';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
-import { readGitReview } from './git-review-main.js';
+import { readGitBranch, readGitReview } from './git-review-main.js';
import {
handleReconnectableRead,
type ReconnectableReadIpcMain,
@@ -44,6 +44,21 @@ export function registerRuntimeHostWorkspaceIpc(
if (!cwd) return { ok: false as const, reason: 'workspace_unavailable' as const };
return readGitReview(cwd, request.source, undefined, request.baseBranch);
});
+
+ // The composer's branch chip: the same session → workspace resolution as the
+ // review read above, but only the branch the chip prints.
+ handleReconnectableRead(input.ipcMain, 'git:branch', async (_event, raw: unknown) => {
+ const sessionId = readBranchRequest(raw);
+ const cwd =
+ input.allowLocalWorkspace === false ? null : await sessionWorkspace(input.client, sessionId);
+ // An unavailable workspace is not a repository either: the chip stays off.
+ if (!cwd) return { ok: false as const, isGitRepo: false };
+ return readGitBranch(cwd);
+ });
+}
+
+function readBranchRequest(value: unknown): string {
+ return requiredString(requiredRecord(value, 'Git branch').sessionId, 'Session id');
}
async function sessionWorkspace(client: WorkspaceClient, sessionId: string): Promise {
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index a4947dff46..ee4c3951a4 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -86,6 +86,7 @@ import type {
AppUpdateStatus,
} from '../shared/app-update.js';
import type {
+ GitBranchReadResult,
GitReviewReadResult,
GitReviewSource,
} from '@maka/core/git-review';
@@ -1484,6 +1485,8 @@ export interface MakaBridge {
source: GitReviewSource;
baseBranch?: string;
}): Promise;
+ /** The working tree's branch (or short sha on a detached HEAD). */
+ branch(input: { sessionId: string }): Promise;
};
goal: {
/** The session's current goal (null when none is set). */
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index deed3cd94a..769e463215 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -183,6 +183,7 @@ import type {
import type { ThinkingLevel } from '@maka/core/model-thinking';
import type { E2eFixtureState } from '@maka/core/e2e-fixture';
import type {
+ GitBranchReadResult,
GitReviewReadResult,
GitReviewSource,
} from '@maka/core/git-review';
@@ -2984,6 +2985,9 @@ const makaBridge = {
}): Promise {
return invokeSessionInput('git-review:read', input);
},
+ branch(input: { sessionId: string }): Promise {
+ return invokeSessionInput('git:branch', input);
+ },
},
goal: {
get(sessionId: string): Promise {
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 91b55b8256..d8f4ab5bae 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -150,6 +150,7 @@ interface ChatComposerRegionProps
*/
children: (
usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ gitBranch: { readonly name?: string; readonly shortSha?: string } | undefined,
) => ReactNode;
}>;
directoryComposerProps: Pick<
@@ -266,12 +267,14 @@ export function ChatComposerRegion({
// the anchor prop remains the reading it falls back to.
const renderComposer = (
liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ gitBranch: { name?: string; shortSha?: string } | undefined,
) => (
{(goalProjection) => (
- {renderComposer}
+ {(usage, gitBranch) => renderComposer(usage, gitBranch)}
) : (
- renderComposer(undefined)
+ renderComposer(undefined, undefined)
)}
>
);
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index dde5730c49..5d629776aa 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -33,7 +33,11 @@ import type {
ArtifactTextReadResult,
} from '@maka/core/artifacts';
import type { BrowserState, BrowserViewRect } from '@maka/core/browser';
-import type { GitReviewReadResult, GitReviewSource } from '@maka/core/git-review';
+import type {
+ GitBranchReadResult,
+ GitReviewReadResult,
+ GitReviewSource,
+} from '@maka/core/git-review';
import type { PermissionMode } from '@maka/core/permission';
import type { RegenerateTurnInput } from '@maka/core/runtime-inputs';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
@@ -68,6 +72,8 @@ export interface WorkbarReviewService {
source: GitReviewSource;
baseBranch?: string;
}): Promise;
+ /** The working tree's branch (or short sha when detached), for the composer chip. */
+ branch(sessionId: string): Promise;
subscribeSessionEvents(
sessionId: string,
handler: (event: SessionEvent) => void,
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 12723bcaaf..1a704842a0 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -22,6 +22,7 @@ import type { WorkbarServices } from './ports.js';
export { WorkbarServicesProvider } from './services-context.js';
export type {
WorkbarServices,
+ WorkbarReviewService,
SessionTracePage,
SessionUsageSummary,
WorkbarIngestInput,
@@ -56,6 +57,10 @@ export * from './tools/terminal/session-terminal-frame.js';
export * from '../../application/contracts/session-inspector/use-session-trace.js';
export * from './controller/use-workbar-controller.js';
export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js';
+export {
+ useComposerGitBranch,
+ type ComposerGitBranch,
+} from './tools/composer-git-branch.js';
const noopSubscription = (): (() => void) => () => undefined;
@@ -73,6 +78,7 @@ export function createFakeWorkbarServices(
read: async () => {
throw new Error('Fake review.read is not configured');
},
+ branch: async () => ({ ok: false, isGitRepo: false }),
subscribeSessionEvents: noopSubscription,
},
terminal: {
diff --git a/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts b/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts
new file mode 100644
index 0000000000..d3e0479b59
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useWorkbarServices } from '../services-context.js';
+
+/**
+ * How long this session's PTY must be silent before the branch is re-read. Long
+ * enough that a command's output burst settles into one read, short enough that
+ * the chip has caught up by the time a person looks at it.
+ */
+const PTY_QUIET_MS = 400;
+
+export interface ComposerGitBranch {
+ readonly name?: string;
+ readonly shortSha?: string;
+}
+
+/**
+ * The branch the active Session's working tree is on, for the composer's branch
+ * chip. `undefined` whenever there is nothing to show — no session, not a
+ * repository, a failed read — so the chip renders nothing rather than an empty
+ * husk.
+ *
+ * The read is re-taken, not frozen: a branch changes under the app, including
+ * from the Desktop's own integrated terminal, so a value read once would go
+ * silently stale — and a stale status readout is worse than an absent one,
+ * because it still looks like a good value.
+ *
+ * Three triggers, because a branch changes in three places:
+ * - the app was left and returned to (`focus`, `visibilitychange`);
+ * - a command ran in this session's INTEGRATED terminal, which lives in the
+ * same document — so neither window event fires. That terminal is a long-lived
+ * PTY: `git checkout` produces no new shell run and no session event, only
+ * output. The signal is therefore the output going quiet — a command has
+ * finished when the PTY has been silent for a beat — and only for a run
+ * belonging to THIS session.
+ * - `sessionId` changing (a different session is a different working tree).
+ *
+ * `subscribeSessionEvents` would not cover the middle case: it carries the
+ * model's transcript events (`tool_start`/`tool_result`), and a command typed by
+ * a person is not one of those.
+ *
+ * The re-read is driven through refs, not through a state token: a busy terminal
+ * spawns one read per quiet gap, and most of those answer the same branch. A
+ * token held in state would repaint the composer on every one of them even when
+ * nothing changed, so reads go through `read()` and a state update happens only
+ * when the value actually differs.
+ */
+export function useComposerGitBranch(
+ sessionId: string | undefined,
+): ComposerGitBranch | undefined {
+ const { review, terminal } = useWorkbarServices();
+ const [branch, setBranch] = useState(undefined);
+ // The last applied value, readable from a trigger without re-subscribing, and
+ // the session the in-flight read belongs to (a late answer for a session the
+ // user has left must not land).
+ const branchRef = useRef(undefined);
+ const sessionRef = useRef(sessionId);
+ sessionRef.current = sessionId;
+
+ // Set state only on a real change. Returning the caller is not enough on its
+ // own here because these reads are not the render's own dependency; the guard
+ // is what keeps an unchanged branch from repainting the composer.
+ const apply = useCallback((next: ComposerGitBranch | undefined) => {
+ const current = branchRef.current;
+ if (current?.name === next?.name && current?.shortSha === next?.shortSha) return;
+ branchRef.current = next;
+ setBranch(next);
+ }, []);
+
+ const read = useCallback(() => {
+ const id = sessionRef.current;
+ if (!id) {
+ apply(undefined);
+ return;
+ }
+ void review
+ .branch(id)
+ .then((result) => {
+ if (sessionRef.current !== id) return;
+ apply(
+ result.ok
+ ? {
+ ...(result.snapshot.branch !== null ? { name: result.snapshot.branch } : {}),
+ ...(result.snapshot.branch === null && result.snapshot.shortSha !== null
+ ? { shortSha: result.snapshot.shortSha }
+ : {}),
+ }
+ : undefined,
+ );
+ })
+ .catch(() => {
+ if (sessionRef.current === id) apply(undefined);
+ });
+ }, [review, apply]);
+
+ // The session's own read, taken whenever the session changes.
+ useEffect(() => {
+ if (!sessionId) {
+ branchRef.current = undefined;
+ setBranch(undefined);
+ return;
+ }
+ read();
+ }, [sessionId, read]);
+
+ // Returning to the app is a branch change we cannot observe directly.
+ useEffect(() => {
+ const onFocus = () => read();
+ // `visibilitychange` alone is enough: returning to a tab fires it, and the
+ // hidden->visible transition is the only direction that can have missed a
+ // change. Gating on `document.visibilityState` would trust a property some
+ // embedders do not populate, and the extra read on a hide is harmless.
+ const onVisibility = () => read();
+ window.addEventListener('focus', onFocus);
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => {
+ window.removeEventListener('focus', onFocus);
+ document.removeEventListener('visibilitychange', onVisibility);
+ };
+ }, [read]);
+
+ // The integrated terminal: a persistent PTY in this same document, so a typed
+ // command fires no window event and creates no shell run. Its OUTPUT is the
+ // signal, debounced so a burst costs one read rather than one per chunk, and
+ // scoped to this session so another session's terminal cannot move this chip.
+ useEffect(() => {
+ if (!sessionId) return;
+ let quietTimer: ReturnType | undefined;
+ const unsubscribe = terminal.subscribePtyData((event) => {
+ if (event.sessionId !== sessionId) return;
+ if (quietTimer !== undefined) clearTimeout(quietTimer);
+ quietTimer = setTimeout(() => {
+ quietTimer = undefined;
+ read();
+ }, PTY_QUIET_MS);
+ });
+ return () => {
+ if (quietTimer !== undefined) clearTimeout(quietTimer);
+ unsubscribe();
+ };
+ }, [sessionId, terminal, read]);
+
+ return branch;
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
index e43864ef2d..075b1ad7e3 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
@@ -21,6 +21,12 @@ import { useWorkbarServices } from '../../services-context.js';
import type { ReactElement, ReactNode } from 'react';
import type { LiveContextUsage } from '../../../../application/contracts/session-inspector/live-context-usage.js';
import { useLiveContextUsage } from '../../../../application/contracts/session-inspector/use-live-context-usage.js';
+import {
+ useComposerGitBranch,
+ type ComposerGitBranch,
+} from '../composer-git-branch.js';
+
+export type { ComposerGitBranch } from '../composer-git-branch.js';
/**
* Render-prop boundary for the composer context gauge (#4717).
@@ -36,7 +42,10 @@ export function LiveContextUsageProbe(props: {
readonly sessionId: string | undefined;
readonly model: string | undefined;
readonly providerType: string | undefined;
- readonly children: (usage: LiveContextUsage | undefined) => ReactNode;
+ readonly children: (
+ usage: LiveContextUsage | undefined,
+ gitBranch: ComposerGitBranch | undefined,
+ ) => ReactNode;
}): ReactElement {
const { inspector } = useWorkbarServices();
const usage = useLiveContextUsage({
@@ -45,5 +54,13 @@ export function LiveContextUsageProbe(props: {
model: props.model,
providerType: props.providerType,
});
- return <>{props.children(usage)}>;
+ // Two independent hooks (each in its own module), composed here only because
+ // this is the single injection point the shell can offer: `app-shell.tsx` is
+ // token-frozen by the architecture ratchet and `chat-composer-region.tsx` is
+ // capability-frozen, so a second probe prop cannot be threaded through without
+ // growing recorded debt. The branch logic is not coupled to the usage reading —
+ // `useComposerGitBranch` is standalone and tested on its own; only the carrier
+ // is shared.
+ const gitBranch = useComposerGitBranch(props.sessionId);
+ return <>{props.children(usage, gitBranch)}>;
}
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index d0351d6809..d7a533f6e9 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -108,6 +108,7 @@ export function createDesktopWorkbarServices(
popupMenu: (input) => bridge.appWindow.popupMenu(input),
review: {
read: (input) => bridge.gitReview.read(input),
+ branch: (sessionId) => bridge.gitReview.branch({ sessionId }),
subscribeSessionEvents: (sessionId, handler) =>
bridge.sessions.subscribeEvents(sessionId, handler),
},
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 70dc4523b5..13df3e7d05 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -338,6 +338,34 @@
max-width: min(320px, 92vw);
}
+/* The Git-branch readout beside the usage gauge in the composer footer. A
+ ``, not a control — nothing to click — but it wears the same label type
+ and pill geometry as the model chip. The cap is wider than the model chip's
+ 180px so a usual branch (`feat/some-topic`) shows whole; only a genuinely long
+ one ellipsizes, and the `title` carries the full text in that case. */
+.maka-composer-git-branch {
+ font: var(--maka-text-label);
+ height: var(--h-control-md);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1);
+ padding: 0 var(--space-2);
+ border: var(--border-width-hairline) solid transparent;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--muted-foreground);
+ white-space: nowrap;
+ max-width: min(360px, 40vw);
+}
+
+.maka-composer-git-branch-text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* Model chip (static fallback) still owns its own quiet geometry. */
.maka-composer-model-chip {
font: var(--maka-text-label);
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 0d201c5827..9b9fca0ea9 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -829,6 +829,7 @@ function bridge(options: {
if (options.reviewFail) throw new Error('读取变更失败:无法运行 git diff');
return options.review ?? { ok: true, snapshot: gitReviewSnapshot };
},
+ branch: async () => ({ ok: true, snapshot: { branch: 'main', shortSha: null } }),
subscribeSessionEvents: unsubscribe,
},
terminal: {
diff --git a/packages/core/src/git-review.ts b/packages/core/src/git-review.ts
index 9ca9ec90ac..f65f49ad07 100644
--- a/packages/core/src/git-review.ts
+++ b/packages/core/src/git-review.ts
@@ -61,3 +61,23 @@ export type GitReviewReadResult =
| 'invalid_base_branch'
| 'git_failed';
};
+
+/**
+ * The working tree's Git branch, for the composer's branch chip. `branch: null`
+ * with a `shortSha` means a detached HEAD; a repository with no commits yet has
+ * neither. The chip is only rendered when the read succeeded.
+ */
+export interface GitBranchSnapshot {
+ branch: string | null;
+ shortSha: string | null;
+}
+
+/**
+ * `isGitRepo` distinguishes the two ways a read can fail: `false` is "this
+ * directory is not a repository" (the caller renders nothing), while `true` is
+ * "a repository, but the query failed" (nothing to show either, but the
+ * distinction lets a caller decide whether to retry rather than hide for good).
+ */
+export type GitBranchReadResult =
+ | { ok: true; snapshot: GitBranchSnapshot }
+ | { ok: false; isGitRepo: boolean };
diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx
index 9f6c229f40..46c96d3951 100644
--- a/packages/ui/src/__tests__/composer-context-usage.test.tsx
+++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx
@@ -147,3 +147,72 @@ test('the context usage share resolves declared, then metered, then metadata win
Object.assign(globalThis, original);
}
});
+
+test('the git branch chip shows the branch, the short sha when detached, and nothing without Git', async () => {
+ const original = {
+ document: globalThis.document,
+ window: globalThis.window,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+ }).IS_REACT_ACT_ENVIRONMENT,
+ };
+ const { document, window } = parseHTML('');
+ window.getComputedStyle = () => ({
+ direction: 'ltr',
+ writingMode: 'horizontal-tb',
+ getPropertyValue: () => '',
+ }) as unknown as CSSStyleDeclaration;
+ Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+
+ const render = (branch?: { name?: string; shortSha?: string }) =>
+ root.render(
+
+ undefined}
+ onStop={() => undefined}
+ />
+ ,
+ );
+
+ // The chip is a readout, not a control: nothing under it may be a button, so
+ // a click has nothing to land on.
+ const branchEl = () => container.querySelector('.maka-composer-git-branch');
+ const chipText = () => branchEl()?.textContent?.trim();
+
+ try {
+ // No `gitBranch` — a non-repository workspace — means no chip at all, not an
+ // empty one: absence of Git is the normal case and must not leave a husk.
+ await act(() => render(undefined));
+ assert.equal(chipText(), undefined, 'no chip may render outside a Git repository');
+
+ await act(() => render({ name: 'feature/chip' }));
+ assert.equal(chipText(), 'feature/chip', 'a named branch must render its name');
+ // The chip must be a readout, not a control: whatever carries the branch
+ // text may not be (or contain) a button, so a click has nothing to land on.
+ assert.equal(
+ branchEl()?.closest('button'),
+ null,
+ 'the branch chip must not be a button — the branch is a readout, not an action',
+ );
+ assert.equal(
+ branchEl()?.tagName,
+ 'SPAN',
+ 'the chip must be a plain span, not a control',
+ );
+ // The full branch must survive on the element a truncating row can still
+ // name: `title` is what hover shows when the text had to be shortened.
+ assert.match(branchEl()?.getAttribute('title') ?? '', /feature\/chip/u);
+
+ // Detached HEAD: the branch name is unknown, so the short sha is the label.
+ await act(() => render({ shortSha: 'abc1234' }));
+ assert.equal(chipText(), 'abc1234', 'a detached HEAD must name itself through the short sha');
+ assert.equal(branchEl()?.closest('button'), null, 'a detached HEAD readout must not be a button');
+ } finally {
+ await act(() => root.unmount());
+ Object.assign(globalThis, original);
+ }
+});
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index e0673d69d5..951d954101 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -39,6 +39,7 @@ import {
ArrowUp,
CircleGauge,
FileText,
+ GitBranch,
ListTodo,
MessagesSquare,
Network,
@@ -456,6 +457,17 @@ export const Composer = forwardRef<
/** Open the Host-owned trace surface for this readout. */
onOpen(): void;
};
+ /**
+ * The working tree's Git branch, beside the context-usage readout. Omitted
+ * entirely when the session's directory is not a Git repository, so the
+ * chip simply does not exist there rather than sitting empty.
+ */
+ gitBranch?: {
+ /** The branch name, or `undefined` on a detached HEAD. */
+ name?: string;
+ /** The short commit sha, set only when `name` is absent. */
+ shortSha?: string;
+ };
/**
* Optional edit-and-resend banner above the composer. Desktop owns the
* revision draft; Composer only renders the notice + cancel affordance.
@@ -2318,6 +2330,7 @@ export const Composer = forwardRef<
/>
)}
{props.contextUsage ? : null}
+ {props.gitBranch ? : null}
{/* The project decides where a NEW chat starts, which makes it a
parameter of this send like the model beside it — so it sits
@@ -2469,4 +2482,27 @@ function ContextUsageAction(props: {
);
}
-export type ComposerProps = ComponentProps;
+function GitBranchChip(props: { name?: string; shortSha?: string }) {
+ const copy = getConversationCopy(useUiLocale()).messages.systemNotes;
+ // A detached HEAD has no branch name; the short sha is the honest label. Only
+ // one of the two is ever set (the host resolves it), and neither means the
+ // repository state is unknown — the chip stays off rather than guess.
+ const detached = props.name === undefined;
+ if (detached && props.shortSha === undefined) return null;
+ const label = props.name ?? props.shortSha!;
+ // The visible text is the branch; `title` names what it is and carries the
+ // full text, so a branch the row had to shorten is still readable on hover.
+ const title = detached ? copy.gitBranchDetached(props.shortSha!) : copy.gitBranchLabel;
+ // Readout, not a control: a ``, so there is nothing to click or tab to.
+ // The class matches the ghost buttons beside it and widens past the model
+ // chip's 180px cap, so a usual branch shows whole; only a genuinely long one
+ // ellipsizes, and `title` carries it.
+ return (
+
+
+ {label}
+
+ );
+}
+
+export type ComposerProps = ComponentProps;
\ No newline at end of file
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index c2fbf53d9e..5e26d979de 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -358,6 +358,8 @@ export interface ConversationCopy {
contextUsageNoWindow: (used: number) => string;
contextUsageUnavailable: string;
contextUsageOpen: string;
+ gitBranchLabel: string;
+ gitBranchDetached: (sha: string) => string;
stepLimit: string;
};
};
@@ -593,9 +595,11 @@ const CONVERSATION_COPY = {
`上下文窗口:已用 ${Math.round((used / window) * 100)}%(${formatCompactTokenCount(used)} / ${formatCompactTokenCount(window)} token)`,
contextUsageNoWindow: (used) =>
`已用 ${formatCompactTokenCount(used)} token;上下文窗口上限未知`,
- contextUsageUnavailable: '暂无用量数据',
- contextUsageOpen: '打开用量追踪',
- stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
+ contextUsageUnavailable: '暂无用量数据',
+ contextUsageOpen: '打开用量追踪',
+ gitBranchLabel: '当前 Git 分支',
+ gitBranchDetached: (sha: string) => `游离 HEAD(${sha})`,
+ stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
},
},
chat: {
@@ -753,6 +757,8 @@ const CONVERSATION_COPY = {
`已用 ${formatCompactTokenCount(used)} token;上下文視窗上限未知`,
contextUsageUnavailable: '暫無用量資料',
contextUsageOpen: '開啟用量追蹤',
+ gitBranchLabel: '目前 Git 分支',
+ gitBranchDetached: (sha: string) => `游離 HEAD(${sha})`,
stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。',
},
},
@@ -937,6 +943,8 @@ const CONVERSATION_COPY = {
`This request used ${formatCompactTokenCount(used)} tokens; no context limit is available for this model.`,
contextUsageUnavailable: 'No usage data is available for this request.',
contextUsageOpen: 'Open usage trace',
+ gitBranchLabel: 'Current Git branch',
+ gitBranchDetached: (sha: string) => `detached HEAD (${sha})`,
stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
},
},