From 73054ec8166d848e7f2bc6676348a4d5608d2b6d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 19:21:52 +0800 Subject: [PATCH] revert(ui): drop the composer's Git branch chip Reverts #5487. The chip was the only consumer of the branch read, so the pipeline goes with it: the composer prop and `GitBranchChip`, the `useComposerGitBranch` hook, the `review.branch` port, the `git:branch` IPC, and the `GitBranchReadResult`/`GitBranchSnapshot` types. The branch is ambient state the agent owns now, not one of the parameters this send takes, so a read-only value there is clutter on the row that decides what to send. `#2171`'s "keep the branch context visible" predates agent-managed branches, and the workbar's Review face names the current branch where a person actually looks at it (#5120). Generated-by: Maka --- .../__tests__/composer-git-branch.test.ts | 271 ------------------ .../main/__tests__/git-review-main.test.ts | 28 +- apps/desktop/src/main/git-review-main.ts | 31 -- .../main/runtime-host-workspace-ipc-main.ts | 17 +- apps/desktop/src/preload/bridge-contract.d.ts | 3 - apps/desktop/src/preload/preload.ts | 4 - .../src/renderer/chat-composer-region.tsx | 7 +- .../src/renderer/features/workbar/ports.ts | 8 +- .../src/renderer/features/workbar/testing.ts | 6 - .../workbar/tools/composer-git-branch.ts | 162 ----------- .../inspector/live-context-usage-probe.tsx | 21 +- .../desktop/create-workbar-services.ts | 1 - apps/desktop/src/renderer/styles/composer.css | 28 -- .../stories/session-workbar.stories.tsx | 1 - packages/core/src/git-review.ts | 20 -- .../__tests__/composer-context-usage.test.tsx | 69 ----- packages/ui/src/composer.tsx | 38 +-- packages/ui/src/conversation-copy.ts | 14 +- 18 files changed, 11 insertions(+), 718 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/composer-git-branch.test.ts delete mode 100644 apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts diff --git a/apps/desktop/src/main/__tests__/composer-git-branch.test.ts b/apps/desktop/src/main/__tests__/composer-git-branch.test.ts deleted file mode 100644 index 661f793c7b..0000000000 --- a/apps/desktop/src/main/__tests__/composer-git-branch.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* - * 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 cf66e10b07..b41b7c65b8 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 { readGitBranch, readGitReview } from '../git-review-main.js'; +import { readGitReview } from '../git-review-main.js'; const execFileAsync = promisify(execFile); const roots = new Set(); @@ -170,29 +170,3 @@ 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 98fbc78d68..a75b209c13 100644 --- a/apps/desktop/src/main/git-review-main.ts +++ b/apps/desktop/src/main/git-review-main.ts @@ -24,8 +24,6 @@ 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, @@ -43,35 +41,6 @@ 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 af647a36e2..f2f2d91ca6 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 { readGitBranch, readGitReview } from './git-review-main.js'; +import { readGitReview } from './git-review-main.js'; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -44,21 +44,6 @@ 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 2cddb6b9b8..4e6688eb2f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -85,7 +85,6 @@ import type { AppUpdateStatus, } from '../shared/app-update.js'; import type { - GitBranchReadResult, GitReviewReadResult, GitReviewSource, } from '@maka/core/git-review'; @@ -1478,8 +1477,6 @@ 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 fc42c7d750..b0072ecf24 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -183,7 +183,6 @@ 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'; @@ -2976,9 +2975,6 @@ 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 d8f4ab5bae..91b55b8256 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -150,7 +150,6 @@ interface ChatComposerRegionProps */ children: ( usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, - gitBranch: { readonly name?: string; readonly shortSha?: string } | undefined, ) => ReactNode; }>; directoryComposerProps: Pick< @@ -267,14 +266,12 @@ 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) => ( - {(usage, gitBranch) => renderComposer(usage, gitBranch)} + {renderComposer} ) : ( - renderComposer(undefined, undefined) + renderComposer(undefined) )} ); diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 5744a0c8af..a7c1e6bc52 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -33,11 +33,7 @@ import type { ArtifactTextReadResult, } from '@maka/core/artifacts'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; -import type { - GitBranchReadResult, - GitReviewReadResult, - GitReviewSource, -} from '@maka/core/git-review'; +import type { GitReviewReadResult, GitReviewSource } from '@maka/core/git-review'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; @@ -71,8 +67,6 @@ 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 6edb4cedc1..7c67e20586 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -22,7 +22,6 @@ import type { WorkbarServices } from './ports.js'; export { WorkbarServicesProvider } from './services-context.js'; export type { WorkbarServices, - WorkbarReviewService, SessionTracePage, SessionUsageSummary, WorkbarIngestInput, @@ -57,10 +56,6 @@ 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; @@ -78,7 +73,6 @@ 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 deleted file mode 100644 index d3e0479b59..0000000000 --- a/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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 075b1ad7e3..e43864ef2d 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,12 +21,6 @@ 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). @@ -42,10 +36,7 @@ export function LiveContextUsageProbe(props: { readonly sessionId: string | undefined; readonly model: string | undefined; readonly providerType: string | undefined; - readonly children: ( - usage: LiveContextUsage | undefined, - gitBranch: ComposerGitBranch | undefined, - ) => ReactNode; + readonly children: (usage: LiveContextUsage | undefined) => ReactNode; }): ReactElement { const { inspector } = useWorkbarServices(); const usage = useLiveContextUsage({ @@ -54,13 +45,5 @@ export function LiveContextUsageProbe(props: { model: props.model, providerType: props.providerType, }); - // 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)}; + return <>{props.children(usage)}; } 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 06654bedef..8622be3fc4 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -108,7 +108,6 @@ 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 13df3e7d05..70dc4523b5 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -338,34 +338,6 @@ 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 3567bb1d15..9d4973ab45 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -829,7 +829,6 @@ 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 f65f49ad07..9ca9ec90ac 100644 --- a/packages/core/src/git-review.ts +++ b/packages/core/src/git-review.ts @@ -61,23 +61,3 @@ 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 46c96d3951..9f6c229f40 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -147,72 +147,3 @@ 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 32f512ea60..380f238c48 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -39,7 +39,6 @@ import { ArrowUp, CircleGauge, FileText, - GitBranch, ListTodo, MessagesSquare, Network, @@ -459,17 +458,6 @@ 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. @@ -2336,7 +2324,6 @@ 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 @@ -2488,27 +2475,4 @@ function ContextUsageAction(props: { ); } -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 +export type ComposerProps = ComponentProps; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index e22bb14cdf..6fe41b2941 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -366,8 +366,6 @@ export interface ConversationCopy { contextUsageNoWindow: (used: number) => string; contextUsageUnavailable: string; contextUsageOpen: string; - gitBranchLabel: string; - gitBranchDetached: (sha: string) => string; stepLimit: string; }; }; @@ -604,11 +602,9 @@ const CONVERSATION_COPY = { `上下文窗口:已用 ${Math.round((used / window) * 100)}%(${formatCompactTokenCount(used)} / ${formatCompactTokenCount(window)} token)`, contextUsageNoWindow: (used) => `已用 ${formatCompactTokenCount(used)} token;上下文窗口上限未知`, - contextUsageUnavailable: '暂无用量数据', - contextUsageOpen: '打开用量追踪', - gitBranchLabel: '当前 Git 分支', - gitBranchDetached: (sha: string) => `游离 HEAD(${sha})`, - stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', + contextUsageUnavailable: '暂无用量数据', + contextUsageOpen: '打开用量追踪', + stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, }, chat: { @@ -767,8 +763,6 @@ const CONVERSATION_COPY = { `已用 ${formatCompactTokenCount(used)} token;上下文視窗上限未知`, contextUsageUnavailable: '暫無用量資料', contextUsageOpen: '開啟用量追蹤', - gitBranchLabel: '目前 Git 分支', - gitBranchDetached: (sha: string) => `游離 HEAD(${sha})`, stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', }, }, @@ -954,8 +948,6 @@ 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.', }, },