Skip to content
43 changes: 43 additions & 0 deletions apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -233,6 +264,18 @@ 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 wideFloatingBounds = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getBounds());
await app.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows().find((candidate) => candidate.getTitle() === 'WorkHub')!;
window.setBounds({ ...window.getBounds(), width: 360 });
});
await expect.poll(() => workhub.evaluate(() => innerWidth)).toBe(360);
await expect(workhub.getByRole('combobox', { name: /思考级别|Thinking level/ })).toHaveCount(0);
await app.evaluate(({ BrowserWindow }, bounds) => {
const window = BrowserWindow.getAllWindows().find((candidate) => candidate.getTitle() === 'WorkHub')!;
window.setBounds(bounds);
}, wideFloatingBounds);
await expect.poll(() => workhub.evaluate(() => innerWidth)).toBe(wideFloatingBounds.width);
const thinking = workhub.getByRole('combobox', { name: /思考级别|Thinking level/ });
await expect(thinking).toBeEnabled();
await thinking.click();
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@
"react": 1
},
"importSpecifiers": 98,
"nonTriviaTokens": 12840
"nonTriviaTokens": 12824
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
89 changes: 89 additions & 0 deletions apps/desktop/src/main/__tests__/live-context-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -239,12 +244,14 @@ describe('createLiveContextUsageTracker', () => {
const timer = fakeTimer();
const query = scriptedQuery();
const seen: unknown[] = [];
let failures = 0;
const tracker = createLiveContextUsageTracker({
query: query.query,
delayMs: 400,
schedule: timer.schedule,
cancel: timer.cancel,
onChange: (usage) => seen.push(usage),
onReadFailure: () => { failures += 1; },
});
tracker.setTarget({ sessionId: 's1', route: ROUTE });
query.pending[0]!.resolve(available());
Expand All @@ -255,6 +262,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
assert.equal(failures, 1);
tracker.dispose();
});

Expand Down Expand Up @@ -409,3 +417,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('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
Element: window.Element,
HTMLElement: window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
type ContextResult = Awaited<ReturnType<SessionInspectorService['context']>>;
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<ContextResult>((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);
}
});
31 changes: 31 additions & 0 deletions apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
abandonPendingCompanionCopy,
cleanupCompanionCopy,
createFakeWorkbarServices,
dismissCompanionCopy,
ensureCompanionFork,
performCompanionTurn,
type PerformCompanionTurnDeps,
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 });
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,11 +409,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,
// PR-SHOW-AFTER-FIRST-COMMIT: create hidden on every run so the OS never
Expand Down
22 changes: 17 additions & 5 deletions apps/desktop/src/main/workhub-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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;

export interface WorkHubPresentationDeps {
mainWindow(): BrowserWindow | undefined;
Expand Down Expand Up @@ -201,6 +202,14 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {

function resizeFloating(bounds: Electron.Rectangle, animate: boolean): void {
const window = floating!;
const area = screen.getDisplayMatching(bounds).workArea;
const minWidth = Math.min(FLOATING_MIN_WIDTH, area.width);
const width = Math.min(Math.max(bounds.width, minWidth), 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();
Expand Down Expand Up @@ -294,7 +303,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: Math.min(FLOATING_MIN_WIDTH, width), minHeight: Math.min(80, height),
resizable: conversationExpanded,
alwaysOnTop: true, autoHideMenuBar: true, maximizable: false, fullscreenable: false,
frame: false, transparent: true, backgroundColor: '#00000000',
Expand Down Expand Up @@ -372,7 +381,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 = Math.min(Math.max(old.width, Math.min(FLOATING_MIN_WIDTH, area.width)), area.width);
const height = Math.min(conversationExpanded ? expandedHeight : compactHeight, area.height);
const bounds = {
width, height,
Expand All @@ -393,7 +402,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 = Math.min(Math.max(conversationBounds?.width ?? 520, Math.min(FLOATING_MIN_WIDTH, area.width)), area.width);
const height = Math.min(expandedHeight, area.height);
clearProgressRequest();
conversationBounds = undefined;
Expand Down Expand Up @@ -632,8 +641,11 @@ 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 minWidth = Math.min(FLOATING_MIN_WIDTH, area.width);
const width = Math.min(Math.max(bounds.width, minWidth), 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;
}
Expand Down
22 changes: 7 additions & 15 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2352,7 +2352,7 @@ function AppShellContent({
<WorkHubMainNavigation workbarReady={workHubActive && Boolean(workbar.host.activeId)}
onOpenUsage={() => commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRight}
onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
<WorkHubDock workbarCollapsed={selectors.rightCollapsed} enabled={workHubEnabled} visible={workHubActive && sessionsSelected && !shellObscured} />
<WorkHubDock workbar={workbar.host} enabled={workHubEnabled} visible={workHubActive && sessionsSelected && !shellObscured} />
<ChatSurfaceLayout
// ChatView positions this transcript: switching conversations,
// following the tail and the moves the reader asks for are one
Expand Down Expand Up @@ -2491,21 +2491,13 @@ function AppShellContent({
// mode change to land before the run registers and alter the
// execution config of the turn already sent.
permissionModeDisabledReason={
activeStreamingLive
? shellCopy.permissionModeStreaming
: activeId && turnActive
? shellCopy.permissionModeRunning
: activeId && activeSessionForView?.status === 'waiting_for_user'
? shellCopy.permissionModeWaiting
: undefined
}
onPermissionModeChange={
activeBoundarySurface.localInteractionAvailable
? async mode => {
await setPermissionMode(mode)
}
: undefined
!activeBoundarySurface.permissionMode
? boundaryUnreadableNotice?.detail ?? shellCopy.modeChangeLoading
: modeChangeDisabledReason
}
onPermissionModeChange={activeBoundarySurface.localInteractionAvailable
? mode => void setPermissionMode(mode)
: undefined}
planModeActive={activePlanMode}
// No pending-keyed disable while a toggle commits: the
// pending registries already swallow re-entrant toggles, and
Expand Down
Loading
Loading