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 ( + + + ); +} + +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.', }, },