diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts
index 745e91de99..c4b5df7583 100644
--- a/apps/desktop/e2e/session-workbar.spec.ts
+++ b/apps/desktop/e2e/session-workbar.spec.ts
@@ -64,11 +64,16 @@ test('right workbar visibility belongs to each Session and survives reload', asy
.getByRole('list', { name: '打开工具' })
.getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ })
.click();
- await page.getByRole('button', { name: '打开用量追踪' }).click();
+ const usageAction = page.locator('.maka-context-usage-action');
+ await expect(usageAction).toBeHidden();
+ await page.getByRole('button', { name: '收起侧边栏' }).click();
+ await expect(usageAction).toBeVisible();
+ await usageAction.click();
await expect(page.locator(
'.maka-session-workbar-panel[data-overlay][data-placement="right"] [data-maka-contract="session-inspector"]',
)).toBeVisible();
await expect(panel).toBeVisible();
+ await page.getByRole('button', { name: '展开侧边栏' }).click();
await first.sidebar.getByRole('button', { name: '新任务', exact: true }).click();
const second = await createSession(page, 'second workbar owner');
await expect(panel).toBeHidden();
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index b7ba13f53c..b1c3a0775b 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -60,6 +60,37 @@ test('WorkHub uses its coordination model and shared attachment composer', async
return conversation.left >= 0 && conversation.right <= innerWidth + 1;
})).toBe(true);
}
+ const shellFloor = await page.locator('.maka-shell-astryx').evaluate((element) =>
+ Math.round(parseFloat(getComputedStyle(element).minWidth)));
+ const desktopConversationFloor = await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--maka-conversation-min-width').trim());
+ const workhubConversationFloor = await workhub.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--maka-conversation-min-width').trim());
+ expect(workhubConversationFloor).toBe(desktopConversationFloor);
+ await expect.poll(() => workhub.locator('.workHubLive').evaluate((element) =>
+ getComputedStyle(element).minWidth)).toBe(desktopConversationFloor);
+ const dockLeft = await page.locator('.workHubDock').evaluate((element) =>
+ Math.round(element.getBoundingClientRect().left));
+ let frozenDockWidth: number | undefined;
+ for (const width of [shellFloor - 10, shellFloor - 40]) {
+ const contentWidth = await mainWindow.evaluate((window, nextWidth) => {
+ window.setBounds({ width: nextWidth });
+ return window.getContentSize()[0];
+ }, width);
+ await expect.poll(() => page.evaluate(() => innerWidth)).toBe(contentWidth);
+ expect(contentWidth).toBeLessThan(shellFloor);
+ const dockWidth = await page.locator('.workHubDock').evaluate((element) =>
+ Math.round(element.getBoundingClientRect().width));
+ expect(await page.locator('.workHubDock').evaluate((element) =>
+ Math.round(element.getBoundingClientRect().left))).toBe(dockLeft);
+ frozenDockWidth ??= dockWidth;
+ expect(dockWidth).toBe(frozenDockWidth);
+ await expect.poll(() => workhub.evaluate(() => innerWidth)).toBeLessThan(dockWidth);
+ await expect.poll(() => workhub.locator('.workHubLive').evaluate((element) =>
+ Math.round(element.getBoundingClientRect().width))).toBe(dockWidth);
+ await expect.poll(() => workhub.locator('.workHubLive').evaluate((element) =>
+ Math.round(element.getBoundingClientRect().left))).toBe(0);
+ }
const restoredContentWidth = await mainWindow.evaluate((window, bounds) => {
window.setBounds(bounds);
return window.getContentSize()[0];
@@ -209,19 +240,6 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect(editor).toHaveText('Keep this draft while folding the conversation.');
await workhub.getByRole('button', { name: /打开用量追踪|Open usage trace/ }).click();
await expect(page.getByRole('button', { name: /展开任务工作栏|Expand task workbar/ })).toBeVisible();
- const thinking = workhub.getByRole('combobox', { name: /思考级别|Thinking level/ });
- await expect(thinking).toBeEnabled();
- await thinking.click();
- const thinkingSheet = workhub.getByRole('dialog');
- await expect(thinkingSheet).toBeVisible();
- await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-thinking-levels.png') });
- await expect.poll(() => thinkingSheet.evaluate((element) => {
- const rect = element.getBoundingClientRect();
- return rect.top >= 0 && rect.bottom <= innerHeight;
- })).toBe(true);
- await thinkingSheet.getByRole('option', { name: /^(高|High)$/ }).click();
- await expect(thinking).toContainText(/高|High/);
- await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-composer-controls.png') });
const compactHeight = await workhub.evaluate(() => innerHeight);
const screenLayout = async () => {
const origin = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getContentBounds());
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index a23679da6d..5a39498b21 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -819,7 +819,7 @@
"react": 1
},
"importSpecifiers": 95,
- "nonTriviaTokens": 12681
+ "nonTriviaTokens": 12675
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
index c9a74ef01a..03ef85a9f9 100644
--- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
@@ -85,6 +85,12 @@ async function mountRegion(): Promise<{
Object.assign(document, { getSelection });
Object.assign(window, {
getSelection,
+ getComputedStyle: () =>
+ ({
+ direction: 'ltr',
+ writingMode: 'horizontal-tb',
+ getPropertyValue: () => '',
+ }) as unknown as CSSStyleDeclaration,
matchMedia: () =>
({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList,
});
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 774ffb2f7a..d6070d55d7 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,
@@ -266,12 +271,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());
@@ -282,6 +289,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();
});
@@ -436,3 +444,84 @@ 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.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/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
index 513cad09e0..51a061239a 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
@@ -29,6 +29,7 @@ import {
abandonPendingCompanionCopy,
cleanupCompanionCopy,
createFakeWorkbarServices,
+ dismissCompanionCopy,
ensureCompanionFork,
performCompanionTurn,
type PerformCompanionTurnDeps,
@@ -88,6 +89,36 @@ afterEach(async () => {
});
describe('quote companion disposal fencing', () => {
+ it('waits for an interrupted fork to become idle before removing it', async () => {
+ const defaults = createFakeWorkbarServices();
+ const running = session('running-side-conversation');
+ running.runningTurnIds = ['turn-1'];
+ let listCount = 0;
+ const cleaned: string[] = [];
+ const sideChat = {
+ ...defaults.sideChat,
+ listSessions: async () => {
+ listCount += 1;
+ return listCount < 2 ? [running] : [{ ...running, runningTurnIds: [] }];
+ },
+ cleanupSessionCopy: async (sessionId: string) => {
+ cleaned.push(sessionId);
+ },
+ };
+
+ assert.equal(
+ await dismissCompanionCopy(
+ sideChat,
+ sourceSession.id,
+ panelId,
+ running.id,
+ ),
+ true,
+ );
+ assert.deepEqual(cleaned, [running.id]);
+ assert.ok(listCount >= 2);
+ });
+
it('creates a WorkHub companion from an empty boundary without reading coordination turns', async () => {
const defaults = createFakeWorkbarServices();
const coordinationSession = session(
diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
index 6ef4510049..d41ad759ea 100644
--- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
@@ -236,6 +236,10 @@ test('moves a shared native container while keeping renderer and browser coordin
assert.ok(container.children.has(renderer));
assert.deepEqual({ ...container.boundsUpdates.at(-1) }, host.rect);
assert.deepEqual({ ...renderer.boundsUpdates.at(-1) }, { x: 0, y: 0, width: 800, height: 760 });
+ h.main.setBounds({ x: 0, y: 0, width: 650, height: 800 });
+ assert.deepEqual({ ...container.boundsUpdates.at(-1) }, { x: 200, y: 40, width: 450, height: 760 });
+ assert.deepEqual({ ...renderer.boundsUpdates.at(-1) }, { x: 0, y: 0, width: 450, height: 760 },
+ 'the native viewport clips to Desktop while CSS preserves the inner layout');
await h.command(renderer.webContents, 'detach');
assert.equal(h.container, container);
assert.ok(h.windows[1]!.children.has(container));
@@ -322,6 +326,9 @@ test('opens an empty floating conversation at its composer height', async () =>
await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 160 });
assert.equal(h.windows[1]!.resizable, false);
assert.equal(h.windows[1]!.bounds.height, 160, 'compact input still grows programmatically');
+ h.windows[1]!.setBounds({ ...h.windows[1]!.bounds, width: 320 });
+ await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 160 });
+ assert.equal(h.windows[1]!.bounds.width, 360, 'programmatic compact layout keeps the native minimum width');
await h.command(view.webContents, 'dock');
await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 });
h.movePointer({ x: 1600, y: -900, width: 1000, height: 800 });
@@ -994,6 +1001,30 @@ test('editing progress grows at its existing bottom and opening interpolates bot
h.controller.dispose();
});
+test('external floating bounds changes are not overwritten by an in-flight layout animation', async () => {
+ const h = await harness(true);
+ await h.controller.toggle(true);
+ const view = h.views[0]!;
+ const floating = h.windows[1]!;
+ floating.setBounds({ ...floating.bounds, width: 360 });
+ await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 160 });
+ const nativeSetBounds = floating.setBounds.bind(floating);
+ let deferredBounds: Electron.Rectangle | undefined;
+ floating.setBounds = (bounds) => {
+ if (bounds.width === 520 && !deferredBounds) {
+ deferredBounds = bounds;
+ return;
+ }
+ nativeSetBounds(bounds);
+ };
+ floating.setBounds({ ...floating.bounds, width: 520 });
+ h.advance(100);
+ nativeSetBounds(deferredBounds!);
+ h.advance(500);
+ assert.equal(floating.bounds.width, 520);
+ h.controller.dispose();
+});
+
test('late progress measurements and send acknowledgements cannot revive a dismissed card or invalidate the next card paint', async () => {
const h = await harness();
await h.controller.prepareControl('first');
diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts
index 37f7f94a11..ef255a10de 100644
--- a/apps/desktop/src/main/main-window.ts
+++ b/apps/desktop/src/main/main-window.ts
@@ -408,11 +408,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main
// (see `app-region-hygiene-contract.test.ts`) cover the
// renderer side of the same gate.
resizable: true,
- // #824: enforce the sanitizeBounds restore floor at runtime resize too,
+ // #824: enforce the sanitizeBounds height floor at runtime resize too,
// so the both-present dvh layout fix can't be defeated by dragging the
- // window shorter than the 320px restore minimum. Shares SAFE_MIN_HEIGHT
- // with sanitizeBounds so the resize floor and the restore floor can't
- // drift apart (locked by app-region-hygiene-contract.test.ts).
+ // window below the restore minimum. Width deliberately remains native-
+ // resizable below SAFE_MIN_WIDTH; the renderer freezes its conversation
+ // layout at its own floor and lets the outer shell clip it.
minHeight: SAFE_MIN_HEIGHT,
backgroundColor: initialBg,
// The window stays hidden until `ready-to-show`, so the first visible
diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts
index 5d198dd2cf..c55cca9d7d 100644
--- a/apps/desktop/src/main/workhub-presentation.ts
+++ b/apps/desktop/src/main/workhub-presentation.ts
@@ -28,6 +28,15 @@ import { focusWindow, showWindowInactive, type WindowRevealMode } from './window
const COMMAND = 'workhub-presentation:command';
const SHORTCUT = 'CommandOrControl+Shift+K';
const RESIZE_DURATION = 420;
+const FLOATING_MIN_WIDTH = 360;
+
+function floatingMinWidth(areaWidth: number): number {
+ return Math.min(FLOATING_MIN_WIDTH, areaWidth);
+}
+
+function clampFloatingWidth(width: number, areaWidth: number): number {
+ return Math.min(Math.max(width, floatingMinWidth(areaWidth)), areaWidth);
+}
export interface WorkHubPresentationDeps {
mainWindow(): BrowserWindow | undefined;
@@ -75,6 +84,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
let interactionPending = false;
let resizeTimer: ReturnType | undefined;
let resizeTarget: Electron.Rectangle | undefined;
+ let expectedFloatingBounds: Electron.Rectangle | undefined;
let resizeViewportHeight: number | undefined;
let viewportInset = 0;
let floatingRadius: number | undefined;
@@ -201,6 +211,13 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
function resizeFloating(bounds: Electron.Rectangle, animate: boolean): void {
const window = floating!;
+ const area = screen.getDisplayMatching(bounds).workArea;
+ const width = clampFloatingWidth(bounds.width, area.width);
+ bounds = {
+ ...bounds,
+ width,
+ x: Math.max(area.x, Math.min(bounds.x, area.x + area.width - width)),
+ };
if (resizeTarget && bounds.x === resizeTarget.x && bounds.y === resizeTarget.y &&
bounds.width === resizeTarget.width && bounds.height === resizeTarget.height) return;
const initial = window.getBounds();
@@ -226,6 +243,17 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
cancelFloatingAnimation();
return;
}
+ // A native caller can resize the floating window while a renderer-driven
+ // layout animation is in flight. Once its bounds no longer match the
+ // frame we submitted, the native resize owns the geometry; do not let a
+ // stale animation target overwrite it on the next tick.
+ const current = window.getBounds();
+ if (current.x !== previous.x || current.y !== previous.y || current.width !== previous.width || current.height !== previous.height) {
+ expectedFloatingBounds = undefined;
+ cancelFloatingAnimation(true);
+ fitFloating(false);
+ return;
+ }
const progress = Math.min(1, (performance.now() - started) / RESIZE_DURATION);
// A critically damped response gives the glass a soft start and a long
// landing without overshooting the screen or scaling the live editor.
@@ -236,6 +264,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
const bottom = Math.round(initial.y + initial.height + (bounds.y + bounds.height - initial.y - initial.height) * eased);
const next = { width, height, x: Math.round(center - width / 2), y: bottom - height };
if (next.x !== previous.x || next.y !== previous.y || next.width !== previous.width || next.height !== previous.height) {
+ expectedFloatingBounds = next;
window.setBounds(next);
fitFloating();
previous = next;
@@ -269,7 +298,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
if (view && !view.webContents.isDestroyed()) view.webContents.send('workhub-presentation:viewport-inset', inset / view.webContents.getZoomFactor());
}
- function fitFloating(): void {
+ function fitFloating(rememberExpandedHeight = true): void {
if (!floating || floating.isDestroyed() || parent !== floating || !view) return;
const { width, height } = floating.getContentBounds();
// Clip in the native parent so resizing does not rebuild a renderer mask.
@@ -282,7 +311,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
const canvasHeight = resizeViewportHeight ?? height;
setViewportInset(canvasHeight - height);
setViewBounds({ x: 0, y: height - canvasHeight, width, height: canvasHeight });
- if (progressRequest === undefined && conversationExpanded && !resizeTarget) expandedHeight = height;
+ if (rememberExpandedHeight && progressRequest === undefined && conversationExpanded && !resizeTarget) expandedHeight = height;
}
function ensureFloating(): BrowserWindow {
@@ -294,7 +323,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
title: 'WorkHub', show: false, width, height,
type: process.platform === 'darwin' ? 'panel' : undefined,
x: area.x + Math.round((area.width - width) / 2), y: Math.max(area.y, area.y + area.height - height - 96),
- minWidth: Math.min(360, width), minHeight: Math.min(80, height),
+ minWidth: floatingMinWidth(width), minHeight: Math.min(80, height),
resizable: conversationExpanded,
alwaysOnTop: true, autoHideMenuBar: true, maximizable: false, fullscreenable: false,
frame: false, transparent: true, backgroundColor: '#00000000',
@@ -305,7 +334,22 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
// A macOS panel can accompany fullscreen apps without turning Maka into
// a Dock-less accessory application.
if (process.platform === 'darwin') floating.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true });
- floating.on('resize', fitFloating);
+ const window = floating;
+ window.on('resize', () => {
+ const current = window.getBounds();
+ if (expectedFloatingBounds && current.x === expectedFloatingBounds.x && current.y === expectedFloatingBounds.y &&
+ current.width === expectedFloatingBounds.width && current.height === expectedFloatingBounds.height) {
+ fitFloating();
+ return;
+ }
+ if (resizeTarget) {
+ expectedFloatingBounds = undefined;
+ cancelFloatingAnimation(true);
+ fitFloating(false);
+ return;
+ }
+ fitFloating();
+ });
floating.on('hide', () => deps.onVisibilityChanged?.());
floating.on('minimize', () => deps.onVisibilityChanged?.());
floating.on('show', () => deps.onVisibilityChanged?.());
@@ -372,7 +416,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
target.setResizable(conversationExpanded);
conversationBounds = undefined;
const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
- const width = Math.min(old.width, area.width);
+ const width = clampFloatingWidth(old.width, area.width);
const height = Math.min(conversationExpanded ? expandedHeight : compactHeight, area.height);
const bounds = {
width, height,
@@ -393,7 +437,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
expandOnFocus = true;
const current = floating.getBounds();
const area = screen.getDisplayMatching(current).workArea;
- const width = Math.min(conversationBounds?.width ?? 520, area.width);
+ const width = clampFloatingWidth(conversationBounds?.width ?? 520, area.width);
const height = Math.min(expandedHeight, area.height);
clearProgressRequest();
conversationBounds = undefined;
@@ -632,8 +676,10 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
const animate = conversationExpanded !== value.expanded || !!resizeTarget;
if (conversationExpanded !== value.expanded) floating.setResizable(value.expanded);
conversationExpanded = value.expanded;
- if (bounds.height !== height) {
- resizeFloating({ ...bounds, height, y: Math.max(area.y, Math.min(bounds.y + bounds.height - height, area.y + area.height - height)) }, animate);
+ const width = clampFloatingWidth(bounds.width, area.width);
+ const x = Math.max(area.x, Math.min(bounds.x, area.x + area.width - width));
+ if (bounds.height !== height || bounds.width !== width || bounds.x !== x) {
+ resizeFloating({ ...bounds, x, width, height, y: Math.max(area.y, Math.min(bounds.y + bounds.height - height, area.y + area.height - height)) }, animate);
}
return;
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 7f1e013930..86d2c978b8 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -995,7 +995,12 @@ function AppShellContent({
);
const activePermissionMode = activeId
? sessionSettingIntent.overlays.permissionMode[activeId]
+ // Keep the access control's display value while the authoritative
+ // boundary read for the newly selected Session is in flight. The
+ // control is disabled below until that read settles, but removing its
+ // callback here would unmount the icon and make the footer reflow.
?? activeBoundarySurface.permissionMode
+ ?? activeSessionForView?.permissionMode
: activeBoundarySurface.permissionMode;
const planMode = usePlanModeState(ownerActiveId ? activeHostSession : undefined);
const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({
@@ -2320,7 +2325,7 @@ function AppShellContent({
commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRight}
onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
-
+
{
- await setPermissionMode(mode)
- }
- : undefined
+ !activeBoundarySurface.permissionMode
+ ? boundaryUnreadableNotice?.detail ?? shellCopy.modeChangeLoading
+ : modeChangeDisabledReason
}
+ // Keep this callback defined while the boundary read is
+ // pending. Composer uses its presence to mount the access
+ // control; the disabled reason above and this guard still
+ // fail closed until the authoritative surface is ready.
+ onPermissionModeChange={mode => {
+ if (activeBoundarySurface.localInteractionAvailable) void 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 e94a6f08a2..7b19d7906a 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
@@ -136,6 +136,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;
const coordinator = createRefreshReadCoordinator({
@@ -145,6 +146,7 @@ export function createLiveContextUsageTracker(input: {
if (!diagnostics || !target) return;
input.onChange(liveContextUsageFromDiagnostics(diagnostics, target.route));
},
+ onReadFailure: input.onReadFailure,
delayMs: input.delayMs,
schedule: (callback, delayMs) => {
const handle = input.schedule(callback, delayMs);
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..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
@@ -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,29 @@ 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(() => {
+ if (sessionId === undefined) return;
+ let settingTarget = true;
+ const targetSnapshot = (state: LiveContextUsageState): TargetedLiveContextUsage => ({
+ sessionId,
+ model,
+ providerType,
+ state,
+ });
const tracker = createLiveContextUsageTracker({
query: async (targetSessionId) => {
const result = await inspector.context(targetSessionId);
@@ -56,21 +78,56 @@ 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(
+ targetSnapshot(
+ settingTarget
+ ? { status: 'pending' }
+ : usage
+ ? { status: 'available', usage }
+ : { status: 'unavailable' },
+ ),
+ );
+ },
+ onReadFailure: () => {
+ 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 } },
- );
- const unsubscribe =
- sessionId === undefined
- ? undefined
- : inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event));
+ tracker.setTarget({ sessionId, route: { model, providerType } });
+ settingTarget = false;
+ const unsubscribe = inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event));
return () => {
- unsubscribe?.();
+ unsubscribe();
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 4d3f9db8fd..9a1fdc782a 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -150,6 +150,7 @@ interface ChatComposerRegionProps
*/
children: (
usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ usagePending: boolean,
) => ReactNode;
}>;
directoryComposerProps: Pick<
@@ -266,19 +267,25 @@ export function ChatComposerRegion({
// the anchor prop remains the reading it falls back to.
const renderComposer = (
liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ liveContextUsagePending: boolean,
) => (
{(goalProjection) => (
- {renderComposer}
+ {(usage, usagePending) => renderComposer(usage, usagePending)}
) : (
- renderComposer(undefined)
+ renderComposer(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..33ef0ca1c2 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,7 @@
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';
/**
* Render-prop boundary for the composer context gauge (#4717).
@@ -28,22 +28,28 @@ 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,
+ 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)}>;
+ return <>{props.children(
+ usageState.status === 'available' ? usageState.usage : undefined,
+ usageState.status === 'pending',
+ )}>;
}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
index 0c56368fab..86d95f324f 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
@@ -209,6 +209,28 @@ export async function dismissCompanionCopy(
companionSessionId: string,
): Promise {
await api.stop(companionSessionId).catch(() => undefined);
+
+ // The stop IPC acknowledges the interrupt request before the Host publishes
+ // the terminal Turn projection. Removing the copy during that window is
+ // rejected as session_busy, so wait for the authoritative live-run list
+ // before attempting retirement.
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ const session = await api
+ .listSessions()
+ .then((sessions) => sessions.find((candidate) => candidate.id === companionSessionId))
+ .catch(() => undefined);
+ if (!session || session.runningTurnIds?.length === 0) break;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ if (await cleanupCompanionCopy(api, sourceSessionId, panelId, companionSessionId)) {
+ return true;
+ }
+
+ // A catalog update and the retirement admission can still cross by one
+ // event-loop turn. Give that transient busy result one final retry; durable
+ // cleanup recovery remains responsible for persistent failures.
+ await new Promise((resolve) => setTimeout(resolve, 50));
return cleanupCompanionCopy(api, sourceSessionId, panelId, companionSessionId);
}
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 09ec5a9f17..33a7d8a76d 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx
@@ -17,7 +17,6 @@
* under the License.
*/
-import type { WorkbarTogglePosition } from '@maka/core/settings';
import { isNativeSurfaceOccluded, watchNativeSurface, type NativeSurfaceWatch } from '../../../application/contracts/native-surface-occlusion.js';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '@astryxdesign/core';
@@ -27,12 +26,22 @@ import { useWorkHubServices } from '../services.js';
import { workHubLiveCopy } from '../locales/workhub-live-copy.js';
/** The main window owns only this landing space; the live view keeps its React owner. */
-export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbarTogglePosition = 'edge' }: { enabled: boolean; visible?: boolean; workbarCollapsed: boolean; workbarTogglePosition?: WorkbarTogglePosition }) {
+export function WorkHubDock({ enabled, visible = true, workbar }: {
+ enabled: boolean;
+ visible?: boolean;
+ workbar: { bottomOpen: boolean; rightCollapsed: boolean };
+}) {
const { presentation } = useWorkHubServices();
const t = workHubLiveCopy[useUiLocale()];
const element = useRef(null);
- const workbarState = useRef({ collapsed: workbarCollapsed, togglePosition: workbarTogglePosition });
- workbarState.current = { collapsed: workbarCollapsed, togglePosition: workbarTogglePosition };
+ const workbarRef = useRef({
+ placement: workbar.bottomOpen ? 'bottom' as const : 'right' as const,
+ collapsed: workbar.bottomOpen ? false : workbar.rightCollapsed,
+ });
+ workbarRef.current = {
+ placement: workbar.bottomOpen ? 'bottom' : 'right',
+ collapsed: workbar.bottomOpen ? false : workbar.rightCollapsed,
+ };
const [snapshot, setSnapshot] = useState();
const [backdrop, setBackdrop] = useState();
const [error, setError] = useState();
@@ -66,7 +75,7 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar
const host = {
visible: enabled && visible && rect.width > 0 && rect.height > 0,
occluded,
- workbar: { ...workbarState.current, placement: window.matchMedia('(max-width: 990px)').matches ? 'bottom' as const : 'right' as const },
+ workbar: workbarRef.current,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
};
const key = JSON.stringify(host);
@@ -92,8 +101,7 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar
.catch(() => undefined);
};
}, [enabled, presentation, visible, snapshot?.placement]);
- // The host also reports Workbar state, which can change without moving this node.
- useEffect(() => surface.current?.refresh(), [workbarCollapsed, workbarTogglePosition]);
+ useEffect(() => surface.current?.refresh(), [workbar.bottomOpen, workbar.rightCollapsed]);
return (
{backdrop && snapshot?.placement === 'docked' &&
}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx
index 7e4874ba1a..678d966563 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx
@@ -30,5 +30,5 @@ function WorkHubApplication({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState(services.initialLocale);
useEffect(() => services.subscribeAppearance(setLocale), [services]);
useEffect(() => { void services.presentation.ready(); }, [services]);
- return {children};
+ return {children}
;
}
diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css
index f4f2915561..9f3337bc86 100644
--- a/apps/desktop/src/renderer/maka-tokens.css
+++ b/apps/desktop/src/renderer/maka-tokens.css
@@ -946,6 +946,14 @@
--maka-reading-measure: 800px;
--maka-transcript-gutter: clamp(var(--space-3), 4vw, var(--space-10));
+ /* === conversation layout contract =====================================
+ The Desktop and WorkHub renderers are separate documents, but both load
+ this token sheet. Keep the minimum conversation footprint in one place so
+ their host and live surface cannot drift apart when the native viewport
+ becomes narrower than the layout. This is a layout floor, not a native
+ window minimum: narrow windows clip the right edge of the fixed layout. */
+ --maka-conversation-min-width: 520px;
+
/* === settings ==========================================================
One right-edge cap for a settings row's end slot — the read-only value
and the control cluster share it, so the two kinds of row end stop at
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 68d3a22284..ce699f501c 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -38,6 +38,18 @@
.maka-composer-astryx {
width: min(var(--maka-reading-measure), 100%);
max-width: var(--maka-reading-measure);
+ container: maka-composer / inline-size;
+}
+
+/* A narrow conversation column cannot keep both text selectors and the
+ persistent footer actions readable. The model selector remains the primary
+ choice; thinking level is available again as soon as the Composer card has
+ room for the full control. Container sizing matters here because a desktop
+ viewport can stay wide while a right workbar squeezes this card. */
+@container maka-composer (max-width: 480px) {
+ .maka-composer-astryx .maka-model-selection-controls .maka-context-usage-action {
+ display: none;
+ }
}
/* Reaching the transcript tail fades the scroll-to-bottom control while the
@@ -272,6 +284,9 @@
.maka-composer-left-controls {
display: flex;
align-items: center;
+ /* Text controls shrink; icon actions and the send slot retain their size. */
+ flex: 1 1 0;
+ 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
@@ -280,26 +295,71 @@
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. */
-.maka-composer-left-controls {
+/* Give the leading slot only the space left after the fixed trailing actions
+ and send slots. The class is owned by Maka so this does not depend on
+ Astryx's footer wrapper structure. */
+.maka-composer-footer-leading {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+/* Astryx owns the footer-left wrapper around footerActions. Its default
+ min-content floor otherwise lets the leading controls push the fixed send
+ slot outside the Composer card. The Maka-owned slot marker is the stable
+ boundary here; keep the wrapper shrinkable on every Composer surface. */
+.maka-composer-astryx
+ div:has(> .maka-composer-footer-leading) {
flex: 1 1 auto;
min-width: 0;
}
+.maka-composer-footer-trailing {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+}
+
+.maka-composer-footer-trailing > * {
+ display: inline-flex;
+ align-items: center;
+}
+
+/* Astryx's sendActions slot is already the right-hand footer group. */
+.maka-composer-send-slot {
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--_button-radius);
+ background: var(--background-elevated);
+ 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 {
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. */
@@ -400,13 +460,44 @@
}
/* Model + thinking pair lives in left-controls (after permission), not send. */
.maka-composer-left-controls .maka-model-selection-controls {
+ flex: 1 1 0;
+ min-width: 0;
+ max-width: 100%;
+}
+/* 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) {
+ /* Hug the model label when space is available, but release that width
+ before the adjacent thinking and usage controls when the footer narrows. */
+ flex: 0 1 auto;
min-width: 0;
- max-width: min(420px, 52vw);
+ width: max-content;
+ max-width: 220px;
}
.maka-composer-left-controls .maka-model-switcher-trigger,
.maka-composer-left-controls .maka-new-chat-model-selector {
- min-width: 100px;
- max-width: min(220px, 28vw);
+ min-width: 0;
+ max-width: 100%;
+}
+.maka-composer-left-controls .maka-model-selection-controls .astryx-field:has(.maka-thinking-level-selector) {
+ /* Keep the thinking control readable when Plan/Swarm marks consume the
+ toolbar. The model field is the compressible text control beside it. */
+ flex: 0 1 auto;
+ min-width: 0;
+ width: max-content;
+ max-width: min(180px, 100%);
+}
+.maka-composer-left-controls .maka-thinking-level-selector > button {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+}
+/* 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;
}
/* External model names share this row with an executor selector. Size the
trigger to its label and let the pair wrap instead of squeezing the model
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index 80c0bfb6db..3370912f90 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -48,7 +48,10 @@
paint to the window top (Codex / Claude Desktop / Cursor). */
.maka-shell-astryx {
width: 100%;
- min-width: 0;
+ /* Include the nav column: the shell floor leaves the conversation's
+ fixed layout width available while the native window remains resizable
+ and clips the shell below this content floor. */
+ min-width: calc(var(--maka-sidenav-width) + var(--maka-conversation-min-width) + var(--agents-content-area-gap));
min-height: 0;
overflow: hidden;
}
@@ -160,6 +163,11 @@
the ease dead for every collapse after the handle had once been touched. */
.maka-shell-astryx .maka-sidenav-motion {
display: flex;
+ /* The sidebar is the coordinate origin for the Desktop content and the
+ native WorkHub host. Keep its declared width when the window is narrower
+ than the shell floor; only its explicit resize/collapse state may change
+ it. */
+ flex: 0 0 auto;
min-width: 0;
min-height: 0;
/* The nav sizes itself against a definite height (`.maka-session-panel`
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 443c6d1b7d..8368bcd09a 100644
--- a/apps/desktop/src/renderer/styles/workbar/shell.css
+++ b/apps/desktop/src/renderer/styles/workbar/shell.css
@@ -32,11 +32,10 @@
"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;
- min-width: 0;
min-height: 0;
}
diff --git a/apps/desktop/src/renderer/styles/workbar/side-chat.css b/apps/desktop/src/renderer/styles/workbar/side-chat.css
index 8d73e8754a..e67ee396ea 100644
--- a/apps/desktop/src/renderer/styles/workbar/side-chat.css
+++ b/apps/desktop/src/renderer/styles/workbar/side-chat.css
@@ -78,16 +78,6 @@
flex-wrap: nowrap;
}
-/* Astryx's footer-left slot sizes to max-content. Let that wrapper shrink so
- the fixed send slot remains inside a minimum-width Workbar. */
-.maka-session-workbar-panel[data-placement="right"]
- .maka-quote-companion
- .maka-composer-astryx
- div:has(> .maka-composer-left-controls) {
- flex: 1 1 auto;
- min-width: 0;
-}
-
.maka-session-workbar-panel[data-placement="right"]
.maka-quote-companion
.maka-model-selection-controls {
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 61e690a299..04212a3e2e 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -45,6 +45,16 @@
.workHubLive, .workHubDock { width: 100%; height: 100%; min-width: 0; min-height: 0; }
.workHubLive { position: relative; display: flex; flex-direction: column; background: var(--background); }
+ /* The native dock viewport may become narrower than the conversation
+ layout while the Electron window keeps shrinking. Keep WorkHub's own
+ layout floor so Composer and its fixed right-hand action slot are clipped
+ as one surface instead of reflowing into the left controls. */
+ .workHubLive[data-placement='docked'] {
+ position: absolute;
+ inset: 0 auto auto 0;
+ width: max(100%, var(--maka-conversation-min-width));
+ min-width: var(--maka-conversation-min-width);
+ }
.workHubWorkspace { display: flex; flex: 1; min-height: 0; }
.workHubWorkspace > .mainColumn { display: flex; flex: 1; min-width: 0; min-height: 0; }
.workHubWorkspace > .mainColumn > .maka-chat-layout { flex: 1; min-height: 0; }
@@ -124,6 +134,16 @@
.workHubExpandButton { position: absolute; top: 6px; right: 8px; z-index: 2; color: var(--muted-foreground); -webkit-app-region: no-drag; }
html:has(.workHubLive), body:has(.workHubLive), #root:has(.workHubLive) { background: transparent; }
+/* WorkHub owns an explicit layout boundary because its live surface is a
+ separate document painted inside a native WebContentsView. Keep overflow
+ left-anchored and clipped by this boundary instead of relying on the
+ renderer root's centered preload layout. */
+.workhub-application {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: clip;
+}
/* Progress and conversation share the same editor, including its IME state. */
.workHubLive[data-placement='floating'][data-progress='true'] {
@@ -150,7 +170,7 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after {
.workHubProgressText { margin: 0; height: 36px; font-size: 13px; line-height: 18px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.workHubLive[data-progress='true'] .workHubComposerSurface { border-top: 1px solid color-mix(in oklch, var(--foreground) 8%, transparent); }
.workHubLive[data-progress='true'] .maka-composer { --color-background-popover: transparent; }
-.workHubLive[data-progress='true'][data-progress-editing='false'] .maka-composer-astryx > div > div:has(.maka-composer-left-controls) { display: none; }
+.workHubLive[data-progress='true'][data-progress-editing='false'] .maka-composer-footer-leading { display: none; }
@media (prefers-reduced-motion: reduce) {
.workHubLive[data-placement='floating'], .workHubLive[data-placement='floating']::before, body:has(.workHubLive[data-placement='floating'])::after, .workHubLive[data-conversation-expanded] .workHubHistory { transition: none; }
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 2ee696bbc2..9d9e6343d5 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -3460,7 +3460,14 @@ 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; togglePosition?: 'titlebar' | 'edge' } = {}) {
+function WorkbarInShell(props: {
+ longTitle?: boolean;
+ onShare?: () => void;
+ workbarWidth?: number;
+ withConversation?: boolean;
+ togglePosition?: 'titlebar' | 'edge';
+ composer?: Partial;
+} = {}) {
const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace);
const resizable = useResizable({
defaultSize: props.workbarWidth ?? layout.rightWidth,
@@ -3483,7 +3490,13 @@ function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; work
detailChildren={
- {props.withConversation && }>
+ {props.withConversation &&
+ )}>
}
@@ -3722,13 +3735,14 @@ export const WorkbarTitlebarRestore: Story = {
};
const narrowWorkbarShare = fn();
+const narrowWorkbarWidth = 600;
export const NarrowWorkbarClearsTitlebarReserve: Story = {
render: () => (
),
play: async ({ canvasElement }) => {
@@ -3769,10 +3783,9 @@ export const NarrowWorkbarClearsTitlebarReserve: Story = {
titlebar.getBoundingClientRect().left,
),
);
- expect(workbar.getBoundingClientRect().width).toBeCloseTo(
- detail.getBoundingClientRect().width,
- 0,
- );
+ // The right Workbar keeps its configured width even when the narrow detail
+ // column has less room; titlebar clearance is asserted independently below.
+ expect(workbar.getBoundingClientRect().width).toBeCloseTo(narrowWorkbarWidth, 0);
expect(share.getBoundingClientRect().right).toBeLessThanOrEqual(
titlebar.getBoundingClientRect().right,
);
@@ -3783,6 +3796,73 @@ export const NarrowWorkbarClearsTitlebarReserve: Story = {
},
};
+// 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. Model and thinking controls remain available while
+// the lower-priority usage action is hidden.
+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 leftControls = card.querySelector('.maka-composer-left-controls');
+ if (!leftControls) throw new Error('composer footer controls are missing');
+ expect(getComputedStyle(leftControls).flexWrap).toBe('nowrap');
+
+ const send = within(card).getByRole('button', { name: '发送' });
+ const contextGauge = within(card).queryByRole('button', { name: '打开用量追踪' });
+ const thinkingField = card.querySelector(
+ '.maka-model-selection-controls .astryx-field:has(.maka-thinking-level-selector)',
+ );
+ if (!thinkingField) throw new Error('thinking level field is missing');
+ await waitFor(() => {
+ const cardBox = card.getBoundingClientRect();
+ const sendBox = send.getBoundingClientRect();
+ expect(sendBox.left).toBeGreaterThanOrEqual(cardBox.left - 1);
+ expect(sendBox.right).toBeLessThanOrEqual(cardBox.right + 1);
+ expect(contextGauge).toBeNull();
+ expect(getComputedStyle(thinkingField).display).not.toBe('none');
+ const thinkingBox = thinkingField.getBoundingClientRect();
+ expect(thinkingBox.left).toBeGreaterThanOrEqual(cardBox.left - 1);
+ expect(thinkingBox.right).toBeLessThanOrEqual(sendBox.left + 1);
+ // The remaining controls must stay inside their flex slot rather than
+ // painting over the fixed send slot when the window narrows.
+ const controlsBox = leftControls.getBoundingClientRect();
+ expect(leftControls.scrollWidth).toBeLessThanOrEqual(leftControls.clientWidth + 1);
+ expect(controlsBox.right).toBeLessThanOrEqual(sendBox.left + 1);
+ });
+ },
+};
+
// Real path (#3587): an explicit compaction runs as its own host Turn. The
// transcript shows a live "正在压缩上下文…" row driven by the live Turn snapshot
// (rootExecutionKind: 'context_compact'), with no assistant content of its own.
diff --git a/apps/desktop/stories/composer-skill-draft.stories.tsx b/apps/desktop/stories/composer-skill-draft.stories.tsx
index cc9ab4c958..f713ee89f9 100644
--- a/apps/desktop/stories/composer-skill-draft.stories.tsx
+++ b/apps/desktop/stories/composer-skill-draft.stories.tsx
@@ -62,15 +62,25 @@ function SkillDraftHarness(): React.ReactElement {
};
}, []);
return (
-
-
{
- sent(text);
- }}
- onStop={() => {}}
- />
+
+
+ {
+ sent(text);
+ }}
+ onStop={() => {}}
+ />
+
);
}
diff --git a/packages/core/src/refresh-read-coordinator.ts b/packages/core/src/refresh-read-coordinator.ts
index 3103d1f587..6ffe7ae7bf 100644
--- a/packages/core/src/refresh-read-coordinator.ts
+++ b/packages/core/src/refresh-read-coordinator.ts
@@ -41,6 +41,7 @@ export interface RefreshReadCoordinator {
export function createRefreshReadCoordinator(input: {
read: () => Promise;
apply: (result: T) => void;
+ onReadFailure?: () => void;
delayMs: number;
schedule: (callback: () => void, delayMs: number) => CancelScheduledRefresh;
}): RefreshReadCoordinator {
@@ -60,7 +61,10 @@ export function createRefreshReadCoordinator(input: {
if (readRevision !== revision) return;
input.apply(result);
},
- () => {},
+ () => {
+ if (readRevision !== revision) return;
+ input.onReadFailure?.();
+ },
);
};
diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx
index 9f6c229f40..102760c086 100644
--- a/packages/ui/src/__tests__/composer-context-usage.test.tsx
+++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx
@@ -78,6 +78,72 @@ 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);
+ const value = pendingAction.querySelector('.maka-context-usage-value');
+ assert.ok(value);
+ assert.equal(value.getAttribute('aria-busy'), 'true');
+ assert.equal(pendingAction.textContent?.trim(), '--%');
+
+ 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?.querySelector('.maka-context-usage-value'), value);
+ assert.equal(value.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-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
index e1e4aa3090..3a523d54b2 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -315,6 +315,113 @@ 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);
+
+ 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/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
index c9f61dea52..ba70f91cb1 100644
--- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx
+++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
@@ -32,6 +32,7 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { parseHTML } from 'linkedom';
+import type { SessionSummary } from '@maka/core/session';
import { Composer } from '../composer.js';
import { LocaleProvider } from '../locale-context.js';
@@ -88,6 +89,28 @@ test('a running composer keeps Send alone — no mode switch in the send slot',
assert.doesNotMatch(markup, /SegmentedControl/);
});
+test('a pending Session boundary keeps the access control mounted and disabled', () => {
+ const markup = renderToStaticMarkup(
+
+ undefined}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ ,
+ );
+ assert.match(markup, /class="permissionModeIcon"/);
+ assert.match(markup, /aria-label="Permission mode: Auto"[^>]*aria-disabled="true"/);
+});
+
// Pins the #5003 opt-in contract, not a #4815 regression: base already passed
// this exact assertion (reviewed at the #4815 head). What #4815 adds on top —
// staged quotes counting as sendable content without the flag — is covered by
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index d5c9f08c33..a5270556fc 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -175,9 +175,9 @@ export function ChatModelSwitcher(props: {
/**
* Selector has no controlled open prop, so recovery bumps this instead: the
* remount keyed on it opens the panel via `isDefaultOpen`, an entirely
- * documented surface. The key combines the Session id, this recovery nonce,
- * and the notice acknowledgement nonce. The Composer resets recovery on
- * Session changes so the new Session lands closed.
+ * documented surface. The key combines this recovery nonce and the notice
+ * acknowledgement nonce; the Selector itself stays mounted across Session
+ * changes so the trigger does not flicker or lose focus.
*/
openNonce?: number;
/** Force any open surface closed while an interaction prompt occludes the composer. */
@@ -377,7 +377,7 @@ export function ChatModelSwitcher(props: {
return (
)}
footerActions={(
-
+
{/* Resting order: + leftmost, then permission icon. */}
{showPlusMenu ? (
@@ -2479,40 +2481,48 @@ export const Composer = forwardRef<
}}
/>
- {props.footerAccessory}
)}
- sendButton={stopShown ? (
-
{
- if (props.stopPending) return;
- void props.onStop();
- }}
- icon={}
- />
- ) : (
- // GLOBAL ANCHOR — DO NOT RESTYLE. This Send/Stop slot (its size,
- // shape, glyph, and placement) is the one control the whole app
- // navigates by; it has regressed multiple times from well-meaning
- // "improvements". Queue affordances live in the pending plate
- // above the card, never in this button.
- }
- />
+ sendActions={props.footerAccessory ? (
+
+ {props.footerAccessory}
+
+ ) : undefined}
+ sendButton={(
+
+ {stopShown ? (
+ {
+ if (props.stopPending) return;
+ void props.onStop();
+ }}
+ icon={}
+ />
+ ) : (
+ // GLOBAL ANCHOR — DO NOT RESTYLE. This Send/Stop slot (its size,
+ // shape, glyph, and placement) is the one control the whole app
+ // navigates by; it has regressed multiple times from well-meaning
+ // "improvements". Queue affordances live in the pending plate
+ // above the card, never in this button.
+ }
+ />
+ )}
+
)}
/>
@@ -2536,6 +2546,7 @@ export const Composer = forwardRef<
function ContextUsageAction(props: {
usageTokens?: number;
+ pending?: boolean;
declaredContextWindow?: number;
meteredContextWindow?: number;
metadataContextWindow?: number;
@@ -2551,12 +2562,23 @@ function ContextUsageAction(props: {
// at all the usage stands on its own.
const window =
props.declaredContextWindow ?? props.meteredContextWindow ?? props.metadataContextWindow;
+ const usageTokens = props.usageTokens;
+ const share =
+ usageTokens !== undefined && window !== undefined && window > 0
+ ? `${Math.round((usageTokens / window) * 100)}%`
+ : undefined;
+ const hasShare = share !== undefined;
+ const pending = props.pending && !hasShare;
const label =
- props.usageTokens !== undefined && window !== undefined && window > 0
- ? `${Math.round((props.usageTokens / window) * 100)}%`
+ pending
+ ? '--%'
+ : hasShare
+ ? share
: copy.systemNotes.contextUsageLabel;
const tooltip =
- props.usageTokens === undefined
+ pending
+ ? copy.systemNotes.contextUsageOpen
+ : props.usageTokens === undefined
? copy.systemNotes.contextUsageUnavailable
: window !== undefined && window > 0
? copy.systemNotes.contextUsageShare(props.usageTokens, window)
@@ -2565,12 +2587,13 @@ function ContextUsageAction(props: {
}
label={copy.systemNotes.contextUsageOpen}
tooltip={tooltip}
onClick={props.onOpen}
>
- {label}
+ {label}
);
}
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 6fb800e9f3..c4389404dd 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;