You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/10 02:08:54 UTC
(maka) branch feat/workhub-progress-input created (now d5f14356f5)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a change to branch feat/workhub-progress-input
in repository https://gitbox.apache.org/repos/asf/maka.git
at d5f14356f5 feat(desktop): steer WorkHub from its progress composer
This branch includes the following new commits:
new d5f14356f5 feat(desktop): steer WorkHub from its progress composer
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/01: feat(desktop): steer WorkHub from its progress composer
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/workhub-progress-input
in repository https://gitbox.apache.org/repos/asf/maka.git
commit d5f14356f555d4a82131012f16fa447871056df4
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Sep 10 10:08:34 2026 +0800
feat(desktop): steer WorkHub from its progress composer
Generated-by: OpenAI Codex
---
.github/assets/workhub-progress/card-dark.png | Bin 179124 -> 0 bytes
.github/assets/workhub-progress/card-light.png | Bin 95169 -> 0 bytes
apps/desktop/e2e/workhub-layout.spec.ts | 5 ++
...runtime-host-session-execution-ipc-main.test.ts | 23 +++++++
.../main/__tests__/workhub-presentation.test.ts | 67 ++++++++++++++++++--
.../main/__tests__/workhub-send-visibility.test.ts | 64 +++++++++++++++++++
.../runtime-host-session-execution-ipc-main.ts | 6 +-
apps/desktop/src/main/workhub-presentation.ts | 64 +++++++++++++++----
apps/desktop/src/preload/workhub-presentation.ts | 3 +-
.../workhub/controller/use-workhub-controller.ts | 38 ++++++++++-
.../features/workhub/locales/workhub-live-copy.ts | 3 +
.../desktop/src/renderer/features/workhub/ports.ts | 1 +
.../features/workhub/ui/workhub-progress-card.tsx | 12 ++--
.../renderer/features/workhub/ui/workhub-root.tsx | 70 ++++++++++++---------
.../platform/desktop/create-workhub-services.ts | 7 +++
apps/desktop/src/renderer/styles/workhub.css | 49 ++++++++++-----
apps/desktop/src/shared/workhub-presentation.d.ts | 3 +-
apps/desktop/stories/workhub.stories.tsx | 3 +-
docs/astryx-surface-file-inventory.md | 2 +-
.../src/__tests__/root-turn-coordinator.test.ts | 63 ++++++++++++++++++-
.../runtime-host/src/server/message-coordinator.ts | 8 +++
.../src/server/root-turn-coordinator.ts | 11 ++++
22 files changed, 428 insertions(+), 74 deletions(-)
diff --git a/.github/assets/workhub-progress/card-dark.png b/.github/assets/workhub-progress/card-dark.png
deleted file mode 100644
index 69f3e17626..0000000000
Binary files a/.github/assets/workhub-progress/card-dark.png and /dev/null differ
diff --git a/.github/assets/workhub-progress/card-light.png b/.github/assets/workhub-progress/card-light.png
deleted file mode 100644
index 2d907e5fe6..0000000000
Binary files a/.github/assets/workhub-progress/card-light.png and /dev/null differ
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index b6fce56bdc..ad2b050257 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -313,6 +313,11 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin
await expect(prompt).toHaveCount(1);
await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting');
await expect(stop).toBeVisible();
+ await workhub.locator(COMPOSER_INPUT).fill('立即调整方向,保持当前任务');
+ await workhub.getByRole('button', { name: /^(发送|Send)$/ }).click();
+ await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Acknowledged steering: 立即调整方向,保持当前任务');
+ await expect(workhub.locator('.maka-user-message').filter({ hasText: '立即调整方向,保持当前任务' })).toHaveCount(1);
+ await expect(stop).toBeVisible();
await stop.click();
await expect(stop).toHaveCount(0);
await expect(workhub.locator('[data-transient-message-id]')).toHaveCount(0);
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index e736c0787a..8da53cb213 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -24,6 +24,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { IpcMain } from "electron";
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation';
import { type AttachmentRef } from '@maka/core/events';
import {
@@ -1958,3 +1959,25 @@ function session(cwd = "/workspace", id = 'session-1'): SessionCatalogProjection
function sideConversationSession(id = 'session-1'): SessionCatalogProjection {
return { ...session('/workspace', id), labels: [SIDE_CONVERSATION_SESSION_LABEL] };
}
+
+
+test('steers WorkHub through Host admission even though the ordinary Session catalog omits it', async () => {
+ const submits: unknown[] = [];
+ const ipc = ipcHarness();
+ registerExecutionIpc({
+ client: executionClient({
+ getSession: async () => null,
+ submitMessage: async (input) => {
+ submits.push(input);
+ return { disposition: 'steering', queueRevision: 1, skillInvocation: { loaded: [], failed: [], receipts: [] } };
+ },
+ }),
+ observer: unusedObserver(), attachmentApprovals: createAttachmentApprovalRegistry(),
+ emitSessionsChanged() {}, stat: async () => ({ size: 0 }), resizeImage: async (bytes) => bytes, beforeStop() {},
+ }, ipc);
+ const result = await ipc.invoke('sessions:submitMessage', WORKHUB_COORDINATION_SESSION_ID, 'current_turn', {
+ messageId: 'workhub-steering', text: 'Change direction immediately',
+ });
+ assert.deepEqual(submits, [{ sessionId: WORKHUB_COORDINATION_SESSION_ID, messageId: 'workhub-steering', placement: 'current_turn', content: { text: 'Change direction immediately', inlineReferences: [] } }]);
+ assert.equal((result as { disposition: string }).disposition, 'steering');
+});
diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
index c5e7968e2b..970d30b3d6 100644
--- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
@@ -277,17 +277,17 @@ test('animates from the current height, keeps the bottom anchored and survives r
assert.equal(floating.bounds.y + floating.bounds.height, bottom);
// A composer measurement during expansion must not restart or shrink it.
await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 114 });
- h.advance(160);
+ h.advance(340);
assert.equal(floating.bounds.height, 720);
await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 });
h.advance(18);
- assert.ok(floating.bounds.height > 700, 'collapse starts gently while the visible conversation fades');
+ assert.ok(floating.bounds.height >= 700, 'collapse starts gently while the visible conversation fades');
h.advance(62);
const intermediate = floating.bounds.height;
assert.ok(intermediate > 110 && intermediate < 720);
await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 110 });
assert.equal(floating.bounds.height, intermediate);
- h.advance(240);
+ h.advance(420);
assert.equal(floating.bounds.height, 720);
assert.equal(floating.bounds.y + floating.bounds.height, bottom);
await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 });
@@ -612,7 +612,7 @@ test('native resize callbacks do not submit duplicate view bounds and follow dis
const before = view.boundsUpdates.length;
h.advance(9);
assert.ok(view.boundsUpdates.length > before, 'a 120Hz display gets its next animation frame before 16ms');
- h.advance(231);
+ h.advance(411);
assert.equal(h.windows[1]!.bounds.height, 720);
for (let index = 1; index < view.boundsUpdates.length; index++) {
assert.notDeepEqual(view.boundsUpdates[index], view.boundsUpdates[index - 1]);
@@ -755,3 +755,62 @@ test('the shortcut opens the normal composer from progress without waiting for i
assert.equal(floating.bounds.width, 520);
h.controller.dispose();
});
+
+
+test('editing progress grows at its existing bottom and opening interpolates both dimensions without stealing focus', async () => {
+ const h = await harness(true);
+ await h.controller.prepareControl('turn');
+ const view = h.views[0]!;
+ const floating = h.windows[1]!;
+ const request = h.controller.getSnapshot().progressRequest!;
+ await h.command(view.webContents, 'ready');
+ await h.command(view.webContents, 'progress-ready', request);
+ floating.isFocused = () => false;
+ const bottom = floating.bounds.y + floating.bounds.height;
+ const center = floating.bounds.x + floating.bounds.width / 2;
+ await assert.rejects(h.command(h.main.webContents, 'progress-layout', { request, height: 180 }), /Only the WorkHub view/);
+ await assert.rejects(h.command(view.webContents, 'progress-layout', { request, height: NaN }), /Invalid WorkHub progress layout/);
+ await h.command(view.webContents, 'progress-layout', { request, height: 180 });
+ h.advance(80);
+ assert.ok(floating.bounds.height > 112 && floating.bounds.height < 180);
+ assert.equal(floating.bounds.width, 360);
+ assert.equal(floating.bounds.y + floating.bounds.height, bottom);
+ assert.equal(h.controller.getSnapshot().progressRequest, request, 'editing does not open the conversation');
+ h.advance(340);
+ assert.equal(floating.bounds.height, 180);
+ await h.command(view.webContents, 'show-conversation', request);
+ h.advance(80);
+ assert.ok(floating.bounds.width > 360 && floating.bounds.width < 520);
+ assert.ok(floating.bounds.height > 180 && floating.bounds.height < 720);
+ assert.ok(Math.abs(floating.bounds.x + floating.bounds.width / 2 - center) <= 0.5);
+ assert.equal(floating.bounds.y + floating.bounds.height, bottom);
+ h.advance(340);
+ assert.equal(floating.bounds.height, 720);
+ assert.equal(floating.bounds.width, 520);
+ assert.deepEqual({ ...view.boundsUpdates.at(-1) }, { x: 0, y: 0, width: 520, height: 720 });
+ assert.equal(floating.focused, 0);
+ assert.equal(h.main.focused, 0);
+ assert.equal(view.webContents.sent.some(([channel]) => channel.endsWith('focus-composer')), false);
+ 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');
+ const view = h.views[0]!;
+ const first = h.controller.getSnapshot().progressRequest!;
+ await h.command(view.webContents, 'hide');
+ await h.command(view.webContents, 'show-conversation', first);
+ assert.equal(h.controller.getSnapshot().floatingVisible, false);
+ await h.controller.prepareControl('second');
+ const second = h.controller.getSnapshot().progressRequest!;
+ await h.command(view.webContents, 'show-conversation', first);
+ await h.command(view.webContents, 'progress-layout', { request: first, height: 600 });
+ await h.command(view.webContents, 'progress-ready', second);
+ assert.equal(h.controller.getSnapshot().floatingVisible, true);
+ assert.equal(h.windows[1]!.bounds.height, 112);
+ await h.command(view.webContents, 'progress-layout', { request: second, height: 180 });
+ assert.equal(h.windows[1]!.bounds.height, 180, 'reduced motion applies the final layout immediately');
+ assert.equal(h.controller.getSnapshot().progressRequest, second);
+ h.controller.dispose();
+});
diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
index f479a0fe0f..3eea67143b 100644
--- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
@@ -47,6 +47,9 @@ async function mountController(failFirstRead = false) {
const latestRead = deferred<void>();
const requests: Array<Parameters<WorkHubServices['answer']>[1]> = [];
let rootTurn: { turnId: string; runId: string; status: 'running' | 'cancelled' | 'completed' } | undefined;
+ const steers: Array<Parameters<WorkHubServices['steer']>> = [];
+ let steerResult: Awaited<ReturnType<WorkHubServices['steer']>> = 'admitted';
+ let onSteer: ((input: Parameters<WorkHubServices['steer']>) => void) | undefined;
const interrupts: Array<{ sessionId: string; turnId: string; runId: string }> = [];
const handlers = new Map<string, IpcHandler>();
const ipc = { handle: (channel: string, handler: IpcHandler) => { handlers.set(channel, handler); } };
@@ -90,6 +93,7 @@ async function mountController(failFirstRead = false) {
handler({ messages: [], ready: true, hasOlder: false, hasNewer: false });
return { observationChanged: () => {}, loadOlder: async () => {}, loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, close: async () => {} };
},
+ steer: async (...input: Parameters<WorkHubServices['steer']>) => { steers.push(input); onSteer?.(input); return steerResult; },
answer: (_id: string, input: Parameters<WorkHubServices['answer']>[1]) => invoke('workhub:answer', input),
stop: (target: string, turnId: string) => invoke('sessions:stop', target, { source: 'stop_button', expectedTurnId: turnId }),
} as unknown as WorkHubServices;
@@ -104,6 +108,8 @@ async function mountController(failFirstRead = false) {
get controller() { return controller; }, get openCount() { return openCount; },
reconnect(epoch = hostEpoch) { hostEpoch = epoch; onPhase('pending'); onPhase('ready'); },
complete(turnId: string) { rootTurn = { turnId, runId: `run:${turnId}`, status: 'completed' }; },
+ onSteer(handler: typeof onSteer) { onSteer = handler; },
+ steers, setSteerResult(value: typeof steerResult) { steerResult = value; },
sessionId, requests, get admission() { return admission; }, latestRead, interrupts,
resetAdmission() { admission = deferred<{ turnId: string }>(); },
admit(turnId: string) { rootTurn = { turnId, runId: `run:${turnId}`, status: 'running' }; },
@@ -337,3 +343,61 @@ test('Retry reopens a failed initial WorkHub read after Session resolution', asy
await act(async () => { h.admission.resolve({ turnId }); await sent; });
h.latestRead.resolve();
});
+
+
+test('WorkHub steering keeps the current Turn and Stop authority and reconciles only its own durable message', async () => {
+ const h = await mountController();
+ let sent!: Promise<boolean>;
+ await act(async () => { sent = h.controller.send('original request', []); });
+ const turnId = h.requests[0]!.turnId;
+ h.admit(turnId);
+ await act(async () => { h.admission.resolve({ turnId }); await sent; });
+ const attachments: AttachmentRef[] = [{ kind: 'doc', name: 'brief.txt', mimeType: 'text/plain', bytes: 4, ref: { kind: 'workspace_file', relativePath: 'brief.txt' } }];
+ await act(async () => { assert.equal(await h.controller.send('change direction', attachments), true); });
+ assert.equal(h.requests.length, 1, 'steering must not start or queue another answer');
+ assert.equal(h.controller.liveTurn?.turnId, turnId);
+ assert.deepEqual(h.steers[0]!.slice(2), ['change direction', attachments]);
+ const messageId = h.steers[0]![1];
+ const original: StoredMessage = { type: 'user', id: 'original-canonical-id', turnId, text: 'original request', ts: 1 };
+ await act(() => h.publish([original]));
+ assert.deepEqual(h.controller.transientMessages.map((message) => message.id), [messageId]);
+ await act(() => h.emit({ type: 'steering_message', id: 'steer-observation', turnId, messageId, ts: 2, content: { text: 'change direction', attachments } }));
+ assert.deepEqual(h.controller.transientMessages, [], 'live steering must not duplicate its admission placeholder while the transcript lags');
+ await act(() => h.publish([original, { type: 'user', id: messageId, turnId, text: 'change direction', attachments, ts: 2 }]));
+ assert.deepEqual(h.controller.transientMessages, []);
+ await act(async () => { await h.controller.stop(); });
+ assert.equal(h.interrupts[0]?.turnId, turnId);
+ h.latestRead.resolve();
+});
+
+test('uncertain steering retains its identity across Turn completion and rejection preserves the active Turn', async () => {
+ const h = await mountController();
+ await act(() => h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }));
+ h.setSteerResult('rejected');
+ await act(async () => { assert.equal(await h.controller.send('change direction', []), false); });
+ assert.equal(h.controller.liveTurn?.turnId, 'active-turn');
+ assert.equal(h.controller.busy, true);
+ assert.deepEqual(h.controller.transientMessages, []);
+ h.setSteerResult('unknown');
+ await act(async () => { assert.equal(await h.controller.send('change direction', []), false); });
+ const messageId = h.steers[1]![1];
+ await act(() => h.emit({ type: 'complete', id: 'done', turnId: 'active-turn', ts: 2, stopReason: 'end_turn' }));
+ h.setSteerResult('admitted');
+ await act(async () => { assert.equal(await h.controller.send('change direction', []), true); });
+ assert.equal(h.steers[2]![1], messageId);
+ assert.equal(h.requests.length, 0, 'retry must recover the steering receipt even after its Turn ends');
+ h.latestRead.resolve();
+});
+
+
+test('steering observed before its admission response renders once and outranks an uncertain receipt', async () => {
+ const h = await mountController();
+ await act(() => h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }));
+ h.setSteerResult('unknown');
+ h.onSteer(([, messageId, text]) => h.emit({ type: 'steering_message', id: 'consumed', turnId: 'active-turn', messageId, ts: 2, content: { text } }));
+ await act(async () => { assert.equal(await h.controller.send('change direction', []), true); });
+ assert.deepEqual(h.controller.transientMessages, []);
+ assert.equal(h.controller.error, undefined);
+ assert.equal(h.controller.liveTurn?.turnId, 'active-turn');
+ h.latestRead.resolve();
+});
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index fa1f0d0504..5854e05d9d 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -428,10 +428,8 @@ export function registerRuntimeHostSessionExecutionIpc(
// the row it already rendered, and what makes a retry the same Message.
// Minting one here would hand back an identity the caller never showed.
if (!command.messageId) throw new Error("Submitted message has no identity");
- const session = await deps.client.getSession(sessionId);
- if (!session) {
- throw new Error(`Runtime Host Session not found: ${sessionId}`);
- }
+ // Host admission validates the target, including steering-only Sessions
+ // such as WorkHub that intentionally do not appear in the task catalog.
let attachments = retainedAttachmentsForSession(
sessionId,
command.retainedAttachments ?? [],
diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts
index bd70308e98..2caa52b6f0 100644
--- a/apps/desktop/src/main/workhub-presentation.ts
+++ b/apps/desktop/src/main/workhub-presentation.ts
@@ -25,6 +25,7 @@ import { installMainWindowPermissionPolicy } from './main-window-permission-poli
const COMMAND = 'workhub-presentation:command';
const SHORTCUT = 'CommandOrControl+Shift+K';
+const RESIZE_DURATION = 420;
export interface WorkHubPresentationDeps {
mainWindow(): BrowserWindow | undefined;
@@ -147,6 +148,8 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
function resizeFloating(bounds: Electron.Rectangle, animate: boolean): void {
const window = floating!;
+ if (resizeTarget && bounds.x === resizeTarget.x && bounds.y === resizeTarget.y &&
+ bounds.width === resizeTarget.width && bounds.height === resizeTarget.height) return;
const initial = window.getBounds();
cancelFloatingAnimation();
if (!animate || !window.isVisible() || systemPreferences.getAnimationSettings().prefersReducedMotion) {
@@ -164,13 +167,15 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
cancelFloatingAnimation();
return;
}
- const progress = Math.min(1, (performance.now() - started) / 240);
- // Shrinking a fully visible conversation needs a gentle start as well
- // as a gentle landing; expansion reveals its content after growing.
- const eased = bounds.height < initial.height ? progress * progress * (3 - 2 * progress) : 1 - (1 - progress) ** 3;
+ 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.
+ const eased = (1 - (1 + 7 * progress) * Math.exp(-7 * progress)) / (1 - 8 * Math.exp(-7));
const height = Math.round(initial.height + (bounds.height - initial.height) * eased);
+ const width = Math.round(initial.width + (bounds.width - initial.width) * eased);
+ const center = initial.x + initial.width / 2 + (bounds.x + bounds.width / 2 - initial.x - initial.width / 2) * eased;
const bottom = Math.round(initial.y + initial.height + (bounds.y + bounds.height - initial.y - initial.height) * eased);
- const next = { ...bounds, height, y: bottom - height };
+ 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) {
window.setBounds(next);
fitFloating();
@@ -180,7 +185,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
// Keep frame deadlines independent of native resize work; do not add
// another full frame's delay after every setBounds/resize callback.
const elapsed = performance.now() - started;
- const nextFrame = Math.min(240, (Math.floor(elapsed / frameDuration) + 1) * frameDuration);
+ const nextFrame = Math.min(RESIZE_DURATION, (Math.floor(elapsed / frameDuration) + 1) * frameDuration);
resizeTimer = setTimeout(tick, Math.max(1, nextFrame - elapsed));
}
else cancelFloatingAnimation();
@@ -196,10 +201,10 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
}
function fitFloating(): void {
- if (!floating || floating.isDestroyed() || parent !== floating || !view || progressRequest !== undefined) return;
+ if (!floating || floating.isDestroyed() || parent !== floating || !view) return;
const { width, height } = floating.getContentBounds();
setViewBounds({ x: 0, y: 0, width, height });
- if (conversationExpanded && !resizeTarget) expandedHeight = height;
+ if (progressRequest === undefined && conversationExpanded && !resizeTarget) expandedHeight = height;
}
function ensureFloating(): BrowserWindow {
@@ -317,12 +322,10 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
placement = 'floating';
attach(target);
view!.setVisible(true);
- // Preserve the parked conversation's viewport. The native window clips the
- // small card; no transcript layout or composer state needs to be replaced.
- setViewBounds({ x: 0, y: 0, width: Math.max(360, viewBounds?.width ?? 520), height: Math.max(112, viewBounds?.height ?? 720) });
const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
const width = Math.min(360, area.width), height = Math.min(112, area.height);
target.setBounds({ width, height, x: area.x + Math.round((area.width - width) / 2), y: Math.max(area.y, area.y + area.height - height - 96) });
+ fitFloating();
// The renderer acknowledges its painted card before showInactive, avoiding
// one frame of the old full conversation in the compact native window.
changed();
@@ -413,6 +416,10 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
return { main, isMain };
};
authorize();
+ if (command === 'show-conversation' && payload !== undefined) {
+ if (typeof payload !== 'number' || !Number.isSafeInteger(payload)) throw new Error('Invalid progress request');
+ if (payload !== progressRequest) return;
+ }
const changesPresentation = command === 'detach' || command === 'show-conversation' || command === 'dock' || command === 'hide' || command === 'session';
const revision = changesPresentation ? ++presentationRevision : presentationRevision;
return enqueue(async () => {
@@ -474,11 +481,44 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
changed();
}
return;
- case 'show-conversation':
+ case 'progress-layout': {
+ if (isMain) throw new Error('Only the WorkHub view can size its progress card');
+ const value = payload as { request?: unknown; height?: unknown } | null;
+ if (!value || typeof value.request !== 'number' || !Number.isSafeInteger(value.request) ||
+ typeof value.height !== 'number' || !Number.isFinite(value.height) || value.height <= 0) throw new Error('Invalid WorkHub progress layout');
+ if (value.request !== progressRequest || !floating || !deps.isEnabled()) return;
+ const bounds = resizeTarget ?? floating.getBounds();
+ const area = screen.getDisplayMatching(bounds).workArea;
+ const height = Math.min(Math.max(112, Math.ceil(value.height)), area.height);
+ if (height !== bounds.height) resizeFloating({ ...bounds, height, y: Math.max(area.y, bounds.y + bounds.height - height) }, true);
+ return;
+ }
+ case 'show-conversation': {
+ if (payload !== undefined && payload !== progressRequest) return;
conversationExpanded = true;
expandOnFocus = true;
+ if (progressRequest !== undefined && floating) {
+ const current = floating.getBounds();
+ const area = screen.getDisplayMatching(current).workArea;
+ const width = Math.min(conversationBounds?.width ?? 520, area.width);
+ const height = Math.min(expandedHeight, area.height);
+ clearProgressRequest();
+ conversationBounds = undefined;
+ floating.setResizable(true);
+ changed();
+ resizeFloating({
+ width, height,
+ x: Math.max(area.x, Math.min(current.x + Math.round((current.width - width) / 2), area.x + area.width - width)),
+ y: Math.max(area.y, Math.min(current.y + current.height - height, area.y + area.height - height)),
+ }, true);
+ // A send acknowledgement can arrive after the user has switched
+ // apps. Growing the conversation must not steal focus back.
+ if (floating.isFocused()) focusComposer();
+ return;
+ }
detach(true);
return;
+ }
case 'detach': detach(); return;
case 'conversation-layout': {
if (isMain) throw new Error('Only the WorkHub view can size its conversation');
diff --git a/apps/desktop/src/preload/workhub-presentation.ts b/apps/desktop/src/preload/workhub-presentation.ts
index a0eba54d67..302a3d8d39 100644
--- a/apps/desktop/src/preload/workhub-presentation.ts
+++ b/apps/desktop/src/preload/workhub-presentation.ts
@@ -32,7 +32,8 @@ export const workHubPresentationBridge: WorkHubPresentationBridge = {
setHost: (host) => ipcRenderer.invoke('workhub-presentation:command', 'host', host),
setConversationLayout: (layout) => ipcRenderer.invoke('workhub-presentation:command', 'conversation-layout', layout),
progressReady: (request) => ipcRenderer.invoke('workhub-presentation:command', 'progress-ready', request),
- showConversation: () => ipcRenderer.invoke('workhub-presentation:command', 'show-conversation'),
+ resizeProgress: (request, height) => ipcRenderer.invoke('workhub-presentation:command', 'progress-layout', { request, height }),
+ showConversation: (request) => ipcRenderer.invoke('workhub-presentation:command', 'show-conversation', request),
detach: () => ipcRenderer.invoke('workhub-presentation:command', 'detach'),
dock: () => ipcRenderer.invoke('workhub-presentation:command', 'dock'),
hide: () => ipcRenderer.invoke('workhub-presentation:command', 'hide'),
diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
index 75655afbd0..ee36a7d454 100644
--- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
+++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
@@ -76,6 +76,7 @@ export function useWorkHubController() {
currentSessionId.current = sessionId;
const sendingRef = useRef(false);
const pendingSend = useRef<SendAttempt | undefined>(undefined);
+ const pendingSteer = useRef<{ sessionId: string; turnId: string; messageId: string; text: string; attachments: AttachmentRef[]; observed: boolean }>(undefined);
const report = (reason: unknown) =>
setError(reason instanceof Error ? reason.message : String(reason));
@@ -254,6 +255,12 @@ export function useWorkHubController() {
sessionId,
(event) => {
if (disposed) return;
+ if (event.type === 'steering_message') {
+ if (pendingSteer.current?.messageId === event.messageId) pendingSteer.current.observed = true;
+ // The live Turn now owns this row, before the durable transcript
+ // necessarily catches up. Retire its admission placeholder.
+ setTransientMessages((previous) => previous.filter((message) => message.id !== event.messageId));
+ }
reconcileAdmission(sessionId, event.turnId, event.type === 'abort' || event.type === 'error' || event.type === 'complete');
setLiveTurn((previous) => {
const next = applyLiveTurnEvent(previous, event, localeRef.current);
@@ -284,7 +291,8 @@ export function useWorkHubController() {
messages.some((message) => message.type === 'turn_state' && message.status !== 'running'));
}
setTransientMessages((previous) => previous.filter((pending) =>
- !snapshot.messages.some((message) => message.type === 'user' && message.turnId === pending.hostTurnId),
+ !snapshot.messages.some((message) => message.type === 'user' &&
+ (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))),
));
setLiveTurn((previous) =>
previous ? reconcileTerminalLiveTurn(previous, [...snapshot.messages]) : previous,
@@ -317,12 +325,34 @@ export function useWorkHubController() {
pendingTurnId ?? (liveTurn && !liveTurn.terminal ? liveTurn.turnId : session?.runningTurnIds?.[0]);
const busy = sending || Boolean(runningTurnId);
async function send(text: string, attachments: AttachmentRef[]) {
- if (!sessionId || !text.trim() || busy || sendingRef.current) return false;
+ if (!sessionId || !text.trim() || sendingRef.current) return false;
const target = sessionId;
+ const previousSteer = pendingSteer.current;
+ const sameSteer = previousSteer?.sessionId === target && previousSteer.text === text &&
+ JSON.stringify(previousSteer.attachments) === JSON.stringify(attachments) ? previousSteer : undefined;
+ const steeringTurnId = sameSteer?.turnId ?? runningTurnId;
sendingRef.current = true;
setSending(true);
setError(undefined);
try {
+ if (steeringTurnId) {
+ const attempt = sameSteer ?? { sessionId: target, turnId: steeringTurnId, messageId: crypto.randomUUID(), text, attachments: [...attachments], observed: false };
+ pendingSteer.current = attempt;
+ const result = await services.steer(target, attempt.messageId, text, attachments);
+ if (result === 'rejected' && pendingSteer.current === attempt) pendingSteer.current = undefined;
+ if (result !== 'admitted' && !attempt.observed) throw new Error(workHubLiveCopy[localeRef.current][result === 'unknown' ? 'sendUnknown' : 'sendNotAdmitted']);
+ if (pendingSteer.current === attempt) pendingSteer.current = undefined;
+ if (currentSessionId.current === target) {
+ viewportNavigation.followLatest(target);
+ if (!attempt.observed && !transcriptRef.current.messages.some((message) => message.id === attempt.messageId)) {
+ setTransientMessages((messages) => [...messages, {
+ id: attempt.messageId, hostTurnId: steeringTurnId, text, attachments: [...attachments],
+ ts: Date.now(), transientPlacement: 'current_turn',
+ }]);
+ }
+ }
+ return true;
+ }
const previous = pendingSend.current;
const sameRejected = previous?.sessionId === target && previous.admission === 'rejected' && previous.input.text === text && JSON.stringify(previous.input.attachments ?? []) === JSON.stringify(attachments);
const attempt: SendAttempt = {
@@ -344,6 +374,10 @@ export function useWorkHubController() {
const result = await services.answer(target, attempt.input);
return acceptAnswer(attempt, result);
} catch (reason) {
+ if (steeringTurnId) {
+ if (currentSessionId.current === target) report(reason);
+ return false;
+ }
if (currentSessionId.current === target) {
const attempt = pendingSend.current;
const failedTurnId = attempt?.input.turnId;
diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
index e4f997555f..b696d97942 100644
--- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
+++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
@@ -27,6 +27,7 @@ export const workHubLiveCopy = {
controlFailed: 'The action could not be completed. Review the conversation and try again.',
progressTitle: 'WorkHub progress', progressWorking: 'Working', progressPaused: 'Paused', progressError: 'Needs attention', progressDone: 'Finished', progressHint: 'You can keep working in Maka.', progressOpen: 'View conversation', progressClose: 'Hide progress for this task',
title: 'WorkHub',
+ progressInput: 'Add a message…',
float: 'Float WorkHub',
dock: 'Return to Maka',
hide: 'Hide',
@@ -48,6 +49,7 @@ export const workHubLiveCopy = {
controlFailed: '操作未完成,请查看对话后重试。',
progressTitle: 'WorkHub 进度', progressWorking: '正在处理', progressPaused: '已暂停', progressError: '需要查看', progressDone: '本轮已结束', progressHint: '你可以继续在 Maka 中操作。', progressOpen: '查看对话', progressClose: '本轮不再显示进度',
title: '工作台',
+ progressInput: '补充一句…',
float: '浮出工作台',
dock: '收回 Maka',
hide: '隐藏',
@@ -69,6 +71,7 @@ export const workHubLiveCopy = {
controlFailed: '操作未完成,請查看對話後重試。',
progressTitle: 'WorkHub 進度', progressWorking: '正在處理', progressPaused: '已暫停', progressError: '需要查看', progressDone: '本輪已結束', progressHint: '你可以繼續在 Maka 中操作。', progressOpen: '查看對話', progressClose: '本輪不再顯示進度',
title: '工作台',
+ progressInput: '補充一句…',
float: '浮出工作台',
dock: '收回 Maka',
hide: '隱藏',
diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts
index e7de73fd11..5f12e0eb17 100644
--- a/apps/desktop/src/renderer/features/workhub/ports.ts
+++ b/apps/desktop/src/renderer/features/workhub/ports.ts
@@ -58,6 +58,7 @@ export interface WorkHubServices {
readAttachmentBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
prepareAttachments(sessionId: string, items: Array<{ approvalId: string; name: string; mimeType?: string } | { file: File }>): Promise<AttachmentRef[]>;
answer(sessionId: string, input: WorkHubAnswerInput): Promise<WorkHubAnswerResult>;
+ steer(sessionId: string, messageId: string, text: string, attachments: AttachmentRef[]): Promise<'admitted' | 'unknown' | 'rejected'>;
configureModel(
sessionId: string,
input: OperationInput<'workhub.coordination.configureModel'>,
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx
index 1e086d2eb1..9eccbc7993 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx
@@ -17,8 +17,8 @@
* under the License.
*/
-import { useEffect } from 'react';
-import { Button, IconButton } from '@astryxdesign/core';
+import { useEffect, type Ref } from 'react';
+import { IconButton } from '@astryxdesign/core';
import { MakaWordmark, useUiLocale, type LiveTurnProjection } from '@maka/ui';
import { ArrowRight, X } from '@maka/ui/icons';
import type { StoredMessage } from '@maka/core/session';
@@ -38,12 +38,14 @@ function latestText(liveTurn: LiveTurnProjection | undefined, messages: readonly
return undefined;
}
-export function WorkHubProgressCard({ request, control, liveTurn, messages, busy }: {
+export function WorkHubProgressCard({ ref, request, control, liveTurn, messages, busy, onOpen }: {
+ ref?: Ref<HTMLElement>;
request: number;
control?: WorkHubControlSnapshot;
liveTurn?: LiveTurnProjection;
messages: readonly StoredMessage[];
busy: boolean;
+ onOpen(): void;
}) {
const { presentation } = useWorkHubServices();
const t = workHubLiveCopy[useUiLocale()];
@@ -57,13 +59,13 @@ export function WorkHubProgressCard({ request, control, liveTurn, messages, busy
});
return () => { cancelAnimationFrame(first); cancelAnimationFrame(second); };
}, [presentation, request]);
- return <aside className="workHubProgressCard" aria-label={t.progressTitle}>
+ return <aside ref={ref} className="workHubProgressCard" aria-label={t.progressTitle}>
<div className="workHubProgressHeader">
<span className="workHubProgressBrand"><MakaWordmark width={42} /></span>
<span className="workHubProgressStatus" role="status"><i data-active={busy || control?.phase === 'acting'} />{status}</span>
+ <IconButton className="workHubProgressOpen" size="sm" variant="ghost" label={t.progressOpen} tooltip={t.progressOpen} icon={<ArrowRight size={12} />} onClick={onOpen} />
<IconButton className="workHubProgressClose" size="sm" variant="ghost" icon={<X size={12} />} label={t.progressClose} onClick={() => { void presentation.hide().catch(console.error); }} />
</div>
<p className="workHubProgressText">{spoken?.replace(/\s+/g, ' ').trim() || t.progressHint}</p>
- <Button className="workHubProgressOpen" size="sm" variant="ghost" label={t.progressOpen} endContent={<ArrowRight size={12} />} onClick={() => { void presentation.showConversation().catch(console.error); }} />
</aside>;
}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index c00a7d2c06..5be98e92ec 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -75,8 +75,9 @@ export function WorkHubRoot() {
const revealMark = useRef<HTMLDivElement>(null);
const history = useRef<HTMLDivElement>(null);
const hasPresented = useRef(false);
- const pendingComposerFocus = useRef(false);
+ const progressHeader = useRef<HTMLElement>(null);
const surface = useRef<HTMLElement>(null);
+ const [editingProgressRequest, setEditingProgressRequest] = useState<number>();
const [expandedOverride, setConversationExpanded] = useState<boolean>();
const hasConversation = transcript.messages.length > 0 || busy || Boolean(controller.liveTurn);
const conversationExpanded = expandedOverride ?? hasConversation;
@@ -86,34 +87,36 @@ export function WorkHubRoot() {
const [control, setControl] = useState<WorkHubControlSnapshot>();
const [presentation, setPresentation] = useState<WorkHubPresentationSnapshot>();
const progress = presentation?.progressRequest !== undefined;
- const floating = !progress && presentation?.placement === 'floating';
- const showConversation = !floating || conversationExpanded;
- useLayoutEffect(() => {
- if (!progress && pendingComposerFocus.current) {
- pendingComposerFocus.current = false;
- composer.current?.focus();
- }
- }, [progress]);
+ const editingProgress = progress && editingProgressRequest === presentation.progressRequest;
+ const floating = presentation?.placement === 'floating';
+ const showConversation = !progress && (!floating || conversationExpanded);
+ const editProgress = () => {
+ if (progress) setEditingProgressRequest(presentation.progressRequest);
+ };
const compactHeight = () => Math.ceil(composerSurface.current?.getBoundingClientRect().height ?? 96);
- useEffect(() => {
- if (!composerSurface.current || progress) return;
+ useLayoutEffect(() => {
+ if (!composerSurface.current) return;
const resize = () => {
surface.current?.style.setProperty('--workhub-composer-height', `${compactHeight()}px`);
+ if (progress) {
+ const height = Math.ceil(progressHeader.current?.getBoundingClientRect().height ?? 80) + compactHeight() + 2;
+ void services.presentation.resizeProgress(presentation.progressRequest!, height).catch(controller.report);
+ return;
+ }
void services.presentation.setConversationLayout({ expanded: conversationExpanded, compactHeight: compactHeight() }).catch(controller.report);
};
const observer = new ResizeObserver(resize);
observer.observe(composerSurface.current);
+ if (progressHeader.current) observer.observe(progressHeader.current);
resize();
return () => observer.disconnect();
- }, [services, floating, conversationExpanded, progress]);
- const toggleConversation = async () => {
+ }, [services, floating, conversationExpanded, presentation?.progressRequest, editingProgress]);
+ const toggleConversation = () => {
if (conversationExpanded) {
cancelReveal(revealMark.current, history.current);
setExpandedLayoutHeight(surface.current?.querySelector('.maka-chat-layout')?.getBoundingClientRect().height ?? 0);
setConversationExpanded(false);
} else {
- revealWordmark(revealMark.current, history.current);
- await services.presentation.setConversationLayout({ expanded: true, compactHeight: compactHeight() });
setConversationExpanded(true);
}
composer.current?.focus();
@@ -122,6 +125,7 @@ export function WorkHubRoot() {
let active = true;
const acceptPresentation = (next: WorkHubPresentationSnapshot) => {
if (!active) return;
+ if (next.progressRequest !== undefined) hasPresented.current = true;
if (next.progressRequest !== undefined || next.placement !== 'floating' || !next.floatingVisible) {
cancelReveal(revealMark.current, history.current);
for (const animation of composerSurface.current?.getAnimations() ?? []) animation.cancel();
@@ -144,8 +148,7 @@ export function WorkHubRoot() {
void services.control.getSnapshot().then(acceptControl).catch(controller.report);
const focus = services.presentation.onFocusComposer((expand) => {
if (expand) setConversationExpanded(true);
- if (surface.current?.inert) pendingComposerFocus.current = true;
- else composer.current?.focus();
+ composer.current?.focus();
// A warm summon must not fade the last painted frame back out.
if (hasPresented.current) return;
hasPresented.current = true;
@@ -178,13 +181,16 @@ export function WorkHubRoot() {
};
return (
<WorkHubHighlightProvider>
- {progress && <WorkHubProgressCard request={presentation.progressRequest!} control={control} liveTurn={controller.liveTurn} messages={transcript.messages} busy={busy} />}
- <section ref={surface} inert={progress} aria-hidden={progress} data-progress-parked={progress} className="workHubLive workhub-surface" data-placement={presentation?.placement ?? 'docked'} data-conversation-expanded={showConversation} aria-label={t.title}>
- {floating && conversationExpanded && <div className="workHubWindowControls">
+ <section ref={surface} data-progress={progress} data-progress-editing={editingProgress} className="workHubLive workhub-surface" data-placement={presentation?.placement ?? 'docked'} data-conversation-expanded={showConversation} aria-label={t.title}>
+ {progress && <WorkHubProgressCard ref={progressHeader} request={presentation.progressRequest!} control={control} liveTurn={controller.liveTurn} messages={transcript.messages} busy={busy} onOpen={() => {
+ setConversationExpanded(true);
+ call(services.presentation.showConversation(presentation.progressRequest));
+ }} />}
+ {!progress && floating && conversationExpanded && <div className="workHubWindowControls">
<IconButton className="workHubCloseButton" type="button" size="sm" variant="ghost" icon={<X size={12} />} label={t.hide} onClick={() => call(services.presentation.hide())} />
<div className="workHubWindowActions">
<IconButton type="button" size="sm" variant="ghost" icon={<PictureInPicture2 size={14}><path d="m8 11-4-4m0 4V7h4" /></PictureInPicture2>} label={t.dock} onClick={() => call(services.presentation.dock())} />
- <IconButton type="button" size="sm" variant="ghost" icon={<ChevronDown size={14} style={{ rotate: conversationExpanded ? '0deg' : '180deg' }} />} label={conversationExpanded ? t.collapseConversation : t.expandConversation} aria-expanded={conversationExpanded} onClick={() => call(toggleConversation())} />
+ <IconButton type="button" size="sm" variant="ghost" icon={<ChevronDown size={14} style={{ rotate: conversationExpanded ? '0deg' : '180deg' }} />} label={conversationExpanded ? t.collapseConversation : t.expandConversation} aria-expanded={conversationExpanded} onClick={toggleConversation} />
</div>
</div>}
<div className="workHubRevealMark" ref={revealMark} aria-hidden="true"><MakaWordmark width={192} /></div>
@@ -193,7 +199,7 @@ export function WorkHubRoot() {
style={!showConversation ? { height: expandedLayoutHeight, flex: 'none', position: 'absolute', bottom: 0, width: '100%' } : undefined}
onReturnToTail={transcript.hasNewer ? controller.loadLatest : undefined}
composer={
- <div className="workHubComposerSurface" ref={composerSurface}>
+ <div className="workHubComposerSurface" ref={composerSurface} onFocusCapture={editProgress} onPointerUpCapture={editProgress}>
{(controller.error || control?.error) && (
<div className="workHubLiveError" role="alert">
{controller.error ?? t.controlFailed}
@@ -203,13 +209,21 @@ export function WorkHubRoot() {
</div>
)}
<WorkHubComposer
- placeholder={t.welcome}
+ placeholder={progress ? t.progressInput : t.welcome}
ref={composer}
sessionId={controller.sessionId}
streaming={busy}
- sendBlocked={!controller.sessionId || busy || !session?.model}
+ sendBlocked={!controller.sessionId || controller.sending || !session?.model}
+ allowAttachmentImportWhileStreaming
stopPending={controller.stopPending}
- onSend={(text, attachments) => { setConversationExpanded(true); return controller.send(text, attachments); }}
+ onSend={async (text, attachments) => {
+ const accepted = await controller.send(text, attachments);
+ if (accepted) {
+ setConversationExpanded(true);
+ if (progress) call(services.presentation.showConversation(presentation.progressRequest));
+ }
+ return accepted;
+ }}
onStop={controller.stop}
activeSession={session}
activeModel={session?.model}
@@ -217,7 +231,7 @@ export function WorkHubRoot() {
activeModelConnectionSlug={session?.llmConnectionSlug}
modelChoices={controller.choices}
modelPickerPresentation={showConversation ? 'menu' : 'wheel'}
- maxInputRows={showConversation ? undefined : 6}
+ maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
onModelChange={controller.changeModel}
modelSwitchHasHistory={transcript.messages.length > 0}
footerAccessory={
@@ -227,8 +241,8 @@ export function WorkHubRoot() {
</div>
}
/>
- {floating && !conversationExpanded && (
- <IconButton className="workHubExpandButton" type="button" size="sm" variant="ghost" icon={<ChevronDown size={14} style={{ rotate: '180deg' }} />} label={t.expandConversation} aria-expanded={false} onClick={() => call(toggleConversation())} />
+ {!progress && floating && !conversationExpanded && (
+ <IconButton className="workHubExpandButton" type="button" size="sm" variant="ghost" icon={<ChevronDown size={14} style={{ rotate: '180deg' }} />} label={t.expandConversation} aria-expanded={false} onClick={toggleConversation} />
)}
</div>
}
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
index 127e8ab0ff..3e6ce665c2 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
@@ -79,6 +79,13 @@ export function createDesktopWorkHubServices(
readAttachmentBytes: bridge.attachments.readBytes,
prepareAttachments: (sessionId, items) => bridge.workHub.prepareAttachments(sessionId, items),
answer: (sessionId, input) => bridge.workHub.answer(sessionId, input),
+ steer: async (sessionId, messageId, text, attachments) => {
+ const result = await bridge.sessions.submitMessage(sessionId, 'current_turn', {
+ messageId, text, retainedAttachments: attachments,
+ }, { waitForHostAdmission: true });
+ if (result.ok) return result.disposition === 'steering' ? 'admitted' : 'rejected';
+ return result.reason === 'outcome_unknown' ? 'unknown' : 'rejected';
+ },
configureModel: (sessionId, input) => bridge.workHub.configureModel(sessionId, input),
observe: (sessionId, handler, onError, onPhase) =>
bridge.sessions.subscribeEvents(sessionId, handler, () => onPhase('ready'), onPhase, onError),
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 6499d60335..5591569493 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -162,21 +162,32 @@
.workHubRevealMark svg { filter: drop-shadow(0 2px 12px var(--background)); }
.workHubLive[data-conversation-expanded='false'] .workHubRevealMark { visibility: hidden; }
.workHubLiveWelcome > svg { color: var(--maka-brand); }
- .workHubHistory { transition: opacity 180ms ease-out, visibility 0s; }
- .workHubLive[data-conversation-expanded='false'] .workHubHistory { visibility: hidden; opacity: 0; transition-delay: 0s, 180ms; }
+ .workHubHistory { transition: opacity 240ms ease-out, visibility 0s; }
+ .workHubLive[data-conversation-expanded='false'] .workHubHistory { visibility: hidden; opacity: 0; transition-delay: 0s, 240ms; }
.workHubWindowControls { position: absolute; inset: 4px 12px auto; height: 28px; display: flex; align-items: center; justify-content: space-between; z-index: 2; -webkit-app-region: drag; }
.workHubWindowActions { display: flex; gap: 4px; }
.workHubWindowControls button { -webkit-app-region: no-drag; color: var(--muted-foreground); }
.workHubWindowControls .workHubCloseButton { width: 18px; height: 18px; min-width: 18px; min-height: 18px; padding: 0; border-radius: 50%; background: color-mix(in oklch, var(--foreground) 13%, transparent); }
/* Transparent Windows windows need content clipping, including the global grain overlay. */
.workHubLive[data-placement='floating'], body:has(.workHubLive[data-placement='floating'])::after {
- border-radius: 20px;
+ border-radius: 26px;
+ transition: border-radius 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.workHubLive[data-placement='floating'] {
overflow: hidden;
padding-top: 32px;
- background: linear-gradient(145deg, color-mix(in oklch, var(--background) 90%, transparent), color-mix(in oklch, var(--background) 84%, transparent));
+ background: linear-gradient(145deg, rgb(255 255 255 / 3%), transparent 70%);
+ background-color: color-mix(in oklch, var(--background) 86%, transparent);
+ transition: border-radius 420ms cubic-bezier(0.22, 1, 0.36, 1), background-color 420ms ease-out;
}
+ .workHubLive[data-placement='floating']::before {
+ content: ''; position: absolute; inset: 0; z-index: 4; pointer-events: none; border-radius: inherit;
+ background: linear-gradient(155deg, rgb(255 255 255 / 7%), transparent 38%, transparent 72%, rgb(255 255 255 / 3%));
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 24%), inset 0 -1px 0 color-mix(in oklch, var(--foreground) 9%, transparent), inset 0 0 0 1px color-mix(in oklch, var(--foreground) 7%, transparent);
+ opacity: 0.65; transition: opacity 420ms ease-out;
+ }
+ .workHubLive[data-placement='floating'][data-conversation-expanded='false'], body:has(.workHubLive[data-placement='floating'][data-conversation-expanded='false'])::after { border-radius: 20px; }
+ .workHubLive[data-placement='floating'][data-conversation-expanded='false']::before { opacity: 1; }
.workHubLive[data-placement='floating'] .workhub-anchor-rail { display: none; }
.workHubLive[data-placement='floating'] .maka-chat-layout { background: transparent; }
.workHubLive[data-placement='floating'] .maka-composer {
@@ -207,21 +218,31 @@ html:has(.workHubLive), body:has(.workHubLive), #root:has(.workHubLive) { backgr
.workhub-filters { flex-wrap: nowrap; overflow-x: auto; }
}
-/* Progress is a passive companion; the conversation stays mounted behind it. */
-.workHubLive[data-progress-parked='true'] { visibility: hidden; pointer-events: none; }
-.workHubProgressCard {
- position: fixed; inset: 0 auto auto 0; width: 360px; height: 112px; box-sizing: border-box;
- display: flex; flex-direction: column; gap: 4px; padding: 11px 13px 9px;
+/* Progress and conversation share the same editor, including its IME state. */
+.workHubLive[data-placement='floating'][data-progress='true'] {
border: 1px solid color-mix(in oklch, var(--foreground) 12%, transparent); border-radius: 18px;
- background: linear-gradient(135deg, color-mix(in oklch, var(--background) 96%, transparent), color-mix(in oklch, var(--background) 88%, transparent));
- color: var(--foreground); overflow: hidden; -webkit-app-region: drag;
+ background-color: color-mix(in oklch, var(--background) 94%, transparent);
+ overflow: hidden;
}
-body:has(.workHubProgressCard)::after { width: 360px; height: 112px; border-radius: 18px; }
+.workHubProgressCard {
+ position: absolute; inset: 0 0 auto; box-sizing: border-box; z-index: 2;
+ display: flex; flex-direction: column; gap: 4px; padding: 11px 13px 7px;
+ color: var(--foreground); -webkit-app-region: drag;
+}
+body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after { border-radius: 18px; }
+.workHubLive[data-progress='true'][data-progress-editing='true'], body:has(.workHubLive[data-progress='true'][data-progress-editing='true'])::after { border-radius: 24px; }
+.workHubLive[data-progress='true'][data-progress-editing='true'] { background-color: color-mix(in oklch, var(--background) 90%, transparent); }
.workHubProgressHeader { display: flex; align-items: center; gap: 9px; min-height: 18px; }
.workHubProgressBrand { display: flex; opacity: 0.7; }
.workHubProgressStatus { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; font-size: 11px; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.workHubProgressStatus i { flex: none; width: 5px; height: 5px; border-radius: 50%; background: var(--muted-foreground); }
.workHubProgressStatus i[data-active='true'] { background: var(--maka-brand); }
-.workHubProgressCard .workHubProgressClose { width: 18px; height: 18px; min-width: 18px; padding: 0; -webkit-app-region: no-drag; color: var(--muted-foreground); }
+.workHubProgressCard :is(.workHubProgressClose, .workHubProgressOpen) { width: 18px; height: 18px; min-width: 18px; padding: 0; -webkit-app-region: no-drag; color: var(--muted-foreground); }
.workHubProgressText { margin: 0; height: 36px; font-size: 13px; line-height: 18px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-.workHubProgressOpen { align-self: flex-end; -webkit-app-region: no-drag; }
+.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; }
+
+@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/src/shared/workhub-presentation.d.ts b/apps/desktop/src/shared/workhub-presentation.d.ts
index 2386768f60..3c42cf86a5 100644
--- a/apps/desktop/src/shared/workhub-presentation.d.ts
+++ b/apps/desktop/src/shared/workhub-presentation.d.ts
@@ -48,7 +48,8 @@ export interface WorkHubPresentationBridge {
setHost(host: WorkHubHost): Promise<string | void>;
setConversationLayout(layout: { expanded: boolean; compactHeight: number }): Promise<void>;
progressReady(request: number): Promise<void>;
- showConversation(): Promise<void>;
+ resizeProgress(request: number, height: number): Promise<void>;
+ showConversation(progressRequest?: number): Promise<void>;
detach(): Promise<void>;
dock(): Promise<void>;
hide(): Promise<void>;
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 8610f75b0e..c6503efe88 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -50,8 +50,9 @@ function makeServices(failFirst: boolean, withHistory: boolean): WorkHubServices
let updateSessions: (() => void) | undefined;
const publish = () => updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true });
return {
+ steer: async () => 'admitted',
surface: 'workhub', initialLocale: 'zh-CN', subscribeAppearance: () => () => {},
- presentation: { ready: async () => {}, progressReady: async () => {}, showConversation: async () => {}, getSnapshot: async () => ({ placement: 'docked', floatingVisible: false, shortcutRegistered: true, rendererCrashed: false }), setHost: async () => {}, setConversationLayout: async () => {}, detach: async () => {}, dock: async () => {}, hide: async () => {}, openSession: async (id) => { writes.open(id); }, subscribe: () => () => {}, onFocusComposer: () => () => {}, onOpenMain: () => [...]
+ presentation: { ready: async () => {}, progressReady: async () => {}, resizeProgress: async () => {}, showConversation: async () => {}, getSnapshot: async () => ({ placement: 'docked', floatingVisible: false, shortcutRegistered: true, rendererCrashed: false }), setHost: async () => {}, setConversationLayout: async () => {}, detach: async () => {}, dock: async () => {}, hide: async () => {}, openSession: async (id) => { writes.open(id); }, subscribe: () => () => {}, onFocusComposer: ( [...]
control: { getSnapshot: async () => ({ revision: 0, phase: 'idle', canUndo: false }), subscribe: () => () => {}, stop: async () => {}, undo: async () => {} },
resolve: async () => sessionId, subscribeHosts: () => () => {}, subscribeAvailability: () => () => {},
getSession: async () => session,
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index f2ff06b435..4c72867bdb 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -106,7 +106,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx` | other | Button, List, ListItem, StatusDot | aligned — uses Astryx (Button, List, ListItem, StatusDot) | aligned |
-| `apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx` | other | Button, IconButton | aligned — uses Astryx (Button, IconButton) | aligned |
+| `apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx` | other | IconButton | aligned — uses Astryx (IconButton) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx` | other | Button, IconButton | aligned — uses Astryx (Button, IconButton) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
index d4834c4a0b..ca28c0e7c2 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -3723,11 +3723,32 @@ test('WorkHub v2 binds the requesting Desktop before admission while v1 stays un
test('active WorkHub authority reads the admitted v2 input and refuses other or completed Turns', async () => {
for (const toolProfile of ['workhub-coordination-v1', 'workhub-coordination-v2'] as const) {
let backend: BlockingRootBackend | undefined;
+ const consumed: string[] = [];
const fixture = await createFailureFixture({
registerBackend: (backends) => {
backends.register(
'ai-sdk',
- (context) => (backend = new BlockingRootBackend(context.sessionId)),
+ (context) =>
+ (backend = new (class extends BlockingRootBackend {
+ override async *send(input: BackendSendInput): AsyncIterable<SessionEvent> {
+ for await (const event of super.send(input)) {
+ for (const lease of input.pullSteering?.() ?? []) {
+ yield {
+ type: 'steering_message',
+ id: randomUUID(),
+ turnId: input.turnId,
+ ts: Date.now(),
+ messageId: lease.messageId,
+ content: lease.content,
+ submittedContentDigest: lease.submittedContentDigest,
+ };
+ input.ackSteering?.([lease.id]);
+ consumed.push(lease.messageId);
+ }
+ yield event;
+ }
+ }
+ })(context.sessionId)),
);
},
});
@@ -3746,6 +3767,25 @@ test('active WorkHub authority reads the admitted v2 input and refuses other or
permissionMode: toolProfile === 'workhub-coordination-v2' ? 'bypass' : 'explore',
},
});
+ const submit = (
+ messageId: string,
+ placement: 'current_turn' | 'next_turn' = 'current_turn',
+ ) =>
+ fixture.messages.handlers['turn.message.submit'](
+ {
+ originHostEpoch: fixture.hostEpoch,
+ sessionId: WORKHUB_COORDINATION_SESSION_ID,
+ messageId,
+ content: { text: 'Change direction immediately' },
+ placement,
+ },
+ operationContext(fixture.hostEpoch, fixture.acquireResidency, 'desktop'),
+ );
+ assert.equal(
+ (await submit('idle-steering')).ok,
+ false,
+ 'idle WorkHub cannot start an ordinary Turn',
+ );
const turnId = 'live-workhub-turn';
const content = { text: 'Continue Payments and explain the result here' };
assert.equal(await fixture.coordinator.readActiveWorkHubRequest(turnId), undefined);
@@ -3766,6 +3806,26 @@ test('active WorkHub authority reads the admitted v2 input and refuses other or
toolProfile === 'workhub-coordination-v2' ? content : undefined,
);
assert.equal(await fixture.coordinator.readActiveWorkHubRequest('other-turn'), undefined);
+ const submitted = await submit('workhub-steering');
+ assert.equal(
+ submitted.ok && submitted.result.disposition,
+ toolProfile === 'workhub-coordination-v2' ? 'steering' : false,
+ );
+ assert.equal(
+ (await submit('workhub-followup', 'next_turn')).ok,
+ false,
+ 'WorkHub cannot queue another ordinary Turn',
+ );
+ if (toolProfile === 'workhub-coordination-v2') {
+ backend!.release();
+ await fixture.coordinator.whenIdle(WORKHUB_COORDINATION_SESSION_ID);
+ assert.deepEqual(consumed, ['workhub-steering']);
+ assert.equal(
+ (await submit('workhub-steering')).ok,
+ true,
+ 'a lost reply is recovered from durable admission after completion',
+ );
+ }
await fixture.coordinator.stopRoot({
sessionId: WORKHUB_COORDINATION_SESSION_ID,
turnId,
@@ -3773,6 +3833,7 @@ test('active WorkHub authority reads the admitted v2 input and refuses other or
});
await fixture.coordinator.whenIdle(WORKHUB_COORDINATION_SESSION_ID);
assert.equal(await fixture.coordinator.readActiveWorkHubRequest(turnId), undefined);
+ assert.equal((await submit('completed-steering')).ok, false);
} finally {
backend?.release();
await fixture.coordinator.close();
diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts
index f82f6e896c..835d46ac83 100644
--- a/packages/runtime-host/src/server/message-coordinator.ts
+++ b/packages/runtime-host/src/server/message-coordinator.ts
@@ -105,6 +105,8 @@ const EMPTY_SKILL_INVOCATION: SkillInvocationResult = {
export interface HostMessageSessionHeader {
readonly isArchived: boolean;
readonly unavailableReason?: string;
+ /** A reserved execution may accept steering without admitting a successor Turn. */
+ readonly steeringOnly?: boolean;
}
export type HostMessageRootState =
@@ -1256,10 +1258,16 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
if (header.unavailableReason) {
return failure('operation_unavailable', header.unavailableReason);
}
+ if (header.steeringOnly && input.placement !== 'current_turn') {
+ return failure('operation_unavailable', 'This Session accepts steering only');
+ }
const rootState = await this.#root.readRootState(input.sessionId);
if (this.#failStopped) {
return failure('host_draining', 'Runtime Host message authority has failed');
}
+ if (header.steeringOnly && rootState.kind !== 'active') {
+ return failure('operation_unavailable', 'No active Turn can accept steering');
+ }
if (rootState.kind === 'idle') {
const existingState = this.#sessions.get(input.sessionId);
if (existingState && hasLiveMessageState(existingState)) {
diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts
index 2b46e2dbd0..5c0674b661 100644
--- a/packages/runtime-host/src/server/root-turn-coordinator.ts
+++ b/packages/runtime-host/src/server/root-turn-coordinator.ts
@@ -518,6 +518,17 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
async readSessionHeader(sessionId: string): Promise<HostMessageSessionHeader | null> {
if (isWorkHubCoordinationSessionId(sessionId)) {
+ const active = this.#executions.get(sessionId);
+ if (
+ active?.descriptor.kind === 'workhub_coordination' &&
+ !active.descriptor.operation &&
+ !active.stopRequested
+ ) {
+ const header = await this.stores.sessionStore.readHeaderSnapshot(sessionId);
+ if (header.toolProfile === 'workhub-coordination-v2') {
+ return { isArchived: header.isArchived, steeringOnly: true };
+ }
+ }
return {
isArchived: false,
unavailableReason: WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org