From 8b653c0cebef303ddf84a4329afc74973e7c369b Mon Sep 17 00:00:00 2001 From: colafornia Date: Sat, 19 Sep 2026 23:10:35 +0800 Subject: [PATCH 01/10] fix(desktop): stabilize composer across session switches Keep the composer and model controls stable as session state changes, and restore drafts before paint. Preserve permission and usage controls while their session-specific data resolves, with a compact pending usage state. Set a 520px minimum conversation width and keep footer controls on one line as available space narrows. Add regression coverage for the layout and session transition behavior. Generated-by: Codex --- .../composer-layout-contract.test.ts | 61 ++++++++ .../main/__tests__/live-context-usage.test.ts | 90 +++++++++++ apps/desktop/src/renderer/app-shell.tsx | 25 ++-- .../session-inspector/live-context-usage.ts | 3 + .../use-live-context-usage.ts | 80 +++++++++- .../src/renderer/chat-composer-region.tsx | 32 +++- .../inspector/live-context-usage-probe.tsx | 36 ++++- .../features/workhub/ui/workhub-dock.tsx | 2 +- apps/desktop/src/renderer/styles/composer.css | 69 +++++++-- .../src/renderer/styles/workbar/artifacts.css | 63 -------- .../src/renderer/styles/workbar/shell.css | 3 +- apps/desktop/stories/app-shell.stories.tsx | 141 ++++++++++++------ .../__tests__/composer-context-usage.test.tsx | 62 ++++++++ .../composer-draft-caret-focus.test.tsx | 22 +++ .../composer-model-picker-recovery.test.tsx | 109 ++++++++++++++ packages/ui/src/composer.tsx | 50 ++++++- packages/ui/src/styles.css | 7 + packages/ui/src/use-composer-draft.ts | 6 +- 18 files changed, 705 insertions(+), 156 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/composer-layout-contract.test.ts diff --git a/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts b/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts new file mode 100644 index 0000000000..07342859b8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts @@ -0,0 +1,61 @@ +/* + * 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 assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +const composerCssUrl = [ + new URL('../../renderer/styles/composer.css', import.meta.url), + new URL('../../../src/renderer/styles/composer.css', import.meta.url), +].find((candidate) => existsSync(candidate)); + +if (!composerCssUrl) throw new Error('Could not locate renderer/styles/composer.css'); + +const composerCss = readFileSync(composerCssUrl, 'utf8'); + +function rule(selector: string): string { + const escaped = selector.replace(/[.*+?^$()|[\]\\]/g, '\\$&'); + const match = composerCss.match(new RegExp(escaped + '\\s*\\{([^}]*)\\}', 'u')); + assert.ok(match, 'missing composer layout rule: ' + selector); + return match[1] ?? ''; +} + +describe('composer footer layout', () => { + it('lets the left footer shrink without growing its wrapper', () => { + const footerLeft = rule('.maka-composer-astryx div:has(> .maka-composer-left-controls)'); + assert.match(footerLeft, /min-width:\s*0;/u); + assert.doesNotMatch(footerLeft, /flex:/u); + }); + + it('keeps model controls on one row and permits long labels to ellipsize', () => { + const controls = rule('.maka-composer-left-controls'); + assert.match(controls, /flex-wrap:\s*nowrap;/u); + assert.match(controls, /min-width:\s*0;/u); + + const modelSelection = rule('.maka-composer-left-controls .maka-model-selection-controls'); + assert.match(modelSelection, /min-width:\s*0;/u); + assert.match(modelSelection, /flex:\s*0\s+1\s+auto;/u); + assert.match(modelSelection, /max-width:\s*100%;/u); + + const modelText = rule('.maka-composer-model-chip-text'); + assert.match(modelText, /text-overflow:\s*ellipsis;/u); + assert.match(modelText, /white-space:\s*nowrap;/u); + }); +}); diff --git a/apps/desktop/src/main/__tests__/live-context-usage.test.ts b/apps/desktop/src/main/__tests__/live-context-usage.test.ts index 0a7f0abd3d..3a6d062e31 100644 --- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts +++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts @@ -19,8 +19,13 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import { act, createElement, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; import type { SessionEvent } from '@maka/core/events'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; +import type { SessionInspectorService } from '../../renderer/application/contracts/session-inspector/service.js'; +import { useLiveContextUsageState } from '../../renderer/application/contracts/session-inspector/use-live-context-usage.js'; import { createLiveContextUsageTracker, liveContextUsageFromDiagnostics, @@ -239,12 +244,14 @@ describe('createLiveContextUsageTracker', () => { const timer = fakeTimer(); const query = scriptedQuery(); const seen: unknown[] = []; + let failures = 0; const tracker = createLiveContextUsageTracker({ query: query.query, delayMs: 400, schedule: timer.schedule, cancel: timer.cancel, onChange: (usage) => seen.push(usage), + onReadFailure: () => { failures += 1; }, }); tracker.setTarget({ sessionId: 's1', route: ROUTE }); query.pending[0]!.resolve(available()); @@ -255,6 +262,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); await Promise.resolve(); assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]); + assert.equal(failures, 1); tracker.dispose(); }); @@ -409,3 +417,85 @@ describe('createLiveContextUsageTracker', () => { assert.deepEqual(seen, [undefined]); }); }); + +it('reports pending rather than another target usage during a session switch', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + type ContextResult = Awaited>; + const pending: Array<{ sessionId: string; resolve: (value: ContextResult) => void }> = []; + const inspector: SessionInspectorService = { + trace: async () => { throw new Error('not used'); }, + summary: async () => { throw new Error('not used'); }, + context: (sessionId: string) => + new Promise((resolve) => pending.push({ sessionId, resolve })), + subscribeSessionEvents: () => () => undefined, + subscribeUsageChanges: () => () => undefined, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + let renders: Array<{ + sessionId: string; + status: 'pending' | 'available' | 'unavailable'; + usageTokens: number | undefined; + }> = []; + function Probe(props: { sessionId: string }): ReactElement { + const usage = useLiveContextUsageState({ + inspector, + sessionId: props.sessionId, + model: ROUTE.model, + providerType: ROUTE.providerType, + }); + renders.push({ + sessionId: props.sessionId, + status: usage.status, + usageTokens: usage.status === 'available' ? usage.usage.usageTokens : undefined, + }); + return createElement('span'); + } + + try { + await act(() => root.render(createElement(Probe, { sessionId: 's1' }))); + await act(async () => { + pending[0]?.resolve({ ok: true, data: available({ inputTokens: 1_000 }) }); + await Promise.resolve(); + }); + assert.equal(renders.at(-1)?.usageTokens, 1_000); + + renders = []; + await act(() => root.render(createElement(Probe, { sessionId: 's2' }))); + assert.ok(renders.length > 0); + assert.equal(renders.at(-1)?.status, 'pending'); + assert.equal( + renders.some((render) => render.usageTokens === 1_000), + false, + 'the old session usage must not appear in any render for the new target', + ); + await act(async () => { + pending[1]?.resolve({ + ok: true, + data: { status: 'unavailable', reason: 'no_completed_request' }, + }); + await Promise.resolve(); + }); + assert.equal(renders.at(-1)?.status, 'unavailable'); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 8973a377c0..e00f282459 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2483,7 +2483,15 @@ function AppShellContent({ ? shellCopy.configureModelsOnHost(composerProfileName) : undefined} sendBlocked={taskSubmissionHardBlocked} - permissionMode={activePermissionMode} + // A session switch starts a fresh authoritative boundary + // read. Keep the fixed footer slot mounted during that read + // with the session's persisted mode, but leave it disabled + // until the boundary confirms local interaction is allowed. + permissionMode={ + activePermissionMode + ?? activeSessionForView?.permissionMode + ?? newSessionPermissionMode + } // Every "cannot change this mid-turn" gate reads `turnActive`, // the same witness Stop reads. Reading the persisted status // here instead left these toggles live through the whole @@ -2491,7 +2499,9 @@ function AppShellContent({ // mode change to land before the run registers and alter the // execution config of the turn already sent. permissionModeDisabledReason={ - activeStreamingLive + activeId && !activeBoundarySurface.localInteractionAvailable + ? boundaryUnreadableNotice?.detail ?? shellCopy.modeChangeLoading + : activeStreamingLive ? shellCopy.permissionModeStreaming : activeId && turnActive ? shellCopy.permissionModeRunning @@ -2499,13 +2509,10 @@ function AppShellContent({ ? shellCopy.permissionModeWaiting : undefined } - onPermissionModeChange={ - activeBoundarySurface.localInteractionAvailable - ? async mode => { - await setPermissionMode(mode) - } - : undefined - } + onPermissionModeChange={async mode => { + if (!activeBoundarySurface.localInteractionAvailable) return; + await setPermissionMode(mode); + }} planModeActive={activePlanMode} // No pending-keyed disable while a toggle commits: the // pending registries already swallow re-entrant toggles, and diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts index e6852c37e2..c0cc06a847 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts @@ -135,6 +135,7 @@ export function createLiveContextUsageTracker(input: { schedule: (callback: () => void, delayMs: number) => unknown; cancel: (handle: unknown) => void; onChange: (usage: LiveContextUsage | undefined) => void; + onReadFailure?: () => void; }): LiveContextUsageTracker { let target: LiveContextUsageTarget | undefined; let revision = 0; @@ -155,6 +156,8 @@ export function createLiveContextUsageTracker(input: { input.onChange(liveContextUsageFromDiagnostics(diagnostics, current.route)); }, () => { + if (readRevision !== revision) return; + input.onReadFailure?.(); // A failed read leaves the last value standing: it is still the newest // answer anyone has, and blanking it would report "no usage" for a // read that simply failed. diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts index 1933b5e423..6ebd1e6830 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts @@ -25,6 +25,18 @@ import { } from './live-context-usage.js'; import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js'; +interface TargetedLiveContextUsage { + readonly sessionId: string; + readonly model: string | undefined; + readonly providerType: string | undefined; + readonly state: LiveContextUsageState; +} + +export type LiveContextUsageState = + | { readonly status: 'pending' } + | { readonly status: 'available'; readonly usage: LiveContextUsage } + | { readonly status: 'unavailable' }; + /** * The composer gauge's live reading (#4717). * @@ -34,19 +46,28 @@ import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js'; * settled provider request, and this hook keeps the gauge on that snapshot: * an immediate read when the target changes, then a debounced re-read on each * trace-relevant live event, the same signal the inspector's context bar - * follows. When the snapshot cannot vouch for the composer's active route the - * hook says nothing, and the caller falls back to the per-turn anchor. + * follows. The stateful form distinguishes a new target's first read from a + * settled refusal, so the composer does not present "no usage" while the Host + * is still answering. The value-only wrapper remains for consumers that only + * need the available reading. */ -export function useLiveContextUsage(input: { +export function useLiveContextUsageState(input: { readonly inspector: SessionInspectorService; readonly sessionId: string | undefined; readonly model: string | undefined; readonly providerType: string | undefined; -}): LiveContextUsage | undefined { +}): LiveContextUsageState { const { inspector } = input; - const [usage, setUsage] = useState(undefined); + const [snapshot, setSnapshot] = useState(undefined); const { sessionId, model, providerType } = input; useEffect(() => { + let settingTarget = true; + const targetSnapshot = (state: LiveContextUsageState): TargetedLiveContextUsage => ({ + sessionId: sessionId!, + model, + providerType, + state, + }); const tracker = createLiveContextUsageTracker({ query: async (targetSessionId) => { const result = await inspector.context(targetSessionId); @@ -56,13 +77,40 @@ export function useLiveContextUsage(input: { delayMs: TRACE_REFRESH_DEBOUNCE_MS, schedule: (callback, delayMs) => setTimeout(callback, delayMs), cancel: (handle) => clearTimeout(handle as ReturnType), - onChange: setUsage, + onChange: (usage) => { + setSnapshot( + sessionId === undefined + ? undefined + : targetSnapshot( + settingTarget + ? { status: 'pending' } + : usage + ? { status: 'available', usage } + : { status: 'unavailable' }, + ), + ); + }, + onReadFailure: () => { + if (sessionId === undefined) return; + setSnapshot((current) => { + if ( + current?.sessionId === sessionId + && current.model === model + && current.providerType === providerType + && current.state.status === 'available' + ) { + return current; + } + return targetSnapshot({ status: 'unavailable' }); + }); + }, }); tracker.setTarget( sessionId === undefined ? undefined : { sessionId, route: { model, providerType } }, ); + settingTarget = false; const unsubscribe = sessionId === undefined ? undefined @@ -72,5 +120,23 @@ export function useLiveContextUsage(input: { tracker.dispose(); }; }, [inspector, sessionId, model, providerType]); - return usage; + if (sessionId === undefined) return { status: 'unavailable' }; + if ( + snapshot?.sessionId !== sessionId + || snapshot.model !== model + || snapshot.providerType !== providerType + ) { + return { status: 'pending' }; + } + return snapshot.state; +} + +export function useLiveContextUsage(input: { + readonly inspector: SessionInspectorService; + readonly sessionId: string | undefined; + readonly model: string | undefined; + readonly providerType: string | undefined; +}): LiveContextUsage | undefined { + const state = useLiveContextUsageState(input); + return state.status === 'available' ? state.usage : undefined; } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 91b55b8256..9582df2612 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -150,6 +150,8 @@ interface ChatComposerRegionProps */ children: ( usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, + gitBranch: { readonly name?: string; readonly shortSha?: string } | undefined, + usagePending: boolean, ) => ReactNode; }>; directoryComposerProps: Pick< @@ -266,19 +268,37 @@ 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, + liveContextUsagePending: boolean, ) => ( {(goalProjection) => ( 0 + ), } - : contextUsage} + : undefined} // AppShell carries staged attachments into both queued and steering // follow-ups. Other Composer hosts remain gated by default because a // text-only running-turn submission would leave attachments behind. @@ -375,10 +395,10 @@ export function ChatComposerRegion({ model={composerRest.activeModel} providerType={composerRest.activeProviderType} > - {renderComposer} + {(usage, gitBranch, usagePending) => renderComposer(usage, gitBranch, usagePending)} ) : ( - renderComposer(undefined) + renderComposer(undefined, undefined, false) )} ); diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx index e43864ef2d..f997e175ec 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 @@ -20,7 +20,13 @@ 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 { useLiveContextUsageState } 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). @@ -28,22 +34,38 @@ import { useLiveContextUsage } from '../../../../application/contracts/session-i * The live reading needs a subscription and state, and both live here — in * the feature that owns the inspector's context snapshot — so the shell only * renders the reading, the same division of labour as the goal projection's - * render-prop consumer around the same composer. `undefined` means the - * snapshot cannot vouch for the composer's active route; the caller falls - * back to the per-turn anchor. + * render-prop consumer around the same composer. The pending bit lets the + * caller distinguish a new target's first read from a settled refusal; + * `undefined` usage still makes the caller try the per-turn anchor. */ export function LiveContextUsageProbe(props: { readonly sessionId: string | undefined; readonly model: string | undefined; readonly providerType: string | undefined; - readonly children: (usage: LiveContextUsage | undefined) => ReactNode; + readonly children: ( + usage: LiveContextUsage | undefined, + gitBranch: ComposerGitBranch | undefined, + usagePending: boolean, + ) => ReactNode; }): ReactElement { const { inspector } = useWorkbarServices(); - const usage = useLiveContextUsage({ + const usageState = useLiveContextUsageState({ inspector, sessionId: props.sessionId, model: props.model, providerType: props.providerType, }); - return <>{props.children(usage)}; + // Two independent hooks (each in its own module), composed here only because + // this is the single injection point the shell can offer: `app-shell.tsx` is + // token-frozen by the architecture ratchet and `chat-composer-region.tsx` is + // capability-frozen, so a second probe prop cannot be threaded through without + // growing recorded debt. The branch logic is not coupled to the usage reading — + // `useComposerGitBranch` is standalone and tested on its own; only the carrier + // is shared. + const gitBranch = useComposerGitBranch(props.sessionId); + return <>{props.children( + usageState.status === 'available' ? usageState.usage : undefined, + gitBranch, + usageState.status === 'pending', + )}; } diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx index 9e8e6d4e22..b786e6b576 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx @@ -65,7 +65,7 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed }: { ena const host = { visible: enabled && visible && rect.width > 0 && rect.height > 0, occluded, - workbar: { collapsed: collapsed.current, placement: window.matchMedia('(max-width: 990px)').matches ? 'bottom' as const : 'right' as const }, + workbar: { collapsed: collapsed.current, placement: 'right' as const }, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, }; const key = JSON.stringify(host); diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 70dc4523b5..822373c05a 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -264,6 +264,7 @@ .maka-composer-left-controls { display: flex; align-items: center; + min-width: 0; /* PR-REFERENCE-PIXEL-8 (WAWQAQ msg `f79de85f` round 8): reference implementation's bundle uses gap values centered on 4-8px (extracted from `globals-UfMzAdiO.css` — 4px is the most common gap, 12px never @@ -272,26 +273,38 @@ individual controls separable while pulling them into one coherent toolbar group. */ gap: var(--space-1-5); - flex-wrap: wrap; + flex-wrap: nowrap; } -/* Shrinkable so the `flex-wrap: wrap` above can actually engage. At - `flex: 0 0 auto` this box sizes to max-content, which means it never has a - width to wrap inside — it just overflows, and the overflow runs under the - send button and past the card's right edge. That stayed invisible while the - row held four controls; the project picker adds to it and a 480px window has - nowhere to put the overflow. `min-width: 0` because the default - `auto` floor would keep the same overflow for a long model name. */ +/* The footer stays one control row. Text-bearing controls below absorb the + shrink while icon actions and the send slot retain their fixed geometry. */ .maka-composer-left-controls { flex: 1 1 auto; min-width: 0; } +/* Release Astryx's footer-left wrapper's intrinsic minimum. Its own default + flex sizing keeps the footer compact; assigning flex-grow here would also + change the slot's size instead of only allowing horizontal shrink. */ +.maka-composer-astryx div:has(> .maka-composer-left-controls) { + min-width: 0; + max-width: 100%; +} + /* Quiet footer: + and permission are both ghost icon buttons. */ .maka-composer-left-controls .permissionModeIcon, .maka-composer-left-controls .maka-composer-plus-menu { display: inline-flex; align-items: center; + flex: 0 0 auto; +} + +/* Boundary reads briefly disable the permission action on every session + switch. Keep the quiet toolbar icon visually stable while Astryx continues + to enforce aria-disabled, block activation, and expose the reason tooltip. */ +.maka-composer-left-controls .permissionModeIcon [aria-disabled='true'], +.maka-composer-left-controls .permissionModeIcon button:disabled { + opacity: 1; } /* Cursor: product-wide native-cursor.css (maka.legacy) owns default vs pointer. */ @@ -338,6 +351,40 @@ 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; + flex: 0 1 auto; + min-width: calc(var(--icon-meta) + var(--space-1)); + max-width: min(360px, 40vw); +} + +.maka-composer-git-branch > svg { + flex: 0 0 auto; +} + +.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); @@ -392,13 +439,15 @@ } /* Model + thinking pair lives in left-controls (after permission), not send. */ .maka-composer-left-controls .maka-model-selection-controls { + flex: 0 1 auto; min-width: 0; - max-width: min(420px, 52vw); + max-width: 100%; } .maka-composer-left-controls .maka-model-switcher-trigger, .maka-composer-left-controls .maka-new-chat-model-selector { + flex: 0 1 auto; min-width: 100px; - max-width: min(220px, 28vw); + max-width: 220px; } .maka-composer-model-chip-text { min-width: 0; diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css index e0ff7745ac..e2266128ea 100644 --- a/apps/desktop/src/renderer/styles/workbar/artifacts.css +++ b/apps/desktop/src/renderer/styles/workbar/artifacts.css @@ -369,66 +369,3 @@ .maka-artifact-preview-spinner { flex: 0 0 auto; } - -/* Narrow windows place the single workbar below the conversation. */ -@media (max-width: 990px) { - .maka-workbar-edge:not([data-placement]) { - top: auto; right: auto; bottom: 12px; left: 50%; transform: translateX(-50%); width: 112px; height: 12px; - } - .maka-workbar-edge:not([data-placement]) .maka-workbar-edge-glass { - top: auto; right: auto; bottom: -12px; left: 50%; width: 112px; height: 28px; - clip-path: path('M0 28 C22 28 27 0 56 0 C85 0 90 28 112 28 Z'); - transform-origin: center bottom; transform: translateX(-50%) scaleY(.08); - } - .maka-workbar-edge:not([data-placement]):is(:hover, :focus-visible) .maka-workbar-edge-glass { transform: translateX(-50%) scaleY(1); } - .maka-workbar-edge:not([data-placement]):active .maka-workbar-edge-glass { transform: translateX(-50%) scaleY(.86); } - .maka-workbar-edge:not([data-placement]) svg { rotate: 90deg; translate: 0 -4px; } - @media (hover: none) { - .maka-workbar-edge:not([data-placement]) .maka-workbar-edge-glass { transform: translateX(-50%) scaleY(1); } - } - .maka-window-titlebar { - --maka-titlebar-workbar-reserve: 0px; - } - - .maka-detail-with-artifacts { - grid-template-areas: - "main" - "bottom-handle" - "bottom" - "right-handle" - "right"; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr) 0 auto 0 auto; - } - - .maka-session-workbar[data-placement], - .maka-session-workbar-panel[data-overlay][data-placement] { - width: 100%; - min-width: 0; - min-height: min(220px, 42dvh); - max-height: min(42dvh, 360px); - height: min(42dvh, 360px); - margin-left: 0; - margin-top: var(--agents-content-area-gap); - padding-top: 0; - } - - .maka-session-workbar-panel[data-overlay][data-placement] { - padding-top: var(--size-element-sm); - } - - .maka-workbar-resize-handle { display: none; } - - .maka-session-workbar .maka-browser-panel, - .maka-session-workbar .maka-artifact-pane { - width: 100%; - min-height: 0; - max-height: none; - border: 0; - box-shadow: none; - } - - .maka-artifact-preview { - min-height: 120px; - } -} diff --git a/apps/desktop/src/renderer/styles/workbar/shell.css b/apps/desktop/src/renderer/styles/workbar/shell.css index 778e4f0959..dc573085c4 100644 --- a/apps/desktop/src/renderer/styles/workbar/shell.css +++ b/apps/desktop/src/renderer/styles/workbar/shell.css @@ -26,13 +26,14 @@ /* ──────────────────────────────────────────────────────────────────────── */ .maka-detail-with-artifacts { + --maka-conversation-min-width: 520px; position: relative; display: grid; grid-template-areas: "main right-handle right" "bottom-handle right-handle right" "bottom right-handle right"; - grid-template-columns: minmax(0, 1fr) 0 auto; + grid-template-columns: minmax(var(--maka-conversation-min-width), 1fr) 0 auto; grid-template-rows: minmax(0, 1fr) 0 auto; flex: 1 1 auto; height: auto; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 91122169bb..bbc89c635e 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -3429,7 +3429,13 @@ const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout( { type: 'open', placement: 'right', tab: { id: 'workbar:files', kind: 'files' } }, ); -function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; workbarWidth?: number; withConversation?: boolean } = {}) { +function WorkbarInShell(props: { + longTitle?: boolean; + onShare?: () => void; + workbarWidth?: number; + withConversation?: boolean; + composer?: Partial; +} = {}) { const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace); const resizable = useResizable({ defaultSize: props.workbarWidth ?? layout.rightWidth, @@ -3451,7 +3457,13 @@ function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; work detailChildren={
- {props.withConversation && }> + {props.withConversation && + )}> }
@@ -3611,62 +3623,99 @@ export const WorkbarEdgeRevealAndCollapse: Story = { const narrowWorkbarShare = fn(); export const NarrowWorkbarClearsTitlebarReserve: Story = { +======= +// Real path: a session with the right workbar open while the conversation +// column is narrow enough for long model and Git branch labels to exercise the +// composer's footer shrink contract. +export const NarrowComposerFooter: Story = { + parameters: { + viewport: { + options: { + composerNarrow: { + name: 'Maka desktop with a narrow conversation column', + styles: { width: '1200px', height: '800px' }, + type: 'desktop' as const, + }, + }, + }, + }, + globals: { viewport: { value: 'composerNarrow', isRotated: false } }, +>>>>>>> 6593352fa (fix(desktop): stabilize composer across session switches) render: () => ( ), play: async ({ canvasElement }) => { - narrowWorkbarShare.mockClear(); - const canvas = within(canvasElement); - const titlebar = canvasElement.querySelector('.maka-window-titlebar'); - const identity = canvasElement.querySelector( - '[data-maka-contract="titlebar-identity"]', - ); - const detail = canvasElement.querySelector('.maka-detail-with-artifacts'); - const workbar = canvasElement.querySelector( - '.maka-session-workbar[data-placement="right"]:not([data-collapsed])', - ); - if (!titlebar || !identity || !detail || !workbar) { - throw new Error('the titlebar, identity, detail area, or right workbar is missing'); - } + const mainColumn = canvasElement.querySelector('.maka-detail-with-artifacts > .mainColumn'); + const card = mainColumn?.querySelector('.maka-composer-astryx'); + if (!mainColumn || !card) throw new Error('the narrow conversation composer is missing'); - // A bottom Workbar must not take width from the title. Share can remain - // clickable even when a stale right-side reserve squeezes the title away. - await userEvent.click(canvas.getByRole('button', { name: '收起任务工作栏' })); - const restore = await canvas.findByRole('button', { name: '展开任务工作栏' }); - await waitFor(() => expect(workbar).not.toBeVisible()); - const collapsedIdentityWidth = identity.getBoundingClientRect().width; - expect(collapsedIdentityWidth).toBeGreaterThan(0); + const leftControls = card.querySelector('.maka-composer-left-controls'); + if (!leftControls) throw new Error('composer footer controls are missing'); + expect(getComputedStyle(leftControls).flexWrap).toBe('nowrap'); - await userEvent.click(restore); + const send = within(card).getByRole('button', { name: '发送' }); + const branch = card.querySelector('.maka-composer-git-branch'); + if (!branch) throw new Error('composer branch readout is missing'); await waitFor(() => { - expect(workbar).toBeVisible(); - // The edge control never takes space from the titlebar. - expect(identity.getBoundingClientRect().width).toBeGreaterThanOrEqual( - collapsedIdentityWidth - 1, - ); + const cardBox = card.getBoundingClientRect(); + const sendBox = send.getBoundingClientRect(); + const branchBox = branch.getBoundingClientRect(); + expect(sendBox.left).toBeGreaterThanOrEqual(cardBox.left - 1); + expect(sendBox.right).toBeLessThanOrEqual(cardBox.right + 1); + expect(branchBox.right).toBeLessThanOrEqual(cardBox.right + 1); }); + }, +}; +// Real path: a session with the right workbar open while the conversation +// column is narrow enough for a long model label to exercise the composer's +// footer shrink contract. +export const NarrowComposerFooter: Story = { + parameters: { + viewport: { + options: { + composerNarrow: { + name: 'Maka desktop with a narrow conversation column', + styles: { width: '1200px', height: '800px' }, + type: 'desktop' as const, + }, + }, + }, + }, + globals: { viewport: { value: 'composerNarrow', isRotated: false } }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const mainColumn = canvasElement.querySelector('.maka-detail-with-artifacts > .mainColumn'); + const card = mainColumn?.querySelector('.maka-composer-astryx'); + if (!mainColumn || !card) throw new Error('the narrow conversation composer is missing'); - const share = identity.querySelector('[aria-label$="任务操作"]')!; - await waitFor(() => - expect(share.getBoundingClientRect().left).toBeGreaterThanOrEqual( - titlebar.getBoundingClientRect().left, - ), - ); - expect(workbar.getBoundingClientRect().width).toBeCloseTo( - detail.getBoundingClientRect().width, - 0, - ); - expect(share.getBoundingClientRect().right).toBeLessThanOrEqual( - titlebar.getBoundingClientRect().right, - ); + const leftControls = card.querySelector('.maka-composer-left-controls'); + if (!leftControls) throw new Error('composer footer controls are missing'); + expect(getComputedStyle(leftControls).flexWrap).toBe('nowrap'); - await userEvent.click(share); - await userEvent.click(await within(canvasElement.ownerDocument.body).findByRole('menuitem', { name: '分享任务' })); - expect(narrowWorkbarShare).toHaveBeenCalledOnce(); + const send = within(card).getByRole('button', { name: '发送' }); + await waitFor(() => { + const cardBox = card.getBoundingClientRect(); + const sendBox = send.getBoundingClientRect(); + expect(sendBox.left).toBeGreaterThanOrEqual(cardBox.left - 1); + expect(sendBox.right).toBeLessThanOrEqual(cardBox.right + 1); + }); }, }; diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 9f6c229f40..6f3fca5c09 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -78,6 +78,68 @@ test('the context usage action opens its host trace surface', async () => { } }); +test('the context usage action keeps one control while its reading resolves', 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); + + try { + await act(() => root.render( + + undefined }} + onSend={() => undefined} + onStop={() => undefined} + /> + , + )); + const pendingAction = container.querySelector( + 'button[aria-label="Open usage trace"]', + ); + assert.ok(pendingAction); + assert.equal(pendingAction.getAttribute('aria-busy'), 'true'); + assert.equal(pendingAction.textContent?.trim(), '--%'); + assert.ok(pendingAction.querySelector('.maka-context-usage-value')); + + await act(() => root.render( + + undefined, + }} + onSend={() => undefined} + onStop={() => undefined} + /> + , + )); + const resolvedAction = container.querySelector( + 'button[aria-label="Open usage trace"]', + ); + assert.equal(resolvedAction, pendingAction); + assert.equal(resolvedAction?.getAttribute('aria-busy'), null); + assert.equal(resolvedAction?.textContent?.trim(), '40%'); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); + test('the context usage share resolves declared, then metered, then metadata window', async () => { const original = { document: globalThis.document, diff --git a/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx b/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx index db1c2335f7..fa3f89194e 100644 --- a/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx +++ b/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx @@ -47,6 +47,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; import { parseHTML } from 'linkedom'; import { Composer } from '../composer.js'; import { LocaleProvider } from '../locale-context.js'; @@ -200,6 +201,17 @@ function harness() { ); }); }, + async renderSync(props: Parameters[0]) { + await act(() => { + flushSync(() => { + root.render( + + + , + ); + }); + }); + }, }; } @@ -273,3 +285,13 @@ test('a session swap leaves focus on the row that caused it', async () => { 'the restored caret took focus out from under the row the user activated', ); }); + +test('a session swap paints the incoming draft before passive effects run', async () => { + const dom = harness(); + await dom.render({ ...withDraft('session-a', 'first draft'), draftKey: 'session-a' }); + await dom.focus(dom.outside()); + + await dom.renderSync({ ...withDraft('session-b', 'second draft'), draftKey: 'session-b' }); + assert.equal(dom.editable().textContent, 'second draft'); + assert.equal(dom.focused(), dom.outside(), 'restoring the incoming draft must not steal focus'); +}); diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx index 05bd362f04..b529cfdf48 100644 --- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx +++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx @@ -229,6 +229,115 @@ test('the recovery handle opens the existing exact account-and-model picker', as } }); +test('keeps the model trigger mounted and interactive across sessions', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + HTMLBRElement: globalThis.HTMLBRElement, + Node: globalThis.Node, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => + new Proxy( + { direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }, + { get: (target, key) => (key in target ? target[key as keyof typeof target] : '') }, + ) as unknown as CSSStyleDeclaration; + window.matchMedia = () => + ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList; + window.scrollTo = () => {}; + window.scrollBy = () => {}; + window.getSelection = () => + ({ + rangeCount: 0, + isCollapsed: true, + anchorNode: null, + focusNode: null, + removeAllRanges() {}, + addRange() {}, + getRangeAt: () => { + throw new Error('no range'); + }, + }) as unknown as Selection; + document.createRange = () => + ({ + selectNodeContents() {}, + collapse() {}, + cloneRange() { + return this; + }, + }) as unknown as Range; + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { this.setAttribute('open', ''); }, + show(this: HTMLElement) { this.setAttribute('open', ''); }, + close(this: HTMLElement) { this.removeAttribute('open'); }, + }); + Object.assign(globalThis, { + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + HTMLBRElement: window.HTMLBRElement, + Node: window.Node, + matchMedia: window.matchMedia, + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const choice: ChatModelChoice = { + connectionId: 'connection-openrouter', + connectionSlug: 'openrouter', + connectionName: 'OpenRouter', + providerType: 'openrouter', + providerLabel: 'OpenRouter', + model: 'openai/gpt-5', + label: 'GPT-5', + isDefault: true, + thinkingLevels: [], + }; + const render = (sessionId: string) => root.render( + + undefined} + onSend={() => undefined} + onStop={() => undefined} + /> + , + ); + + try { + await act(() => render('session-a')); + const triggerBefore = container.querySelector('.maka-model-switcher-trigger'); + assert.ok(triggerBefore); + const buttonBefore = triggerBefore.querySelector('[aria-expanded]'); + assert.ok(buttonBefore); + + await act(() => render('session-b')); + const triggerAfter = container.querySelector('.maka-model-switcher-trigger'); + assert.equal(triggerAfter, triggerBefore, 'session changes must preserve the model trigger DOM'); + assert.equal( + triggerAfter?.querySelector('[aria-expanded]')?.getAttribute('aria-readonly'), + null, + 'session changes must not create a transient read-only trigger', + ); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); + test('the thinking picker survives levels arriving after mount', async () => { // Thinking levels resolve asynchronously; a picker that mounts variantless // must not change its hook count when they land. diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 380f238c48..c975046e12 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -39,6 +39,7 @@ import { ArrowUp, CircleGauge, FileText, + GitBranch, ListTodo, MessagesSquare, Network, @@ -447,6 +448,8 @@ export const Composer = forwardRef< /** Read-only usage indicator for the active model's latest request. */ contextUsage?: { usageTokens?: number; + /** The active target is still resolving its first authoritative reading. */ + pending?: boolean; declaredContextWindow?: number; /** * The window the usage number was metered against, frozen at call time. @@ -458,6 +461,17 @@ export const Composer = forwardRef< /** Open the Host-owned trace surface for this readout. */ onOpen(): void; }; + /** + * The working tree's Git branch, beside the context-usage readout. Omitted + * entirely when the session's directory is not a Git repository, so the + * chip simply does not exist there rather than sitting empty. + */ + gitBranch?: { + /** The branch name, or `undefined` on a detached HEAD. */ + name?: string; + /** The short commit sha, set only when `name` is absent. */ + shortSha?: string; + }; /** * Optional edit-and-resend banner above the composer. Desktop owns the * revision draft; Composer only renders the notice + cancel affordance. @@ -2324,6 +2338,7 @@ export const Composer = forwardRef< /> )} {props.contextUsage ? : null} + {props.gitBranch ? : null}
{/* The project decides where a NEW chat starts, which makes it a parameter of this send like the model beside it — so it sits @@ -2436,6 +2451,7 @@ export const Composer = forwardRef< function ContextUsageAction(props: { usageTokens?: number; + pending?: boolean; declaredContextWindow?: number; meteredContextWindow?: number; metadataContextWindow?: number; @@ -2452,11 +2468,15 @@ function ContextUsageAction(props: { const window = props.declaredContextWindow ?? props.meteredContextWindow ?? props.metadataContextWindow; const label = - props.usageTokens !== undefined && window !== undefined && window > 0 + props.pending + ? '--%' + : props.usageTokens !== undefined && window !== undefined && window > 0 ? `${Math.round((props.usageTokens / window) * 100)}%` : copy.systemNotes.contextUsageLabel; const tooltip = - props.usageTokens === undefined + props.pending + ? copy.systemNotes.contextUsageOpen + : props.usageTokens === undefined ? copy.systemNotes.contextUsageUnavailable : window !== undefined && window > 0 ? copy.systemNotes.contextUsageShare(props.usageTokens, window) @@ -2469,10 +2489,34 @@ function ContextUsageAction(props: { label={copy.systemNotes.contextUsageOpen} tooltip={tooltip} onClick={props.onOpen} + aria-busy={props.pending || undefined} > - {label} + {label} ); } +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; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index ff581cc4a3..42abe62187 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -33,6 +33,13 @@ .maka-model-wheel-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--muted-foreground); transform: scale(0.96); transform-origin: left center; transition: transform 180ms ease-out, color 180ms ease-out; } .maka-model-wheel-option[data-active='true'] .maka-model-wheel-label { font-weight: 600; color: var(--foreground); transform: scale(1); } .maka-model-wheel-viewport[aria-disabled='true'] { opacity: 0.5; overflow-y: hidden; } + +.maka-context-usage-value { + display: inline-block; + min-width: 3ch; + text-align: center; + font-variant-numeric: tabular-nums; +} @media (prefers-reduced-motion: reduce) { .maka-model-wheel-label { transition: none; } } .maka-model-wheel-provider { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--muted-foreground); } .maka-model-wheel-check { position: absolute; right: 8px; top: 50%; translate: 0 -50%; color: var(--muted-foreground); } diff --git a/packages/ui/src/use-composer-draft.ts b/packages/ui/src/use-composer-draft.ts index c440cca296..12c63fba04 100644 --- a/packages/ui/src/use-composer-draft.ts +++ b/packages/ui/src/use-composer-draft.ts @@ -35,7 +35,7 @@ * same moment without this hook depending on them. */ -import { useEffect, useRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import type { ComposerTextPort } from './chat-input-behavior.js'; import { appendPromptContextDraft, @@ -118,7 +118,7 @@ export function useComposerDraft(input: { return activeDraftKeyRef.current; } - useEffect(() => { + useLayoutEffect(() => { const previousKey = activeDraftKeyRef.current; const nextKey = input.draftKey; if (previousKey === nextKey) return; @@ -134,7 +134,7 @@ export function useComposerDraft(input: { input.text.setValue(nextDraft); }, [input.draftKey]); - useEffect(() => { + useLayoutEffect(() => { const key = activeDraftKeyRef.current; const persisted = input.persistence?.read(key); if (!persisted) return; From ef583c4eb2ddb198a0fd5486433b46c917c5b007 Mon Sep 17 00:00:00 2001 From: colafornia Date: Sat, 19 Sep 2026 23:43:15 +0800 Subject: [PATCH 02/10] fix(desktop): reserve composer send button space Constrain the left footer slot to the width left by the fixed send action and let the model label shrink sooner in narrow conversations. Generated-by: Codex --- apps/desktop/src/renderer/styles/composer.css | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 822373c05a..2a22bb93e5 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -283,12 +283,14 @@ min-width: 0; } -/* Release Astryx's footer-left wrapper's intrinsic minimum. Its own default - flex sizing keeps the footer compact; assigning flex-grow here would also - change the slot's size instead of only allowing horizontal shrink. */ +/* Astryx's footer slots otherwise size from their content. Give the left + controls only the space left after the fixed send slot and footer gap. */ .maka-composer-astryx div:has(> .maka-composer-left-controls) { + flex: 1 1 0; min-width: 0; - max-width: 100%; +} +.maka-composer-astryx div:has(> .maka-composer-left-controls) + div { + flex: 0 0 auto; } /* Quiet footer: + and permission are both ghost icon buttons. */ @@ -439,14 +441,14 @@ } /* Model + thinking pair lives in left-controls (after permission), not send. */ .maka-composer-left-controls .maka-model-selection-controls { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; max-width: 100%; } .maka-composer-left-controls .maka-model-switcher-trigger, .maka-composer-left-controls .maka-new-chat-model-selector { flex: 0 1 auto; - min-width: 100px; + min-width: 72px; max-width: 220px; } .maka-composer-model-chip-text { From 67138f54ad0b2633c9e5db82b2cf6a7f8e2db62c Mon Sep 17 00:00:00 2001 From: colafornia Date: Sat, 19 Sep 2026 23:59:17 +0800 Subject: [PATCH 03/10] fix(desktop): shrink the composer model field before controls overlap Apply width constraints to the Selector Field wrapper and preserve the start of model labels when truncating. Place the pending usage accessibility state on its value node and update the affected assertions. Generated-by: Codex --- .../__tests__/composer-layout-contract.test.ts | 6 +++--- apps/desktop/src/renderer/styles/composer.css | 18 +++++++++++++++--- .../__tests__/composer-context-usage.test.tsx | 7 +++++-- packages/ui/src/composer.tsx | 3 +-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts b/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts index 07342859b8..e14990c2b3 100644 --- a/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts +++ b/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts @@ -38,10 +38,10 @@ function rule(selector: string): string { } describe('composer footer layout', () => { - it('lets the left footer shrink without growing its wrapper', () => { + it('allocates the remaining footer width to the left controls', () => { const footerLeft = rule('.maka-composer-astryx div:has(> .maka-composer-left-controls)'); assert.match(footerLeft, /min-width:\s*0;/u); - assert.doesNotMatch(footerLeft, /flex:/u); + assert.match(footerLeft, /flex:\s*1\s+1\s+0;/u); }); it('keeps model controls on one row and permits long labels to ellipsize', () => { @@ -51,7 +51,7 @@ describe('composer footer layout', () => { const modelSelection = rule('.maka-composer-left-controls .maka-model-selection-controls'); assert.match(modelSelection, /min-width:\s*0;/u); - assert.match(modelSelection, /flex:\s*0\s+1\s+auto;/u); + assert.match(modelSelection, /flex:\s*1\s+1\s+auto;/u); assert.match(modelSelection, /max-width:\s*100%;/u); const modelText = rule('.maka-composer-model-chip-text'); diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 2a22bb93e5..061ae0e04e 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -445,12 +445,24 @@ min-width: 0; max-width: 100%; } -.maka-composer-left-controls .maka-model-switcher-trigger, -.maka-composer-left-controls .maka-new-chat-model-selector { +/* Selector's className belongs to the trigger INSIDE its Field. The Field + is the model group's flex item, so its automatic content minimum must be + released here for the label to yield space to usage, branch, and send. */ +.maka-composer-left-controls .maka-model-selection-controls > .astryx-field:has(.maka-model-switcher-trigger, .maka-new-chat-model-selector) { flex: 0 1 auto; - min-width: 72px; + min-width: 100px; max-width: 220px; } +.maka-composer-left-controls .maka-model-switcher-trigger, +.maka-composer-left-controls .maka-new-chat-model-selector { + min-width: 0; + max-width: 100%; +} +/* Keep the model family and version visible in the closed Composer picker. */ +.maka-composer-left-controls .maka-model-switcher-trigger .modelPickerOptionLabel, +.maka-composer-left-controls .maka-new-chat-model-selector .modelPickerOptionLabel { + direction: ltr; +} .maka-composer-model-chip-text { min-width: 0; overflow: hidden; diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 6f3fca5c09..9307b0f035 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -111,7 +111,9 @@ test('the context usage action keeps one control while its reading resolves', as 'button[aria-label="Open usage trace"]', ); assert.ok(pendingAction); - assert.equal(pendingAction.getAttribute('aria-busy'), 'true'); + const value = pendingAction.querySelector('.maka-context-usage-value'); + assert.ok(value); + assert.equal(value.getAttribute('aria-busy'), 'true'); assert.equal(pendingAction.textContent?.trim(), '--%'); assert.ok(pendingAction.querySelector('.maka-context-usage-value')); @@ -132,7 +134,8 @@ test('the context usage action keeps one control while its reading resolves', as 'button[aria-label="Open usage trace"]', ); assert.equal(resolvedAction, pendingAction); - assert.equal(resolvedAction?.getAttribute('aria-busy'), null); + assert.equal(resolvedAction?.querySelector('.maka-context-usage-value'), value); + assert.equal(value.getAttribute('aria-busy'), null); assert.equal(resolvedAction?.textContent?.trim(), '40%'); } finally { await act(() => root.unmount()); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index c975046e12..0ba8364dd5 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -2489,9 +2489,8 @@ function ContextUsageAction(props: { label={copy.systemNotes.contextUsageOpen} tooltip={tooltip} onClick={props.onOpen} - aria-busy={props.pending || undefined} > - {label} + {label} ); } From 05dc881bb513605ecd699aed0c42a96d0450f2b7 Mon Sep 17 00:00:00 2001 From: colafornia Date: Sun, 20 Sep 2026 11:25:09 +0800 Subject: [PATCH 04/10] fix(desktop): stabilize composer session layout Generated-by: Codex --- .../composer-layout-contract.test.ts | 61 ------- .../use-live-context-usage.ts | 33 ++-- .../src/renderer/chat-composer-region.tsx | 12 +- .../workbar/tools/composer-git-branch.ts | 172 ++++++++++++++++++ apps/desktop/src/renderer/styles/composer.css | 19 +- .../__tests__/composer-context-usage.test.tsx | 2 + packages/ui/src/composer.tsx | 83 +++++---- 7 files changed, 246 insertions(+), 136 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/composer-layout-contract.test.ts create mode 100644 apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts diff --git a/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts b/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts deleted file mode 100644 index e14990c2b3..0000000000 --- a/apps/desktop/src/main/__tests__/composer-layout-contract.test.ts +++ /dev/null @@ -1,61 +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 assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; -import { describe, it } from 'node:test'; - -const composerCssUrl = [ - new URL('../../renderer/styles/composer.css', import.meta.url), - new URL('../../../src/renderer/styles/composer.css', import.meta.url), -].find((candidate) => existsSync(candidate)); - -if (!composerCssUrl) throw new Error('Could not locate renderer/styles/composer.css'); - -const composerCss = readFileSync(composerCssUrl, 'utf8'); - -function rule(selector: string): string { - const escaped = selector.replace(/[.*+?^$()|[\]\\]/g, '\\$&'); - const match = composerCss.match(new RegExp(escaped + '\\s*\\{([^}]*)\\}', 'u')); - assert.ok(match, 'missing composer layout rule: ' + selector); - return match[1] ?? ''; -} - -describe('composer footer layout', () => { - it('allocates the remaining footer width to the left controls', () => { - const footerLeft = rule('.maka-composer-astryx div:has(> .maka-composer-left-controls)'); - assert.match(footerLeft, /min-width:\s*0;/u); - assert.match(footerLeft, /flex:\s*1\s+1\s+0;/u); - }); - - it('keeps model controls on one row and permits long labels to ellipsize', () => { - const controls = rule('.maka-composer-left-controls'); - assert.match(controls, /flex-wrap:\s*nowrap;/u); - assert.match(controls, /min-width:\s*0;/u); - - const modelSelection = rule('.maka-composer-left-controls .maka-model-selection-controls'); - assert.match(modelSelection, /min-width:\s*0;/u); - assert.match(modelSelection, /flex:\s*1\s+1\s+auto;/u); - assert.match(modelSelection, /max-width:\s*100%;/u); - - const modelText = rule('.maka-composer-model-chip-text'); - assert.match(modelText, /text-overflow:\s*ellipsis;/u); - assert.match(modelText, /white-space:\s*nowrap;/u); - }); -}); diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts index 6ebd1e6830..dd3f48a096 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts @@ -61,9 +61,10 @@ export function useLiveContextUsageState(input: { const [snapshot, setSnapshot] = useState(undefined); const { sessionId, model, providerType } = input; useEffect(() => { + if (sessionId === undefined) return; let settingTarget = true; const targetSnapshot = (state: LiveContextUsageState): TargetedLiveContextUsage => ({ - sessionId: sessionId!, + sessionId, model, providerType, state, @@ -79,19 +80,16 @@ export function useLiveContextUsageState(input: { cancel: (handle) => clearTimeout(handle as ReturnType), onChange: (usage) => { setSnapshot( - sessionId === undefined - ? undefined - : targetSnapshot( - settingTarget - ? { status: 'pending' } - : usage - ? { status: 'available', usage } - : { status: 'unavailable' }, - ), + targetSnapshot( + settingTarget + ? { status: 'pending' } + : usage + ? { status: 'available', usage } + : { status: 'unavailable' }, + ), ); }, onReadFailure: () => { - if (sessionId === undefined) return; setSnapshot((current) => { if ( current?.sessionId === sessionId @@ -105,18 +103,11 @@ export function useLiveContextUsageState(input: { }); }, }); - tracker.setTarget( - sessionId === undefined - ? undefined - : { sessionId, route: { model, providerType } }, - ); + tracker.setTarget({ sessionId, route: { model, providerType } }); settingTarget = false; - const unsubscribe = - sessionId === undefined - ? undefined - : inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event)); + const unsubscribe = inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event)); return () => { - unsubscribe?.(); + unsubscribe(); tracker.dispose(); }; }, [inspector, sessionId, model, providerType]); diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 9582df2612..be99e8dc43 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -286,17 +286,7 @@ export function ChatComposerRegion({ meteredContextWindow: liveContextUsage.contextWindow, } : {}), - pending: - liveContextUsagePending - && !liveContextUsage - && !( - contextUsage.usageTokens !== undefined - && ( - contextUsage.declaredContextWindow - ?? contextUsage.metadataContextWindow - ?? 0 - ) > 0 - ), + pending: liveContextUsagePending, } : undefined} // AppShell carries staged attachments into both queued and steering diff --git a/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts b/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts new file mode 100644 index 0000000000..448ea201f5 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/composer-git-branch.ts @@ -0,0 +1,172 @@ +/* + * 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; +} + +interface ComposerGitBranchSnapshot { + readonly sessionId: string | undefined; + readonly branch: ComposerGitBranch | undefined; +} + +/** + * 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 [snapshot, setSnapshot] = useState({ + sessionId, + branch: 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((id: string, next: ComposerGitBranch | undefined) => { + const current = branchRef.current; + if (current?.name === next?.name && current?.shortSha === next?.shortSha) return; + branchRef.current = next; + setSnapshot({ sessionId: id, branch: next }); + }, []); + + const read = useCallback(() => { + const id = sessionRef.current; + if (!id) { + return; + } + void review + .branch(id) + .then((result) => { + if (sessionRef.current !== id) return; + apply( + id, + 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(id, undefined); + }); + }, [review, apply]); + + // The session's own read, taken whenever the session changes. + useEffect(() => { + branchRef.current = undefined; + setSnapshot({ sessionId, branch: undefined }); + if (!sessionId) { + 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]); + + // Effects run after this render. Reject the previous session's snapshot here + // so a switch never paints its branch while the new read is in flight. + return snapshot.sessionId === sessionId ? snapshot.branch : undefined; +} diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 061ae0e04e..0797c6283c 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -264,6 +264,8 @@ .maka-composer-left-controls { display: flex; align-items: center; + /* Text controls shrink; icon actions and the send slot retain their size. */ + flex: 1 1 auto; min-width: 0; /* PR-REFERENCE-PIXEL-8 (WAWQAQ msg `f79de85f` round 8): reference implementation's bundle uses gap values centered on 4-8px (extracted from @@ -276,13 +278,6 @@ flex-wrap: nowrap; } -/* The footer stays one control row. Text-bearing controls below absorb the - shrink while icon actions and the send slot retain their fixed geometry. */ -.maka-composer-left-controls { - flex: 1 1 auto; - min-width: 0; -} - /* Astryx's footer slots otherwise size from their content. Give the left controls only the space left after the fixed send slot and footer gap. */ .maka-composer-astryx div:has(> .maka-composer-left-controls) { @@ -293,6 +288,16 @@ flex: 0 0 auto; } +/* Disabled Astryx buttons use opacity on the whole button. Give the send + slot its own opaque composer-colored backing so content that overflows the + left slot cannot show through the translucent button. */ +.maka-composer-send-slot { + display: inline-flex; + align-items: center; + border-radius: var(--_button-radius); + background: var(--background-elevated); +} + /* Quiet footer: + and permission are both ghost icon buttons. */ .maka-composer-left-controls .permissionModeIcon, .maka-composer-left-controls .maka-composer-plus-menu { diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 9307b0f035..513cbed191 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -121,6 +121,8 @@ test('the context usage action keeps one control while its reading resolves', as undefined, diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 0ba8364dd5..683ec2ffa6 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -2397,37 +2397,41 @@ export const Composer = forwardRef< {props.footerAccessory} )} - sendButton={stopShown ? ( - { - if (props.stopPending) return; - void props.onStop(); - }} - icon={