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 f8660e2e46..4fefa32aca 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -65,6 +65,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]; @@ -238,19 +269,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 03c39b1c9e..1ef7775134 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -828,7 +828,7 @@ "react": 1 }, "importSpecifiers": 97, - "nonTriviaTokens": 12698 + "nonTriviaTokens": 12692 }, "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 4dca79c1e5..6f67e4d73e 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 06f2af6db6..3c0952215d 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -992,7 +992,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) => ({ @@ -2315,7 +2320,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 91b55b8256..55cc917dc7 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 (