From a84c7def21ccc5daa8fc96aa56b82c3f81fcd30a Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Sat, 11 Jul 2026 16:18:55 +0800 Subject: [PATCH 1/4] feat(web-shell): show current git branch in composer toolbar --- .../src/serve/routes/workspace-git.test.ts | 117 ++++++++++++++++++ .../cli/src/serve/routes/workspace-git.ts | 57 +++++++++ packages/cli/src/serve/run-qwen-serve.ts | 3 + packages/cli/src/serve/server.ts | 17 +++ .../cli/src/serve/workspace-git-state.test.ts | 85 +++++++++++++ packages/cli/src/serve/workspace-git-state.ts | 76 ++++++++++++ .../sdk-typescript/src/daemon/DaemonClient.ts | 19 +++ packages/sdk-typescript/src/daemon/events.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 8 ++ .../src/daemon/ui/normalizer.ts | 3 + .../test/unit/DaemonClient.test.ts | 45 +++++++ packages/web-shell/client/App.tsx | 1 + .../client/components/ChatEditor.module.css | 34 +++++ .../client/components/ChatEditor.tsx | 11 ++ .../components/GitBranchIndicator.test.tsx | 38 ++++++ .../client/components/GitBranchIndicator.tsx | 45 +++++++ packages/web-shell/client/i18n.tsx | 2 + .../daemon/session/DaemonSessionProvider.tsx | 39 ++++-- .../webui/src/daemon/session/mappers.test.ts | 35 ++++++ packages/webui/src/daemon/session/mappers.ts | 11 ++ packages/webui/src/daemon/session/types.ts | 2 + 21 files changed, 640 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/serve/routes/workspace-git.test.ts create mode 100644 packages/cli/src/serve/routes/workspace-git.ts create mode 100644 packages/cli/src/serve/workspace-git-state.test.ts create mode 100644 packages/cli/src/serve/workspace-git-state.ts create mode 100644 packages/web-shell/client/components/GitBranchIndicator.test.tsx create mode 100644 packages/web-shell/client/components/GitBranchIndicator.tsx diff --git a/packages/cli/src/serve/routes/workspace-git.test.ts b/packages/cli/src/serve/routes/workspace-git.test.ts new file mode 100644 index 00000000000..9096ddd444d --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-git.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import type { WorkspaceGitState } from '../workspace-git-state.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { + registerWorkspaceGitRoutes, + registerWorkspaceQualifiedGitRoutes, +} from './workspace-git.js'; + +function runtime( + workspaceId: string, + workspaceCwd: string, + trusted: boolean, +): WorkspaceRuntime { + return { + workspaceId, + workspaceCwd, + primary: workspaceId === 'primary', + trusted, + bridge: { publishWorkspaceEvent: vi.fn() } as unknown as AcpSessionBridge, + } as WorkspaceRuntime; +} + +function registry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry { + return { + primary: runtimes[0]!, + list: () => runtimes, + getByWorkspaceCwd: (cwd) => + runtimes.find((item) => item.workspaceCwd === cwd), + getByWorkspaceId: (id) => runtimes.find((item) => item.workspaceId === id), + resolveWorkspaceCwd: (cwd) => + cwd === undefined + ? runtimes[0] + : runtimes.find((item) => item.workspaceCwd === cwd), + resolveLiveSessionOwner: () => ({ kind: 'not_found' }), + add: () => {}, + }; +} + +describe('workspace Git routes', () => { + it('returns Git status for the bound workspace', async () => { + const app = express(); + const bridge = runtime('primary', '/work/main', true).bridge; + const getStatus = vi.fn(async () => ({ + v: 1 as const, + workspaceCwd: '/work/main', + branch: 'main', + })); + registerWorkspaceGitRoutes(app, { + boundWorkspace: '/work/main', + bridge, + gitState: { getStatus } as unknown as WorkspaceGitState, + }); + + const response = await request(app).get('/workspace/git'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + v: 1, + workspaceCwd: '/work/main', + branch: 'main', + }); + expect(getStatus).toHaveBeenCalledWith('/work/main', bridge); + }); + + it('uses the selected trusted workspace runtime', async () => { + const app = express(); + const primary = runtime('primary', '/work/main', true); + const secondary = runtime('secondary', '/work/secondary', true); + const getStatus = vi.fn(async () => ({ + v: 1 as const, + workspaceCwd: secondary.workspaceCwd, + branch: 'feature/web-shell', + })); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry: registry([primary, secondary]), + gitState: { getStatus } as unknown as WorkspaceGitState, + }); + + const response = await request(app).get('/workspaces/secondary/git'); + + expect(response.status).toBe(200); + expect(response.body.branch).toBe('feature/web-shell'); + expect(getStatus).toHaveBeenCalledWith( + secondary.workspaceCwd, + secondary.bridge, + ); + }); + + it('rejects an untrusted workspace before reading Git status', async () => { + const app = express(); + const primary = runtime('primary', '/work/main', true); + const untrusted = runtime('untrusted', '/work/untrusted', false); + const getStatus = vi.fn(); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry: registry([primary, untrusted]), + gitState: { getStatus } as unknown as WorkspaceGitState, + }); + + const response = await request(app).get('/workspaces/untrusted/git'); + + expect(response.status).toBe(403); + expect(response.body.code).toBe('untrusted_workspace'); + expect(getStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-git.ts b/packages/cli/src/serve/routes/workspace-git.ts new file mode 100644 index 00000000000..42b0db4602d --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-git.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, Response } from 'express'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import type { WorkspaceGitState } from '../workspace-git-state.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; + +export function registerWorkspaceGitRoutes( + app: Application, + deps: { + boundWorkspace: string; + bridge: AcpSessionBridge; + gitState: WorkspaceGitState; + }, +): void { + app.get('/workspace/git', async (_req, res) => { + res + .status(200) + .json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge)); + }); +} + +function resolveTrustedRuntime( + registry: WorkspaceRegistry, + req: Request, + res: Response, +): WorkspaceRuntime | null { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime) return null; + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; +} + +export function registerWorkspaceQualifiedGitRoutes( + app: Application, + deps: { workspaceRegistry: WorkspaceRegistry; gitState: WorkspaceGitState }, +): void { + app.get('/workspaces/:workspace/git', async (req, res) => { + const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); + if (!runtime) return; + res + .status(200) + .json( + await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), + ); + }); +} diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 8d6e2e59323..0c225faf51f 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -4127,6 +4127,9 @@ export async function runQwenServe( ( app.locals as { stopScheduledTaskKeepalive?: () => void } ).stopScheduledTaskKeepalive?.(); + ( + app.locals as { stopWorkspaceGitState?: () => void } + ).stopWorkspaceGitState?.(); // Same rationale for the create_sub_session launchers: stop accepting // new sub-session spawns before the bridges are torn down. Calls // every workspace's launcher stop (primary + secondaries). diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 5268ab0d99b..f2306c646f1 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -166,6 +166,11 @@ import { registerWorkspaceQualifiedLifecycleRoutes, } from './routes/workspace-lifecycle.js'; import { registerWorkspaceManagementRoutes } from './routes/workspace-management.js'; +import { + registerWorkspaceGitRoutes, + registerWorkspaceQualifiedGitRoutes, +} from './routes/workspace-git.js'; +import { WorkspaceGitState } from './workspace-git-state.js'; import { registerWorkspaceMcpControlRoutes, registerWorkspaceQualifiedMcpControlRoutes, @@ -782,6 +787,9 @@ export function createServeApp( const primaryBridge = primaryRuntime.bridge; const primaryWorkspace = primaryRuntime.workspaceService; const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory; + const workspaceGitState = new WorkspaceGitState(); + (app.locals as { stopWorkspaceGitState?: () => void }).stopWorkspaceGitState = + () => workspaceGitState.dispose(); const workspaceQualifiedAcpEnabled = resolveAcpHttpEnabled() && workspaceRegistry.list().length > 1; @@ -977,6 +985,15 @@ export function createServeApp( workspaceRegistry, sendBridgeError, }); + registerWorkspaceGitRoutes(app, { + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + gitState: workspaceGitState, + }); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry, + gitState: workspaceGitState, + }); // Workspace memory + agents CRUD routes. mountWorkspaceMemoryRoutes(app, { diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts new file mode 100644 index 00000000000..3e695700e9d --- /dev/null +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { WorkspaceGitState } from './workspace-git-state.js'; + +vi.mock('@qwen-code/qwen-code-core', () => ({ + resolveBranchName: vi.fn(), + watchRepoBranch: vi.fn(), +})); + +const resolveBranchNameMock = vi.mocked(resolveBranchName); +const watchRepoBranchMock = vi.mocked(watchRepoBranch); + +describe('WorkspaceGitState', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the current branch and publishes only real changes', async () => { + let onChange: (() => void) | undefined; + const dispose = vi.fn(); + const publishWorkspaceEvent = vi.fn(); + resolveBranchNameMock + .mockResolvedValueOnce('main') + .mockResolvedValueOnce('main') + .mockResolvedValueOnce('feature/web-shell'); + watchRepoBranchMock.mockImplementation(async (_cwd, callback) => { + onChange = callback; + return dispose; + }); + const state = new WorkspaceGitState(); + + await expect( + state.getStatus('/workspace', { + publishWorkspaceEvent, + } as unknown as AcpSessionBridge), + ).resolves.toEqual({ + v: 1, + workspaceCwd: '/workspace', + branch: 'main', + }); + + onChange?.(); + await vi.waitFor(() => + expect(resolveBranchNameMock).toHaveBeenCalledTimes(2), + ); + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + + onChange?.(); + await vi.waitFor(() => + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'git_branch_changed', + data: { workspaceCwd: '/workspace', branch: 'feature/web-shell' }, + }), + ); + + state.dispose(); + await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()); + }); + + it('returns null and keeps the watcher shared for a non-git workspace', async () => { + resolveBranchNameMock.mockResolvedValue(undefined); + watchRepoBranchMock.mockResolvedValue(() => {}); + const state = new WorkspaceGitState(); + const bridge = { + publishWorkspaceEvent: vi.fn(), + } as unknown as AcpSessionBridge; + + const [first, second] = await Promise.all([ + state.getStatus('/plain', bridge), + state.getStatus('/plain', bridge), + ]); + + expect(first.branch).toBeNull(); + expect(second.branch).toBeNull(); + expect(resolveBranchNameMock).toHaveBeenCalledOnce(); + expect(watchRepoBranchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/serve/workspace-git-state.ts b/packages/cli/src/serve/workspace-git-state.ts new file mode 100644 index 00000000000..47f252138e4 --- /dev/null +++ b/packages/cli/src/serve/workspace-git-state.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; + +export interface WorkspaceGitStatus { + v: 1; + workspaceCwd: string; + branch: string | null; +} + +interface WorkspaceGitEntry { + branch: string | undefined; + dispose: () => void; +} + +export class WorkspaceGitState { + private readonly entries = new Map>(); + + async getStatus( + workspaceCwd: string, + bridge: AcpSessionBridge, + ): Promise { + const entry = await this.getOrCreateEntry(workspaceCwd, bridge); + return { v: 1, workspaceCwd, branch: entry.branch ?? null }; + } + + dispose(): void { + for (const pending of this.entries.values()) { + void pending.then((entry) => entry.dispose()).catch(() => {}); + } + this.entries.clear(); + } + + private getOrCreateEntry( + workspaceCwd: string, + bridge: AcpSessionBridge, + ): Promise { + const existing = this.entries.get(workspaceCwd); + if (existing) return existing; + + const pending = this.createEntry(workspaceCwd, bridge).catch((error) => { + this.entries.delete(workspaceCwd); + throw error; + }); + this.entries.set(workspaceCwd, pending); + return pending; + } + + private async createEntry( + workspaceCwd: string, + bridge: AcpSessionBridge, + ): Promise { + const entry: WorkspaceGitEntry = { + branch: await resolveBranchName(workspaceCwd), + dispose: () => {}, + }; + const refresh = async () => { + const branch = await resolveBranchName(workspaceCwd); + if (branch === entry.branch) return; + entry.branch = branch; + bridge.publishWorkspaceEvent({ + type: 'git_branch_changed', + data: { workspaceCwd, branch: branch ?? null }, + }); + }; + entry.dispose = await watchRepoBranch(workspaceCwd, () => { + void refresh().catch(() => {}); + }); + return entry; + } +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 986d83ab2be..d9bcf9ad150 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -66,6 +66,7 @@ import type { DaemonWorkspaceAgentDetail, DaemonWorkspaceAgentsStatus, DaemonWorkspaceEnvStatus, + DaemonWorkspaceGitStatus, DaemonWorkspaceMcpStatus, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, @@ -687,6 +688,7 @@ export class DaemonClient { body?: unknown; clientId?: string; timeoutMs?: number; + mode?: 'transport' | 'rest'; } = {}, ): Promise { return await this.jsonRequest( @@ -815,6 +817,14 @@ export class DaemonClient { ); } + async workspaceGit(): Promise { + return await this.jsonRequest( + '/workspace/git', + 'GET /workspace/git', + { mode: 'rest' }, + ); + } + async workspaceMcpTools( serverName: string, ): Promise { @@ -3475,6 +3485,15 @@ export class WorkspaceDaemonClient { return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); } + workspaceGit(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/git', + 'GET /workspaces/:workspace/git', + { mode: 'rest' }, + ); + } + workspaceSkills(): Promise { return this.get('/skills', 'GET /workspaces/:workspace/skills'); } diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 7e3d15a71b5..bc1415667ec 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -153,6 +153,7 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ // Carries `currentModelId` and `currentApprovalMode` so reconnecting // clients can seed their reducer without an extra round-trip. 'session_snapshot', + 'git_branch_changed', ] as const; const DAEMON_KNOWN_EVENT_TYPES: ReadonlySet = new Set( diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 4a880950960..3719007dc4b 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -33,6 +33,14 @@ export interface DaemonWorkspaceCapability { trusted: boolean; } +/** Current Git branch metadata returned from a workspace Git status route. */ +export interface DaemonWorkspaceGitStatus { + v: 1; + workspaceCwd: string; + /** Branch name, short detached-HEAD hash, or null outside a Git repository. */ + branch: string | null; +} + /** Capabilities envelope returned from `GET /capabilities`. */ export interface DaemonCapabilities { v: 1; diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 5344e939515..22107798764 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -261,6 +261,9 @@ export function normalizeDaemonEvent( return normalizeApprovalModeChanged(event, base); // ── Workspace events ────────────────────────────────────── + case 'git_branch_changed': + return []; + case 'memory_changed': return normalizeMemoryChanged(event, base); diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 2710d102c41..43a248ad2c0 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -599,6 +599,51 @@ describe('DaemonClient', () => { ]); }); + it('reads primary and workspace-qualified Git status over REST', async () => { + const primary = { + v: 1 as const, + workspaceCwd: '/work/main', + branch: 'main', + }; + const secondary = { + v: 1 as const, + workspaceCwd: '/work/secondary', + branch: 'feature/web-shell', + }; + const { fetch, calls } = recordingFetch((req) => + jsonResponse( + 200, + req.url.endsWith('/workspace/git') ? primary : secondary, + ), + ); + const transportFetch = vi.fn(async () => + jsonResponse(500, { error: 'transport should not be used' }), + ); + const transport: DaemonTransport = { + type: 'acp-http', + supportsReplay: true, + connected: true, + fetch: transportFetch, + async *subscribeEvents() {}, + dispose() {}, + }; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + transport, + }); + + await expect(client.workspaceGit()).resolves.toEqual(primary); + await expect( + client.workspaceByCwd('/work/secondary').workspaceGit(), + ).resolves.toEqual(secondary); + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['GET', 'http://daemon/workspace/git'], + ['GET', 'http://daemon/workspaces/%2Fwork%2Fsecondary/git'], + ]); + expect(transportFetch).not.toHaveBeenCalled(); + }); + it('lets ACP preheat wait longer than the client default timeout', async () => { let resolveResponse: ((value: Response) => void) | undefined; const slowFetch = vi.fn( diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e03fa9307fa..d5d70b351bb 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5863,6 +5863,7 @@ export function App({ onClearQueuedMessages={clearQueuedPrompts} currentMode={currentMode} currentModel={currentModel} + gitBranch={connection.gitBranch} chatWidthMode={chatWidthMode} showChatWidthToggle={!isChatEmptyState} chatWidthToggleMin={chatWidthToggleMin} diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index cd480b48d0c..1652fc4c02b 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -900,6 +900,40 @@ flex-wrap: wrap; } +.gitBranchChip { + display: inline-flex; + min-width: 0; + max-width: 180px; + height: 28px; + padding: 0 8px; + align-items: center; + gap: 5px; + border-radius: 6px; + color: var(--agent-gray-500); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + line-height: 1; +} + +.gitBranchIcon { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.gitBranchIcon svg { + width: 16px; + height: 16px; +} + +.gitBranchText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .toolbarRight { flex-shrink: 0; margin-right: -5px; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 26e1364aa87..56ae414fc72 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -41,10 +41,12 @@ import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; +import { GitBranchIndicator } from './GitBranchIndicator'; import styles from './ChatEditor.module.css'; export type ComposerToolbarAction = | 'approvalMode' + | 'gitBranch' | 'model' | 'commands' | 'files' @@ -53,6 +55,7 @@ export type ComposerToolbarAction = const ACTIVE_TOOLBAR_ACTIONS = [ 'approvalMode', + 'gitBranch', 'model', 'widthMode', 'voice', @@ -84,6 +87,7 @@ interface ChatEditorProps { onClearQueuedMessages?: () => boolean; currentMode?: string; currentModel?: string; + gitBranch?: string; chatWidthMode?: '1000' | 'wide'; showChatWidthToggle?: boolean; chatWidthToggleMin?: number; @@ -911,6 +915,7 @@ export const ChatEditor = memo( onPopQueuedMessages, currentMode = 'default', currentModel = '', + gitBranch, chatWidthMode = '1000', showChatWidthToggle = true, chatWidthToggleMin, @@ -1545,6 +1550,12 @@ export const ChatEditor = memo( )}
+ {gitBranch && showToolbarAction('gitBranch') && ( + + )} {showToolbarAction('approvalMode') && (
{ + it('renders a read-only branch indicator with the complete name', () => { + const branch = 'feature/a-very-long-web-shell-branch-name'; + const ariaLabel = `Current Git branch: ${branch}`; + const container = document.createElement('div'); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const indicator = container.querySelector(`[aria-label="${ariaLabel}"]`); + if (!indicator) throw new Error('branch indicator was not rendered'); + expect(indicator.tagName).toBe('OUTPUT'); + expect(indicator.getAttribute('title')).toBe(branch); + expect(indicator.textContent).toContain(branch); + expect(container.querySelector('button')).toBeNull(); + + act(() => root.unmount()); + }); + + it('localizes the accessible branch label', () => { + expect(getTranslator('en')('git.currentBranch', { branch: 'main' })).toBe( + 'Current Git branch: main', + ); + expect( + getTranslator('zh-CN')('git.currentBranch', { branch: 'main' }), + ).toBe('当前 Git 分支:main'); + }); +}); diff --git a/packages/web-shell/client/components/GitBranchIndicator.tsx b/packages/web-shell/client/components/GitBranchIndicator.tsx new file mode 100644 index 00000000000..2e5e2ab5be7 --- /dev/null +++ b/packages/web-shell/client/components/GitBranchIndicator.tsx @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import styles from './ChatEditor.module.css'; + +function GitBranchIcon() { + return ( + + ); +} + +export function GitBranchIndicator({ + branch, + ariaLabel, +}: { + branch: string; + ariaLabel: string; +}) { + return ( + + + + + {branch} + + ); +} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 15df1eb4d00..de0fef2d004 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -16,6 +16,7 @@ type MessageValue = type Messages = Record; const EN: Messages = { + 'git.currentBranch': (v) => `Current Git branch: ${v?.branch ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', 'about.fastModel': 'Fast Model', @@ -1745,6 +1746,7 @@ const EN: Messages = { const ZH: Messages = { ...EN, + 'git.currentBranch': (v) => `当前 Git 分支:${v?.branch ?? ''}`, // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // a wire name with no entry here falls back to the English display name via // `localizeToolDisplayName`. Proper tool names / acronyms stay in English diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index da28ad91525..25b87764b42 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -453,11 +453,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // session-scoped supported-commands snapshot (which also carries // custom/MCP/workflow commands) still lands once the first prompt // creates a session. - const [providerResult, skillsResult, acpStatusResult] = + const [providerResult, skillsResult, acpStatusResult, gitResult] = await Promise.allSettled([ client.workspaceProviders(), client.workspaceSkills(), client.workspaceAcpStatus(), + effectWorkspaceCwd + ? client.workspaceByCwd(effectWorkspaceCwd).workspaceGit() + : client.workspaceGit(), ]); if (providerResult.status === 'rejected') { console.warn( @@ -498,6 +501,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...current, status: 'connected', workspaceCwd: effectWorkspaceCwd, + gitBranch: + gitResult.status === 'fulfilled' + ? (gitResult.value.branch ?? undefined) + : undefined, models: providerModelStatus.models, currentModel: providerModelStatus.currentModel, currentMode: providerModelStatus.currentMode, @@ -855,14 +862,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { connectionRef.current.skills !== undefined && connectionRef.current.supportedCommands !== undefined && connectionRef.current.context !== undefined; - const [providerResult, commandResult, contextResult] = - canReuseSessionMetadata - ? [undefined, undefined, undefined] - : await Promise.allSettled([ - client.workspaceProviders(), - activeSession.supportedCommands(), - activeSession.context(), - ]); + const gitPromise = client + .workspaceByCwd(activeSession.workspaceCwd) + .workspaceGit(); + const [providerResult, commandResult, contextResult, gitResult] = + await Promise.allSettled([ + canReuseSessionMetadata + ? Promise.resolve(undefined) + : client.workspaceProviders(), + canReuseSessionMetadata + ? Promise.resolve(undefined) + : activeSession.supportedCommands(), + canReuseSessionMetadata + ? Promise.resolve(undefined) + : activeSession.context(), + gitPromise, + ]); const providers = providerResult?.status === 'fulfilled' ? providerResult.value @@ -875,6 +890,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { contextResult?.status === 'fulfilled' ? contextResult.value : undefined; + const gitBranch = + gitResult?.status === 'fulfilled' + ? (gitResult.value.branch ?? undefined) + : undefined; const loadWarningTexts = [ providerResult?.status === 'rejected' ? loadWarningsRef.current?.models @@ -942,6 +961,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { providers: providers ?? current.providers, supportedCommands: supportedCommands ?? current.supportedCommands, context: context ?? current.context, + gitBranch: + gitResult.status === 'fulfilled' ? gitBranch : undefined, capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index 9e1ca914a17..0c8fb1f3063 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -250,6 +250,41 @@ describe('mapWorkspaceSkills', () => { }); describe('updateConnectionFromDaemonEvent', () => { + it('updates and clears the current git branch', () => { + const changed = applyEvent( + { status: 'connected', workspaceCwd: '/workspace', gitBranch: 'main' }, + { + v: 1, + type: 'git_branch_changed', + data: { workspaceCwd: '/workspace', branch: 'feature/web-shell' }, + }, + ); + expect(changed.gitBranch).toBe('feature/web-shell'); + + const cleared = applyEvent(changed, { + v: 1, + type: 'git_branch_changed', + data: { workspaceCwd: '/workspace', branch: null }, + }); + expect(cleared.gitBranch).toBeUndefined(); + }); + + it('ignores git branch changes from a previous workspace', () => { + const current = { + status: 'connected' as const, + workspaceCwd: '/workspace/current', + gitBranch: 'main', + }; + + const next = applyEvent(current, { + v: 1, + type: 'git_branch_changed', + data: { workspaceCwd: '/workspace/previous', branch: 'stale-branch' }, + }); + + expect(next).toBe(current); + }); + it('replaces commands and skills from an available_commands_update', () => { const next = applyEvent( { status: 'connected', workspaceCwd: '/workspace' }, diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 53b21dd0fd1..8e6f691321b 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -257,6 +257,17 @@ export function updateConnectionFromDaemonEvent( } switch (event.type) { + case 'git_branch_changed': { + const data = getRecord(event.data); + const workspaceCwd = getString(data, 'workspaceCwd'); + const branch = getString(data, 'branch'); + setConnection((current) => + workspaceCwd && workspaceCwd !== current.workspaceCwd + ? current + : { ...current, gitBranch: branch }, + ); + break; + } case 'session_metadata_updated': { const data = getRecord(event.data); if (Object.prototype.hasOwnProperty.call(data ?? {}, 'displayName')) { diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 4dbd0c94246..99b2dd89d4f 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -58,6 +58,8 @@ export interface DaemonConnectionState { */ clientId?: string; workspaceCwd?: string; + /** Current Git branch, short detached-HEAD hash, or undefined outside Git. */ + gitBranch?: string; commands?: DaemonCommandInfo[]; skills?: string[]; models?: DaemonModelInfo[]; From ad134732fe31ff3f218066a0ca0497734a4bae73 Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Sat, 11 Jul 2026 21:49:15 +0800 Subject: [PATCH 2/4] fix(web-shell): address git branch indicator review --- .../cli/src/serve/workspace-git-state.test.ts | 22 +++++++++++++++++ packages/sdk-typescript/src/daemon/index.ts | 1 + packages/sdk-typescript/src/index.ts | 1 + .../session/DaemonSessionProvider.test.tsx | 24 ++++++++++++++++--- .../daemon/session/DaemonSessionProvider.tsx | 10 ++++---- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/serve/workspace-git-state.test.ts b/packages/cli/src/serve/workspace-git-state.test.ts index 3e695700e9d..af92cac59c1 100644 --- a/packages/cli/src/serve/workspace-git-state.test.ts +++ b/packages/cli/src/serve/workspace-git-state.test.ts @@ -82,4 +82,26 @@ describe('WorkspaceGitState', () => { expect(resolveBranchNameMock).toHaveBeenCalledOnce(); expect(watchRepoBranchMock).toHaveBeenCalledOnce(); }); + + it('retries after entry creation fails', async () => { + resolveBranchNameMock + .mockRejectedValueOnce(new Error('git unavailable')) + .mockResolvedValueOnce('main'); + watchRepoBranchMock.mockResolvedValue(() => {}); + const state = new WorkspaceGitState(); + const bridge = { + publishWorkspaceEvent: vi.fn(), + } as unknown as AcpSessionBridge; + + await expect(state.getStatus('/retry', bridge)).rejects.toThrow( + 'git unavailable', + ); + await expect(state.getStatus('/retry', bridge)).resolves.toEqual({ + v: 1, + workspaceCwd: '/retry', + branch: 'main', + }); + expect(resolveBranchNameMock).toHaveBeenCalledTimes(2); + expect(watchRepoBranchMock).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 738d27aec66..47bd344d305 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -368,6 +368,7 @@ export type { DaemonWorkspaceTrustState, DaemonWorkspaceTrustStatus, DaemonWorkspaceCapability, + DaemonWorkspaceGitStatus, DaemonAvailableCommand, DaemonArchiveSessionsResult, DaemonCapabilities, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index e1575c13e97..5c192eb9c9e 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -65,6 +65,7 @@ export { type DaemonWorkspaceInitializedData, type DaemonWorkspaceInitializedEvent, type DaemonWorkspaceCapability, + type DaemonWorkspaceGitStatus, type DaemonAvailableCommand, type DaemonCapabilities, type DaemonEnvCell, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 2ae6a8965d6..f666f63a0e2 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -102,6 +102,8 @@ interface MockClient { workspaceSkills: () => Promise; workspaceAcpStatus: () => Promise; workspaceAcpPreheat: () => Promise; + workspaceGit: () => Promise; + workspaceByCwd: (workspaceCwd: string) => Pick; workspaceTools: () => Promise; setWorkspaceToolEnabled: () => Promise; workspaceMemory: () => Promise; @@ -144,6 +146,8 @@ const sdkMocks = vi.hoisted(() => { const workspaceSkills = vi.fn(); const workspaceAcpStatus = vi.fn(); const workspaceAcpPreheat = vi.fn(); + const workspaceGit = vi.fn(); + const workspaceByCwd = vi.fn((_workspaceCwd: string) => ({ workspaceGit })); const workspaceTools = vi.fn(); const setWorkspaceToolEnabled = vi.fn(); const workspaceMemory = vi.fn(); @@ -174,6 +178,8 @@ const sdkMocks = vi.hoisted(() => { workspaceSkills = workspaceSkills; workspaceAcpStatus = workspaceAcpStatus; workspaceAcpPreheat = workspaceAcpPreheat; + workspaceGit = workspaceGit; + workspaceByCwd = workspaceByCwd; workspaceTools = workspaceTools; setWorkspaceToolEnabled = setWorkspaceToolEnabled; workspaceMemory = workspaceMemory; @@ -218,6 +224,8 @@ const sdkMocks = vi.hoisted(() => { workspaceSkills, workspaceAcpStatus, workspaceAcpPreheat, + workspaceGit, + workspaceByCwd, MockDaemonClient, MockDaemonSessionClient, workspaceMcpTools, @@ -274,6 +282,16 @@ const sdkMocks = vi.hoisted(() => { channelLive: true, durationMs: 1, }); + workspaceGit.mockReset(); + workspaceGit.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + branch: 'main', + }); + workspaceByCwd.mockReset(); + workspaceByCwd.mockImplementation((_workspaceCwd: string) => ({ + workspaceGit, + })); workspaceTools.mockReset(); workspaceTools.mockResolvedValue({ v: 1, @@ -5329,12 +5347,12 @@ describe('DaemonSessionProvider', () => { heartbeatFailureThreshold: 2, }); + await vi.waitFor(() => + expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2), + ); await act(async () => { - await wait(10); await flushPromises(); }); - - expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); expect(connection).toMatchObject({ status: 'disconnected', error: 'session gone', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 25b87764b42..70497e3af96 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -862,9 +862,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { connectionRef.current.skills !== undefined && connectionRef.current.supportedCommands !== undefined && connectionRef.current.context !== undefined; - const gitPromise = client - .workspaceByCwd(activeSession.workspaceCwd) - .workspaceGit(); + const gitPromise = activeSession.workspaceCwd + ? client.workspaceByCwd(activeSession.workspaceCwd).workspaceGit() + : client.workspaceGit(); const [providerResult, commandResult, contextResult, gitResult] = await Promise.allSettled([ canReuseSessionMetadata @@ -962,7 +962,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { supportedCommands: supportedCommands ?? current.supportedCommands, context: context ?? current.context, gitBranch: - gitResult.status === 'fulfilled' ? gitBranch : undefined, + gitResult.status === 'fulfilled' + ? gitBranch + : current.gitBranch, capabilities: capabilities ?? current.capabilities, loadingTranscript: undefined, catchingUp: From 9965417141a4a9b17b23fdf9251009ae884221df Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Sat, 11 Jul 2026 23:48:15 +0800 Subject: [PATCH 3/4] test(web-shell): cover git branch review suggestions --- .../src/serve/routes/workspace-git.test.ts | 70 ++++++++ .../cli/src/serve/routes/workspace-git.ts | 33 +++- packages/cli/src/serve/server.ts | 2 + .../client/components/ChatEditor.test.tsx | 162 ++++++++++++++++++ .../session/DaemonSessionProvider.test.tsx | 32 +++- 5 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 packages/web-shell/client/components/ChatEditor.test.tsx diff --git a/packages/cli/src/serve/routes/workspace-git.test.ts b/packages/cli/src/serve/routes/workspace-git.test.ts index 9096ddd444d..3b51e8bb330 100644 --- a/packages/cli/src/serve/routes/workspace-git.test.ts +++ b/packages/cli/src/serve/routes/workspace-git.test.ts @@ -8,6 +8,7 @@ import express from 'express'; import request from 'supertest'; import { describe, expect, it, vi } from 'vitest'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import { sendBridgeError } from '../server/error-response.js'; import type { WorkspaceGitState } from '../workspace-git-state.js'; import type { WorkspaceRegistry, @@ -61,6 +62,7 @@ describe('workspace Git routes', () => { boundWorkspace: '/work/main', bridge, gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, }); const response = await request(app).get('/workspace/git'); @@ -74,6 +76,30 @@ describe('workspace Git routes', () => { expect(getStatus).toHaveBeenCalledWith('/work/main', bridge); }); + it('returns a structured error when bound Git status fails', async () => { + const app = express(); + const bridge = runtime('primary', '/work/main', true).bridge; + const getStatus = vi.fn(async () => { + throw Object.assign(new Error('git failed'), { + code: 'git_status_failed', + }); + }); + registerWorkspaceGitRoutes(app, { + boundWorkspace: '/work/main', + bridge, + gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, + }); + + const response = await request(app).get('/workspace/git'); + + expect(response.status).toBe(500); + expect(response.body).toMatchObject({ + error: 'git failed', + code: 'git_status_failed', + }); + }); + it('uses the selected trusted workspace runtime', async () => { const app = express(); const primary = runtime('primary', '/work/main', true); @@ -86,6 +112,7 @@ describe('workspace Git routes', () => { registerWorkspaceQualifiedGitRoutes(app, { workspaceRegistry: registry([primary, secondary]), gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, }); const response = await request(app).get('/workspaces/secondary/git'); @@ -106,6 +133,7 @@ describe('workspace Git routes', () => { registerWorkspaceQualifiedGitRoutes(app, { workspaceRegistry: registry([primary, untrusted]), gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, }); const response = await request(app).get('/workspaces/untrusted/git'); @@ -114,4 +142,46 @@ describe('workspace Git routes', () => { expect(response.body.code).toBe('untrusted_workspace'); expect(getStatus).not.toHaveBeenCalled(); }); + + it('rejects an unknown workspace before reading Git status', async () => { + const app = express(); + const primary = runtime('primary', '/work/main', true); + const getStatus = vi.fn(); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry: registry([primary]), + gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, + }); + + const response = await request(app).get('/workspaces/missing/git'); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: 'workspace_mismatch', + }); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it('returns a structured error when qualified Git status fails', async () => { + const app = express(); + const primary = runtime('primary', '/work/main', true); + const getStatus = vi.fn(async () => { + throw Object.assign(new Error('qualified git failed'), { + data: { reason: 'watcher' }, + }); + }); + registerWorkspaceQualifiedGitRoutes(app, { + workspaceRegistry: registry([primary]), + gitState: { getStatus } as unknown as WorkspaceGitState, + sendBridgeError, + }); + + const response = await request(app).get('/workspaces/primary/git'); + + expect(response.status).toBe(500); + expect(response.body).toMatchObject({ + error: 'qualified git failed', + data: { reason: 'watcher' }, + }); + }); }); diff --git a/packages/cli/src/serve/routes/workspace-git.ts b/packages/cli/src/serve/routes/workspace-git.ts index 42b0db4602d..fea33d7fb28 100644 --- a/packages/cli/src/serve/routes/workspace-git.ts +++ b/packages/cli/src/serve/routes/workspace-git.ts @@ -6,6 +6,7 @@ import type { Application, Request, Response } from 'express'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import type { SendBridgeError } from '../server/error-response.js'; import type { WorkspaceGitState } from '../workspace-git-state.js'; import type { WorkspaceRegistry, @@ -22,12 +23,17 @@ export function registerWorkspaceGitRoutes( boundWorkspace: string; bridge: AcpSessionBridge; gitState: WorkspaceGitState; + sendBridgeError: SendBridgeError; }, ): void { app.get('/workspace/git', async (_req, res) => { - res - .status(200) - .json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge)); + try { + res + .status(200) + .json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge)); + } catch (err) { + deps.sendBridgeError(res, err, { route: 'GET /workspace/git' }); + } }); } @@ -43,15 +49,24 @@ function resolveTrustedRuntime( export function registerWorkspaceQualifiedGitRoutes( app: Application, - deps: { workspaceRegistry: WorkspaceRegistry; gitState: WorkspaceGitState }, + deps: { + workspaceRegistry: WorkspaceRegistry; + gitState: WorkspaceGitState; + sendBridgeError: SendBridgeError; + }, ): void { app.get('/workspaces/:workspace/git', async (req, res) => { const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); if (!runtime) return; - res - .status(200) - .json( - await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), - ); + const route = 'GET /workspaces/:workspace/git'; + try { + res + .status(200) + .json( + await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), + ); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } }); } diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f2306c646f1..392ea605a1f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -989,10 +989,12 @@ export function createServeApp( boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, gitState: workspaceGitState, + sendBridgeError, }); registerWorkspaceQualifiedGitRoutes(app, { workspaceRegistry, gitState: workspaceGitState, + sendBridgeError, }); // Workspace memory + agents CRUD routes. diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx new file mode 100644 index 00000000000..e9092f3af1c --- /dev/null +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../i18n'; +import { ChatEditor, type ComposerToolbarAction } from './ChatEditor'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), +}); + +vi.mock('../hooks/useComposerCore', async (importOriginal) => { + const React = await import('react'); + const actual = + await importOriginal(); + return { + ...actual, + useComposerCore: () => ({ + containerRef: React.createRef(), + viewRef: { current: null }, + focus: vi.fn(), + submitText: vi.fn(), + clearText: vi.fn(), + getText: vi.fn(() => ''), + hasInput: vi.fn(() => false), + hasContent: false, + handle: { + focus: vi.fn(), + insertText: vi.fn(), + setText: vi.fn(), + clear: vi.fn(), + retryLast: vi.fn(), + addTags: vi.fn(), + removeInlineTags: vi.fn(), + submit: vi.fn(), + }, + pastedImages: [], + removeImage: vi.fn(), + composerTags: [], + removeTopTag: vi.fn(), + addTags: vi.fn(), + removeInlineTags: vi.fn(), + insertText: vi.fn(), + setText: vi.fn(), + submit: vi.fn(), + clear: vi.fn(), + retryLast: vi.fn(), + replaceEditorText: vi.fn(), + shellMode: false, + setShellMode: vi.fn(), + toggleShellMode: vi.fn(), + currentMode: 'default', + sessionName: undefined, + searchState: { + searchMode: false, + searchQuery: '', + searchMatches: [], + searchActiveIndex: 0, + searchInputRef: React.createRef(), + searchUiRef: React.createRef(), + openHistorySearch: vi.fn(), + closeSearch: vi.fn(), + submitSearchMatch: vi.fn(), + handleSearchKeyDown: vi.fn(), + handleSearchInput: vi.fn(), + handleSearchCompositionEnd: vi.fn(), + }, + navigatePrevHistory: vi.fn(), + navigateNextHistory: vi.fn(), + showShortcutHints: false, + followupState: { isVisible: false, suggestion: '' }, + disabled: false, + onAcceptFollowup: vi.fn(), + onDismissFollowup: vi.fn(), + slashMenu: null, + closeSlashMenu: vi.fn(), + selectSlashCompletion: vi.fn(), + acceptSlashCompletion: vi.fn(), + atMenu: null, + closeAtMenu: vi.fn(), + selectAtCompletion: vi.fn(), + acceptAtCompletion: vi.fn(), + enterAtCategory: vi.fn(), + backAtCategories: vi.fn(), + updateAtSearch: vi.fn(), + selectAtTab: vi.fn(), + }), + }; +}); + +let mounted: Array<{ root: Root; container: HTMLDivElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function renderChatEditor(props: { + gitBranch?: string; + visibleToolbarActions?: readonly ComposerToolbarAction[]; +}) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + undefined} + commands={[]} + showChatWidthToggle={false} + currentMode="default" + currentModel="qwen" + {...props} + /> + , + ); + }); + + return container; +} + +describe('ChatEditor git branch toolbar integration', () => { + it('shows the git branch indicator when the branch action is visible', () => { + const container = renderChatEditor({ + gitBranch: 'feature/web-shell', + visibleToolbarActions: ['gitBranch'], + }); + + expect( + container.querySelector( + '[aria-label="Current Git branch: feature/web-shell"]', + ), + ).not.toBeNull(); + }); + + it('hides the git branch indicator without a branch or visible action', () => { + expect( + renderChatEditor({ + visibleToolbarActions: ['gitBranch'], + }).querySelector('[aria-label^="Current Git branch:"]'), + ).toBeNull(); + expect( + renderChatEditor({ + gitBranch: 'main', + visibleToolbarActions: [], + }).querySelector('[aria-label^="Current Git branch:"]'), + ).toBeNull(); + }); +}); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index f666f63a0e2..c12919da899 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -454,10 +454,37 @@ describe('DaemonSessionProvider', () => { status: 'connected', workspaceCwd: '/mock-workspace', currentMode: 'yolo', + gitBranch: 'main', }); expect(connection).not.toHaveProperty('sessionId'); }); + it('populates git branch from the active session workspace', async () => { + sdkMocks.sessions.push(createMockSession()); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-1', + workspaceCwd: '/mock-workspace', + gitBranch: 'main', + }); + expect(sdkMocks.workspaceByCwd).toHaveBeenCalledWith('/mock-workspace'); + }); + it('populates skill slash commands during deferred connect (before first prompt)', async () => { sdkMocks.workspaceProviders.mockResolvedValueOnce({ v: 1, @@ -5397,12 +5424,13 @@ describe('DaemonSessionProvider', () => { heartbeatFailureThreshold: 2, }); + await vi.waitFor(() => + expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2), + ); await act(async () => { - await wait(10); await flushPromises(); }); - expect(heartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); expect(connection).toMatchObject({ status: 'disconnected', error: 'session gone', From 130e986f96069adcf83023c14d1c6c738fd0fdf1 Mon Sep 17 00:00:00 2001 From: han <2992336417@qq.com> Date: Sun, 12 Jul 2026 11:14:54 +0800 Subject: [PATCH 4/4] test(web-shell): fix chat editor test lint --- packages/web-shell/client/components/ChatEditor.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index e9092f3af1c..0c465b86ec6 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -96,7 +96,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { }; }); -let mounted: Array<{ root: Root; container: HTMLDivElement }> = []; +const mounted: Array<{ root: Root; container: HTMLDivElement }> = []; afterEach(() => { for (const { root, container } of mounted.splice(0)) {