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/08 05:26:29 UTC
(maka) branch main updated: feat(workhub): restore chat identity and reuse standard composer (#5003)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 431ca4257a feat(workhub): restore chat identity and reuse standard composer (#5003)
431ca4257a is described below
commit 431ca4257a23860e2ecd6e438de7214b88fb542b
Author: ChengBo Zhang <66...@users.noreply.github.com>
AuthorDate: Tue Sep 8 13:26:24 2026 +0800
feat(workhub): restore chat identity and reuse standard composer (#5003)
Reuse the standard Composer for WorkHub attachments and per-Work configuration, align conversation layouts, and link Work identity across messages and navigation. Preserve attachment ownership when delegating and advance protocol compatibility to epoch 133.
Generated-by: Codex
---
apps/desktop/e2e/workhub-reconstruction.spec.ts | 13 ++
apps/desktop/renderer-architecture.json | 42 ++---
.../main/__tests__/composer-directories.test.ts | 2 +-
.../main/__tests__/new-task-staged-content.test.ts | 2 +-
.../runtime-host-workhub-ipc-main.test.ts | 2 +
.../src/main/__tests__/workhub-controller.test.ts | 19 +++
.../main/__tests__/workhub-surface-flow.test.ts | 3 +
.../src/main/runtime-host-desktop-candidate.ts | 1 +
.../src/main/runtime-host-workhub-ipc-main.ts | 16 ++
apps/desktop/src/preload/bridge-contract.d.ts | 1 +
apps/desktop/src/preload/preload.ts | 4 +
apps/desktop/src/renderer/app-shell.tsx | 41 ++---
apps/desktop/src/renderer/composer-attachments.ts | 59 +------
.../composition/desktop-feature-services.tsx | 5 +
.../controller/use-composer-attachments.ts | 34 ++++
.../src/renderer/features/conversation/index.ts | 5 +
.../model/slash-command-presentation.ts | 51 ++++++
.../tools/side-chat/quote-companion-panel.tsx | 5 +-
.../desktop/src/renderer/features/workhub/index.ts | 6 +
.../renderer/features/workhub/services-context.tsx | 35 ++++
.../features/workhub/ui/workhub-composer.tsx | 171 +++++++++++++++++++
.../workhub/ui/workhub-navigation-rail.tsx | 15 +-
.../features/workhub/ui/workhub-prompt-rail.tsx | 17 +-
.../features/workhub/ui/workhub-work-identity.tsx | 42 +++++
apps/desktop/src/renderer/locales/workhub-copy.ts | 64 +++++++
apps/desktop/src/renderer/pending-items.ts | 56 +-----
.../desktop/create-workhub-composer-services.ts} | 15 +-
apps/desktop/src/renderer/styles/workhub.css | 154 +++++++++++++++--
apps/desktop/src/renderer/workhub-controller.ts | 14 +-
.../src/renderer/workhub-coordination-port.ts | 2 +
apps/desktop/src/renderer/workhub-surface.tsx | 107 ++++++++++--
apps/desktop/stories/workhub.stories.tsx | 190 +++++++++++++++++++++
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 3 +
packages/core/src/session.ts | 50 +++++-
.../workhub-coordination-action-gate.test.ts | 27 +++
.../workhub-coordination-protocol.test.ts | 59 +++++++
.../__tests__/workhub-message-attachments.test.ts | 101 +++++++++++
packages/runtime-host/src/protocol/index.ts | 4 +-
.../src/protocol/workhub-coordination.ts | 48 +++++-
.../src/server/execution-composition.ts | 28 ++-
.../src/server/workhub-coordination-action-gate.ts | 33 +++-
.../src/server/workhub-coordination-coordinator.ts | 16 +-
.../src/server/workhub-message-attachments.ts | 60 +++++++
packages/ui/package.json | 5 +-
.../ui/src}/composer-attachments.ts | 0
packages/ui/src/composer.tsx | 6 +-
.../renderer => packages/ui/src}/pending-items.ts | 0
packages/ui/src/prompt-anchor-rail.tsx | 15 +-
.../ui/src}/use-composer-attachments.ts | 19 ++-
50 files changed, 1429 insertions(+), 243 deletions(-)
diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts
index 397ca975e3..3acf19c471 100644
--- a/apps/desktop/e2e/workhub-reconstruction.spec.ts
+++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts
@@ -58,6 +58,14 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba
await workHubComposer.press('Enter');
const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt });
await expect(routedTurn.locator('.workhub-submitted')).toBeVisible();
+ await expect(routedTurn.locator('.workhub-message-identity')).toContainText(sessionName!);
+ const workIdentity = await routedTurn.getAttribute('data-work-session-id');
+ expect(workIdentity).toBeTruthy();
+ const identityColor = await routedTurn.evaluate((element) =>
+ getComputedStyle(element).getPropertyValue('--workhub-work-color'),
+ );
+ expect(identityColor).toContain('oklch');
+
await routedTurn.locator('.workhub-submitted > button').click();
await expect(page.getByRole('region', { name: 'WorkHub' })).toBeHidden();
@@ -74,6 +82,11 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba
page.locator('.workhub-projected-turn', { hasText: routedPrompt })
.locator('.workhub-submitted-state'),
).toHaveText('关联有效 · 已完成');
+ await expect(routedTurn).toHaveAttribute('data-work-session-id', workIdentity!);
+ expect(await routedTurn.evaluate((element) =>
+ getComputedStyle(element).getPropertyValue('--workhub-work-color'),
+ )).toBe(identityColor);
+
});
test('WorkHub replaces the exact linked delegation across Sessions', async ({
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 51b23b7387..9a6a332a4b 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -223,7 +223,6 @@
"src/renderer/use-app-shell-session-list.ts",
"src/renderer/use-app-shell-session-ui-reads.ts",
"src/renderer/use-app-shell-session-workspace.ts",
- "src/renderer/use-composer-attachments.ts",
"src/renderer/use-deep-research-run.ts",
"src/renderer/use-delayed-flag.ts",
"src/renderer/use-external-store-selector.ts",
@@ -281,7 +280,6 @@
"src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions",
"src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy",
"src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions",
- "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments",
"src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge",
"src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme",
"src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel"
@@ -718,7 +716,7 @@
"nonTriviaTokens": 1395
},
"src/renderer/app-shell.tsx": {
- "importDeclarations": 79,
+ "importDeclarations": 77,
"bridgePaths": {
"window.maka.attachments": 1,
"window.maka.attachments.readBytes": 1,
@@ -830,6 +828,7 @@
"./error-boundary": 1,
"./features/app-update/index.js": 1,
"./features/conversation": 1,
+ "./features/conversation/index.js": 1,
"./features/goals": 1,
"./features/module-hub": 1,
"./features/session-collaboration": 1,
@@ -860,7 +859,6 @@
"./use-app-shell-composer-quotes": 1,
"./use-app-shell-session-ui-reads": 1,
"./use-app-shell-session-workspace": 1,
- "./use-composer-attachments": 1,
"./use-new-task-choice": 1,
"./use-onboarding-snapshot": 1,
"./use-project-context": 1,
@@ -891,11 +889,10 @@
"@maka/core/slash-command-catalog": 1,
"@maka/core/ui-locale": 1,
"@maka/ui": 1,
- "@maka/ui/icons": 1,
"react": 1
},
- "importSpecifiers": 121,
- "nonTriviaTokens": 14630
+ "importSpecifiers": 116,
+ "nonTriviaTokens": 14544
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
@@ -1237,7 +1234,9 @@
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
- "dependencyPaths": {}
+ "dependencyPaths": {
+ "./features/conversation/index.js": 1
+ }
},
"src/renderer/composer-defaults.ts": {
"bridgePaths": {},
@@ -2036,7 +2035,9 @@
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
- "dependencyPaths": {}
+ "dependencyPaths": {
+ "./features/conversation/index.js": 1
+ }
},
"src/renderer/pending-session-view.ts": {
"bridgePaths": {},
@@ -4168,29 +4169,6 @@
"react": 1
}
},
- "src/renderer/use-composer-attachments.ts": {
- "bridgePaths": {},
- "environmentCapabilities": {},
- "hookCalls": {
- "useEffect": 2,
- "useRef": 2,
- "useState": 2,
- "useUiLocale": 1
- },
- "lifecycleMethods": {},
- "unresolvedDependencies": 0,
- "actionFactories": [],
- "dependencyPaths": {
- "./composer-attachments.js": 1,
- "./locales/conversation-copy.js": 1,
- "./locales/shell-copy.js": 1,
- "./pending-items.js": 1,
- "@maka/core/attachments": 1,
- "@maka/core/events": 1,
- "@maka/ui": 1,
- "react": 1
- }
- },
"src/renderer/use-deep-research-run.ts": {
"bridgePaths": {
"window.maka.deepResearch.get": 1,
diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts
index b82d888e14..f3b093de2c 100644
--- a/apps/desktop/src/main/__tests__/composer-directories.test.ts
+++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts
@@ -25,7 +25,7 @@ import { normalizeSessionSendCommand } from '../permission-response-guard.js';
import {
useComposerAttachments,
type ComposerAttachmentService,
-} from '../../renderer/use-composer-attachments.js';
+} from '../../renderer/features/conversation/index.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
afterEach(cleanupFakeDom);
diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
index b0fa5ee43e..b5e9755d52 100644
--- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
+++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
@@ -29,7 +29,7 @@ import { getDesktopConversationCopy } from '../../renderer/locales/conversation-
import {
useComposerAttachments,
type ComposerAttachmentService,
-} from '../../renderer/use-composer-attachments.js';
+} from '../../renderer/features/conversation/index.js';
import { useAppShellComposerQuotes } from '../../renderer/use-app-shell-composer-quotes.js';
import {
composerModelSupportsVision,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts
index 70a84dedf9..33e8313b62 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts
@@ -94,6 +94,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain'
assert.deepEqual(
await handlers.get('workhub:act')?.({}, {
actionId: 'create-action',
+ newWorkDefaults: { permissionMode: 'bypass' },
userText: 'Start accessibility review',
proposal: { disposition: 'create_new', title: 'Accessibility review' },
create: {
@@ -112,6 +113,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain'
);
assert.deepEqual(actions, [{
actionId: 'create-action',
+ newWorkDefaults: { permissionMode: 'bypass' },
userText: 'Start accessibility review',
proposal: { disposition: 'create_new', title: 'Accessibility review' },
create: {
diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts
index b8f0fc0fb1..cddaaeb527 100644
--- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts
@@ -3273,3 +3273,22 @@ test('deterministic routing preserves executable instructions after the model te
assert.equal(result.kind, 'submitted');
if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'ledger');
});
+
+
+test('composer defaults apply only to creation while attachments follow explicit and automatic routing', async () => {
+ const actions: WorkHubCoordinationActInput[] = [];
+ const newWorkDefaults = { model: { llmConnectionId: 'chosen', llmConnectionSlug: 'chosen', model: 'chosen-model' }, permissionMode: 'bypass' as const };
+ const attachments: NonNullable<WorkHubCoordinationActInput['attachments']> = [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'file-1' } }];
+ const existing = createWorkHubController({ sessions: port([session('payments')]), onAct: (input) => actions.push(input) });
+ await existing.submit({ requestId: 'explicit-composer', text: 'Continue payments', explicitTarget: { sessionId: 'payments' }, newWorkDefaults, attachments });
+ assert.equal(actions[0]?.proposal.disposition, 'delegate_existing');
+ assert.equal(actions[0]?.newWorkDefaults, undefined);
+ assert.deepEqual(actions[0]?.attachments, attachments);
+ const freshPort = port([]);
+ freshPort.create = async () => session('created-work');
+ const fresh = createWorkHubController({ sessions: freshPort, onAct: (input) => actions.push(input) });
+ await fresh.submit({ requestId: 'new-composer', text: 'Create a new Session for an accessibility audit', newWorkDefaults, attachments });
+ assert.equal(actions[1]?.proposal.disposition, 'create_new');
+ assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults);
+ assert.deepEqual(actions[1]?.attachments, attachments);
+});
diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
index 772fb55f39..fceaff86c7 100644
--- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
@@ -154,6 +154,9 @@ test('durable delegation renders every projected target state as a navigable res
);
assert.match(markup, /<button/u);
assert.match(markup, /Payments/u);
+ assert.match(markup, /data-work-session-id="payment"/u);
+ assert.match(markup, /workhub-message-identity/u);
+ assert.match(markup, /--workhub-work-hue:/u);
assert.match(markup, /Active link/u);
assert.match(markup, new RegExp(label, 'u'));
assert.match(markup, new RegExp(`data-state="${state}"`, 'u'));
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index 32dfc95ce3..3fd058bf61 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -866,6 +866,7 @@ export async function createDesktopRuntimeHostCandidate(
});
if (target.access === 'owner') {
registerRuntimeHostWorkHubIpc(client, ipc, {
+ attachmentIngest: { approvals: deps.attachmentApprovals, stat: deps.stat, resizeImage: deps.resizeImage },
resolveCreateProject: () => deps.resolveSessionCreateProject({}, target),
emitSessionsChanged,
});
diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts
index 1ebca81bf6..7bb4283f0a 100644
--- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts
@@ -25,11 +25,14 @@ import type {
WorkspaceTarget,
} from '@maka/runtime-host/protocol';
import { RuntimeHostOperationError } from '@maka/runtime-host/client';
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
+import { prepareIngestItems, resolveAttachmentRefs } from './attachment-ingest.js';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js';
type RuntimeHostWorkHubClient = Pick<
DesktopRuntimeHostClient,
+ | 'ingestAttachment'
| 'actWorkHubCoordination'
| 'listWorkHubCoordinationCandidates'
| 'recordWorkHubCoordination'
@@ -39,6 +42,7 @@ type RuntimeHostWorkHubClient = Pick<
type RendererWorkHubActionInput = Omit<WorkHubCoordinationActInput, 'create'>;
export interface RuntimeHostWorkHubIpcOptions {
+ attachmentIngest?: Pick<Parameters<typeof prepareIngestItems>[0], 'approvals' | 'stat'> & { resizeImage?: (bytes: Uint8Array) => Promise<Uint8Array> };
resolveCreateProject(): Promise<WorkspaceTarget>;
emitSessionsChanged(reason: 'created' | 'status-change', sessionId: string): void;
}
@@ -56,6 +60,16 @@ export function registerRuntimeHostWorkHubIpc(
client.recordWorkHubCoordination(input),
);
ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates());
+ ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown) => {
+ if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable');
+ const prepared = await prepareIngestItems({ ...options.attachmentIngest, senderId: event.sender.id, items });
+ const refs = await resolveAttachmentRefs({
+ files: prepared.files,
+ resizeImage: options.attachmentIngest.resizeImage,
+ snapshot: ({ name, mimeType, content }) => client.ingestAttachment({ sessionId: WORKHUB_COORDINATION_SESSION_ID, name, mimeType, content }),
+ });
+ return prepared.commit(() => refs);
+ });
ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => {
try {
const proposal = rawInput?.proposal;
@@ -63,6 +77,7 @@ export function registerRuntimeHostWorkHubIpc(
actionId: rawInput?.actionId,
userText: rawInput?.userText,
proposal,
+ ...(rawInput?.attachments ? { attachments: rawInput.attachments } : {}),
...(rawInput?.confirmation === undefined
? {}
: { confirmation: rawInput.confirmation }),
@@ -78,6 +93,7 @@ export function registerRuntimeHostWorkHubIpc(
if (createsTarget) {
result = await client.actWorkHubCoordination({
...base,
+ ...(rawInput.newWorkDefaults ? { newWorkDefaults: rawInput.newWorkDefaults } : {}),
create: {
workspace: await options.resolveCreateProject(),
},
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 85e6b0a70e..42e131e946 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1007,6 +1007,7 @@ export interface MakaBridge {
): () => void;
};
workHub: {
+ prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise<AttachmentRef[]>;
/** Resolve the active Runtime Host's stable coordination conversation. */
resolveCoordinationSession(): Promise<string>;
/** Persist one deterministic clarification or routing summary. */
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 99790e2880..ff2eaacdc2 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2000,6 +2000,10 @@ const makaBridge = {
},
},
workHub: {
+ async prepareAttachments(coordinationSessionId: string, items: Parameters<MakaBridge['workHub']['prepareAttachments']>[1]) {
+ const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef);
+ return ipcRenderer.invoke('workhub:prepareAttachments', scope, await encodeIngestItems(items));
+ },
resolveCoordinationSession(): Promise<string> {
return resolveDesktopWorkHubCoordinationSession(
activeRuntimeHostRef,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 24518aa030..cf79cdd9b4 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -35,7 +35,6 @@ import type {
} from '@maka/core/events';
import type { OrchestrationMode } from '@maka/core/orchestration';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
-import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog';
import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale';
import { collapseSessionRevisions } from '@maka/core/session-revisions';
import { isLinkedSubagentSession } from '@maka/core/session';
@@ -66,7 +65,6 @@ import {
reconcileInteractions,
} from '@maka/ui';
import type { ConnectionEvent } from '@maka/core/connections';
-import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons';
import { Button } from '@astryxdesign/core/Button';
import { useKeyboardHelp } from './keyboard-help';
import { useCommandPalette } from './command-palette';
@@ -195,7 +193,7 @@ import {
import * as liveContent from './live-content-seed';
import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults';
import { useTurnActionRegistry } from './use-turn-action-registry';
-import { useComposerAttachments } from './use-composer-attachments';
+import { useComposerAttachments, desktopSlashCommandPresentation } from './features/conversation/index.js';
import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes';
import {
type ComposerMentionsSurfaceInput,
@@ -552,7 +550,7 @@ function AppShellContent({
const sessionHostConnections = useShellConnections({
toastApi,
uiLocale,
- target: { kind: 'session', sessionId: ownerActiveId },
+ target: { kind: 'session', sessionId: workHubActive ? workHubCoordinationSessionId : ownerActiveId },
});
const startupConnectionSnapshot = onboarding.snapshot;
const newTaskUsesDefaultHost = taskEntry.selectors.usesDefaultHost;
@@ -568,7 +566,7 @@ function AppShellContent({
}
: defaultHostConnections.snapshot;
}
- const activeConnectionSnapshot = activeId
+ const activeConnectionSnapshot = workHubActive || activeId
? sessionHostConnections.snapshot
: newTaskConnectionSnapshot;
const connections = activeConnectionSnapshot.connections;
@@ -1222,31 +1220,7 @@ function AppShellContent({
streaming: turnActive || activeStreamingLive,
}),
);
- const presentation: Record<
- SlashCommandIdForSurface<'desktop'>,
- Omit<ComposerSlashCommandOption, 'id'>
- > = {
- compact: {
- ...shellCopy.slashCommands.compact,
- keywords: ['compact', 'context', '压缩', '上下文'],
- Icon: Minimize2,
- },
- side: {
- ...shellCopy.slashCommands.side,
- keywords: ['side', 'btw', '侧聊', '追问'],
- Icon: MessageCircleQuestion,
- },
- swarm: {
- ...shellCopy.slashCommands.swarm,
- keywords: ['swarm', 'multi-agent', '多智能体'],
- Icon: Network,
- },
- graph: {
- ...shellCopy.slashCommands.graph,
- keywords: ['graph', 'agent graph', '智能体图'],
- Icon: GitBranch,
- },
- };
+ const presentation = desktopSlashCommandPresentation(shellCopy.slashCommands);
return availableCommands.map(({ id }) => ({ id, ...presentation[id] }));
},
[activeId, activeStreamingLive, shellCopy.slashCommands, turnActive],
@@ -2655,6 +2629,13 @@ function AppShellContent({
locale={uiLocale}
{...(activeId ? { initialFocusSessionId: activeId } : {})}
onOpenSession={openSessionInChat}
+ composerServices={{
+ sessions,
+ modelChoices: chatModelChoices,
+ defaults: { model: newChatModel, permissionMode: newTaskPermissionMode },
+ confirmBypass: () => confirmBypassPermission(toastApi, uiLocale),
+ onOpenModelSettings: () => openSettingsSection('models'),
+ }}
/>
) : (
<WorkHubCoordinationStatus
diff --git a/apps/desktop/src/renderer/composer-attachments.ts b/apps/desktop/src/renderer/composer-attachments.ts
index d978b4ec87..900879e9ff 100644
--- a/apps/desktop/src/renderer/composer-attachments.ts
+++ b/apps/desktop/src/renderer/composer-attachments.ts
@@ -17,61 +17,4 @@
* under the License.
*/
-import type { AttachmentRef } from '@maka/core/events';
-
-export type PendingAttachment = {
- /** Unique per staged item; keys preview ownership and cleanup. */
- stagingKey: string;
- displayName: string;
- mimeType?: string;
- kind: AttachmentRef['kind'];
- size: number;
- /** Present only after the URL has decoded successfully. */
- previewUrl?: string;
- source:
- | { type: 'approval'; approvalId: string; name: string }
- | { type: 'file'; file: File }
- | { type: 'retained'; attachment: AttachmentRef };
-};
-
-export type ComposerIngestInput =
- | { approvalId: string; name: string; mimeType?: string }
- | { file: File };
-
-/** Stable identity across preview-URL merges. */
-export function pendingAttachmentSourceKey(
- attachment: PendingAttachment,
-): unknown {
- if (attachment.source.type === 'approval') {
- return `approval:${attachment.source.approvalId}`;
- }
- if (attachment.source.type === 'file') return attachment.source.file;
- return `retained:${JSON.stringify(attachment.source.attachment)}`;
-}
-
-export function toComposerIngestItems(
- pending: readonly PendingAttachment[],
-): ComposerIngestInput[] {
- return pending.flatMap((item) => {
- if (item.source.type === 'retained') return [];
- return [
- item.source.type === 'approval'
- ? {
- approvalId: item.source.approvalId,
- name: item.source.name,
- ...(item.mimeType ? { mimeType: item.mimeType } : {}),
- }
- : { file: item.source.file },
- ];
- });
-}
-
-export function retainedAttachmentRefs(
- pending: readonly PendingAttachment[],
-): AttachmentRef[] {
- return pending.flatMap((item) =>
- item.source.type === 'retained'
- ? [structuredClone(item.source.attachment)]
- : [],
- );
-}
+export { toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment } from './features/conversation/index.js';
diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
index d12a42a7ca..70a402938b 100644
--- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
+++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
@@ -27,6 +27,7 @@ import { ModuleHubServicesProvider } from '../features/module-hub';
import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management';
import { SessionCollaborationServicesProvider } from '../features/session-collaboration';
import { SessionNavigationServicesProvider } from '../features/session-navigation';
+import { WorkHubComposerServicesProvider } from '../features/workhub/index.js';
import { SessionSettingsServicesProvider } from '../features/session-settings';
import { TaskEntryServicesProvider } from '../features/task-entry';
import { WorkbarServicesProvider } from '../features/workbar';
@@ -39,6 +40,7 @@ import { createDesktopSessionCollaborationServices } from '../platform/desktop/c
import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services';
import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services';
import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services';
+import { createDesktopWorkHubComposerServices } from '../platform/desktop/create-workhub-composer-services.js';
import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services';
export function createDesktopFeatureServices() {
@@ -54,6 +56,7 @@ export function createDesktopFeatureServices() {
sessionSettings: createDesktopSessionSettingsServices(),
taskEntry: createDesktopTaskEntryServices(),
workbar: createDesktopWorkbarServices(),
+ workhub: createDesktopWorkHubComposerServices(),
};
}
@@ -68,6 +71,7 @@ export function DesktopFeatureServicesProvider(props: {
<SessionCollaborationServicesProvider services={props.services.sessionCollaboration}>
<SessionNavigationServicesProvider services={props.services.sessionNavigation}>
<SessionSettingsServicesProvider services={props.services.sessionSettings}>
+ <WorkHubComposerServicesProvider services={props.services.workhub}>
<TaskEntryServicesProvider services={props.services.taskEntry}>
<ModuleHubServicesProvider services={props.services.moduleHub}>
<GoalServicesProvider services={props.services.goal}>
@@ -79,6 +83,7 @@ export function DesktopFeatureServicesProvider(props: {
</GoalServicesProvider>
</ModuleHubServicesProvider>
</TaskEntryServicesProvider>
+ </WorkHubComposerServicesProvider>
</SessionSettingsServicesProvider>
</SessionNavigationServicesProvider>
</SessionCollaborationServicesProvider>
diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts
new file mode 100644
index 0000000000..c47feb32d9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useUiLocale } from '@maka/ui';
+import { useComposerAttachments as useSharedComposerAttachments } from '@maka/ui/use-composer-attachments';
+import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
+import { localizedShellErrorMessage } from '../../../locales/shell-copy.js';
+export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments';
+
+/** Desktop localization for the shared staging and preview lifecycle. */
+export function useComposerAttachments(options: Omit<Parameters<typeof useSharedComposerAttachments>[0], 'copy' | 'formatError'>) {
+ const locale = useUiLocale();
+ return useSharedComposerAttachments({
+ ...options,
+ copy: getDesktopConversationCopy(locale).actions,
+ formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale),
+ });
+}
diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts
index ee44a48d4c..95a408f8d2 100644
--- a/apps/desktop/src/renderer/features/conversation/index.ts
+++ b/apps/desktop/src/renderer/features/conversation/index.ts
@@ -52,3 +52,8 @@ export type { ConversationServices } from './ports.js';
export { ConversationServicesProvider } from './services.js';
export { SessionLocalMessages } from './controller/session-local-messages.js';
export { restoreTranscriptTailAfterSend } from './controller/transcript-reading-position.js';
+
+export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js';
+export { toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment } from '@maka/ui/composer-attachments';
+export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending, type PendingByKey } from '@maka/ui/pending-items';
+export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js';
diff --git a/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts b/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts
new file mode 100644
index 0000000000..a66df2d2d9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons';
+import type { ComposerSlashCommandOption } from '@maka/ui';
+import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog';
+import type { getShellCopy } from '../../../locales/shell-copy.js';
+export function desktopSlashCommandPresentation(copy: ReturnType<typeof getShellCopy>['app']['slashCommands']) {
+ const presentation: Record<
+ SlashCommandIdForSurface<'desktop'>,
+ Omit<ComposerSlashCommandOption, 'id'>
+ > = {
+ compact: {
+ ...copy.compact,
+ keywords: ['compact', 'context', '压缩', '上下文'],
+ Icon: Minimize2,
+ },
+ side: {
+ ...copy.side,
+ keywords: ['side', 'btw', '侧聊', '追问'],
+ Icon: MessageCircleQuestion,
+ },
+ swarm: {
+ ...copy.swarm,
+ keywords: ['swarm', 'multi-agent', '多智能体'],
+ Icon: Network,
+ },
+ graph: {
+ ...copy.graph,
+ keywords: ['graph', 'agent graph', '智能体图'],
+ Icon: GitBranch,
+ },
+ };
+ return presentation;
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index d49fafb4a0..353fe2f510 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -36,7 +36,8 @@ import {
import type { SessionSummary } from '@maka/core/session';
import { generalizedErrorMessageForLocale } from '@maka/core/redaction';
import { useQuoteCompanion } from './use-quote-companion';
-import { useComposerAttachments } from '../../../../use-composer-attachments';
+import { useComposerAttachments } from '@maka/ui/use-composer-attachments';
+import { localizedShellErrorMessage } from '../../../../locales/shell-copy.js';
import { useComposerMentionsContext } from '../../../../composer-mentions.js';
import { preflightAttachmentItems } from '../../../../attachment-preflight';
import { toComposerIngestItems } from '../../../../composer-attachments';
@@ -137,6 +138,8 @@ export function QuoteCompanionPanel(props: {
removeAttachment,
clearSubmittedAttachments,
} = useComposerAttachments({
+ copy: getDesktopConversationCopy(locale).actions,
+ formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale),
draftKey,
toastApi: toast,
service: attachments,
diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts
index 10f1cf43e5..e3e7d8f734 100644
--- a/apps/desktop/src/renderer/features/workhub/index.ts
+++ b/apps/desktop/src/renderer/features/workhub/index.ts
@@ -23,3 +23,9 @@ export * from './model/routing-strategy.js';
export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js';
export { WorkHubPromptRail } from './ui/workhub-prompt-rail.js';
+
+export { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue } from './ui/workhub-work-identity.js';
+
+export { WorkHubComposer, type WorkHubComposerServices, type WorkHubComposerSelection } from './ui/workhub-composer.js';
+
+export { WorkHubComposerServicesProvider } from './services-context.js';
diff --git a/apps/desktop/src/renderer/features/workhub/services-context.tsx b/apps/desktop/src/renderer/features/workhub/services-context.tsx
new file mode 100644
index 0000000000..fd71321192
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workhub/services-context.tsx
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createServicesContext } from '../../application/contracts/feature-services.js';
+import type { WorkHubCreateDefaults } from '@maka/core/session';
+import type { ChatDefaultPermissionMode } from '@maka/core/settings';
+
+export interface WorkHubComposerPort {
+ attachments: {
+ pickFiles(): Promise<{ ok: true; files: Array<{ approvalId: string; name: string; mimeType?: string; size: number }> } | { ok: false; reason: 'cancelled' }>;
+ previewApproval(approvalId: string): Promise<{ ok: true; base64: string; mimeType: string } | { ok: false; reason: string }>;
+ };
+ prepareAttachments(sessionId: string, items: Array<{ approvalId: string; name: string; mimeType?: string } | { file: File }>): Promise<import('@maka/core/events').AttachmentRef[]>;
+ setModelConfiguration(sessionId: string, input: NonNullable<WorkHubCreateDefaults['model']> & { thinkingLevel: null }): Promise<unknown>;
+ setPermissionMode(sessionId: string, mode: ChatDefaultPermissionMode): Promise<unknown>;
+}
+const { Provider, useServices } = createServicesContext<WorkHubComposerPort>('WorkHubComposerServicesProvider');
+export const WorkHubComposerServicesProvider = Provider;
+export const useWorkHubComposerServices = useServices;
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx
new file mode 100644
index 0000000000..2ac3781a36
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useRef, useState } from 'react';
+import { Composer, Selector, useToast, type ComposerProps, type ChatModelChoice } from '@maka/ui';
+import type { SessionSummary, WorkHubCreateDefaults } from '@maka/core/session';
+import { isChatDefaultPermissionMode } from '@maka/core/settings';
+import { useComposerAttachments } from '@maka/ui/use-composer-attachments';
+import { toComposerIngestItems } from '@maka/ui/composer-attachments';
+import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
+import type { AttachmentRef } from '@maka/core/events';
+import { getWorkHubComposerCopy } from '../../../locales/workhub-copy.js';
+import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
+import { localizedShellErrorMessage } from '../../../locales/shell-copy.js';
+import type { UiLocale } from '@maka/core/ui-locale';
+import { useWorkHubComposerServices } from '../services-context.js';
+import { workHubIdentityHue } from './workhub-work-identity.js';
+
+export interface WorkHubComposerServices {
+ sessions: readonly SessionSummary[];
+ modelChoices: ChatModelChoice[];
+ defaults: WorkHubCreateDefaults;
+ confirmBypass(): Promise<boolean>;
+ onOpenModelSettings(): void;
+}
+
+export interface WorkHubComposerSelection {
+ attachments?: AttachmentRef[];
+ explicitTarget?: { sessionId: string };
+ newWorkDefaults?: WorkHubCreateDefaults;
+}
+
+/** Bind the shared Composer to an explicit Work or to creation defaults. */
+type WorkHubComposerProps = Omit<ComposerProps, 'onSend'> & {
+ services?: WorkHubComposerServices;
+ locale: UiLocale;
+ attachmentScope?: string;
+ onSend(text: string, selection: WorkHubComposerSelection, onAccepted?: () => void): Promise<boolean>;
+};
+
+export function WorkHubComposer(props: WorkHubComposerProps) {
+ if (!props.services) return <Composer {...props} onSend={(text) => props.onSend(text, {})} />;
+ return <ConfiguredWorkHubComposer {...props} />;
+}
+
+function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, ...composer }: WorkHubComposerProps) {
+ const settings = useWorkHubComposerServices();
+ const [selectedId, setSelectedId] = useState('');
+ const [defaults, setDefaults] = useState<WorkHubCreateDefaults>(services?.defaults ?? {});
+ const [changing, setChanging] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const toast = useToast();
+ const staged = useComposerAttachments({
+ draftKey: `workhub:${attachmentScope}`,
+ toastApi: toast,
+ service: settings.attachments,
+ copy: getDesktopConversationCopy(locale).actions,
+ formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale),
+ });
+ const uploaded = useRef(new Map<string, AttachmentRef>());
+ const copy = getWorkHubComposerCopy(locale);
+ const selected = services?.sessions.find((session) => session.id === selectedId);
+ const unavailable = Boolean(selectedId && !selected);
+ const defaultChoice = services?.modelChoices.find((choice) => defaults.model &&
+ choice.connectionId === defaults.model.llmConnectionId && choice.connectionSlug === defaults.model.llmConnectionSlug && choice.model === defaults.model.model)
+ ?? services?.modelChoices.find((choice) => choice.isDefault);
+ const defaultModel = defaultChoice ? { llmConnectionId: defaultChoice.connectionId, llmConnectionSlug: defaultChoice.connectionSlug, model: defaultChoice.model } : undefined;
+ const busy = changing || submitting || Boolean(composer.sendBlocked);
+ const settingsLocked = busy || unavailable || selected?.status === 'running' || selected?.status === 'waiting_for_user';
+ const change = async (operation: () => Promise<unknown>) => {
+ setChanging(true);
+ try { await operation(); }
+ catch { toast.error(copy.settingsUpdateFailed); }
+ finally { setChanging(false); }
+ };
+ return <>
+ <div className="workhub-composer-scope workhub-work-identity" style={selected ? { color: `oklch(var(--workhub-tone) ${workHubIdentityHue(selected.id)})` } : undefined}>
+ <div className="workhub-composer-target">
+ <span>{copy.sendTo}</span>
+ <Selector
+ size="sm"
+ label={copy.currentWork}
+ isLabelHidden
+ value={selectedId}
+ isDisabled={busy}
+ options={[
+ { value: '', label: copy.routeAutomatically },
+ ...(unavailable ? [{ value: selectedId, label: copy.workUnavailable }] : []),
+ ...(services?.sessions.filter((session) => !session.isArchived).map((session) => ({ value: session.id, label: session.name || session.id })) ?? []),
+ ]}
+ onChange={(option) => setSelectedId(option)}
+ />
+ </div>
+ <span>{selected ? (copy.selectedWorkSettings) : (copy.newWorkSettings)}</span>
+ </div>
+ <Composer {...composer}
+ sendBlocked={busy || unavailable}
+ allowAttachmentOnlySend
+ pendingAttachments={staged.pendingAttachments}
+ onPickAttachments={staged.pickAttachments}
+ onAttachFilePaths={staged.attachFilePaths}
+ onRemoveAttachment={staged.removeAttachment}
+ onSend={async (text) => {
+ const snapshot = [...staged.pendingAttachments];
+ setSubmitting(true);
+ try {
+ if (snapshot.length > MAX_ATTACHMENT_COUNT || snapshot.some((item) => item.size > MAX_ATTACHMENT_BYTES)) {
+ throw new Error(copy.attachmentLimitExceeded);
+ }
+ const attachments: AttachmentRef[] = [];
+ for (const item of snapshot) {
+ let ref = uploaded.current.get(item.stagingKey);
+ if (!ref) {
+ [ref] = await settings.prepareAttachments(attachmentScope!, toComposerIngestItems([item]));
+ if (!ref) throw new Error('Attachment upload did not return a reference');
+ uploaded.current.set(item.stagingKey, ref);
+ }
+ attachments.push(ref);
+ }
+ const accepted = await onSend(text.trim() || (copy.attachmentPrompt), {
+ ...(selectedId ? { explicitTarget: { sessionId: selectedId } } : { newWorkDefaults: { ...defaults, model: defaultModel } }),
+ ...(attachments.length ? { attachments } : {}),
+ }, () => {
+ staged.clearSubmittedAttachments(snapshot);
+ for (const item of snapshot) uploaded.current.delete(item.stagingKey);
+ });
+ return accepted;
+ } catch (error) {
+ toast.error(copy.sendFailed, localizedShellErrorMessage(error, copy.tryAgain, locale));
+ return false;
+ } finally { setSubmitting(false); }
+ }}
+ activeSession={selected}
+ modelChoices={services?.modelChoices}
+ noModelConnection={!selected && services?.modelChoices.length === 0}
+ activeModelConnectionId={selected?.llmConnectionId}
+ activeModelConnectionSlug={selected?.llmConnectionSlug}
+ activeModel={selected?.model}
+ modelLabel={selected?.model ?? defaultModel?.model}
+ onModelChange={selected && services ? (model) => change(() => settings.setModelConfiguration(selected.id, { ...model, thinkingLevel: null })) : undefined}
+ modelSwitchAvailability={settingsLocked ? { available: false, pending: changing, reason: 'pending' } : undefined}
+ newChatModel={defaultModel}
+ onPickNewChatModel={services ? (model) => { setDefaults((current) => ({ ...current, model })); } : undefined}
+ onOpenModelSettings={services?.onOpenModelSettings}
+ permissionMode={selected?.permissionMode ?? defaults.permissionMode ?? 'ask'}
+ permissionModeDisabledReason={settingsLocked ? (copy.settingsLocked) : undefined}
+ onPermissionModeChange={services ? (mode) => change(async () => {
+ if (!isChatDefaultPermissionMode(mode)) return;
+ if (mode === 'bypass' && !await services.confirmBypass()) return;
+ if (selected) await settings.setPermissionMode(selected.id, mode);
+ else setDefaults((current) => ({ ...current, permissionMode: mode }));
+ }) : undefined}
+ />
+ </>;
+}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx
index 857bb5fe14..9a20668f76 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { useState } from 'react';
+import { useContext, useState, type CSSProperties } from 'react';
import type { WorkHubRailCopy } from '../../../locales/workhub-copy.js';
import type { UiLocale } from '@maka/core/ui-locale';
import { Button, dotForStatus, presentSessionStatus } from '@maka/ui';
@@ -29,6 +29,8 @@ import {
type WorkHubWorkFilter,
} from '../model/anchor-rail.js';
+import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js';
+
export function WorkHubNavigationRail(props: {
readonly locale: UiLocale;
readonly sessions: readonly WorkHubAnchorSession[];
@@ -37,6 +39,7 @@ export function WorkHubNavigationRail(props: {
readonly copy: WorkHubRailCopy;
readonly onOpenSession: (sessionId: string) => void;
}) {
+ const highlight = useContext(WorkHubHighlightContext);
const [filter, setFilter] = useState<WorkHubWorkFilter>('all');
const anchors = deriveWorkHubAnchors({
sessions: props.sessions,
@@ -79,7 +82,15 @@ export function WorkHubNavigationRail(props: {
return (
<ListItem
key={anchor.target.sessionId}
- label={anchor.sessionName}
+ className="workhub-work-identity workhub-navigation-item"
+ style={{ '--workhub-work-hue': workHubIdentityHue(anchor.target.sessionId) } as CSSProperties}
+ data-work-session-id={anchor.target.sessionId}
+ data-work-highlighted={highlight.sessionId === anchor.target.sessionId}
+ onMouseEnter={() => highlight.highlight(anchor.target.sessionId)}
+ onMouseLeave={() => highlight.highlight(undefined)}
+ onFocus={() => highlight.highlight(anchor.target.sessionId)}
+ onBlur={() => highlight.highlight(undefined)}
+ label={<span className="workhub-navigation-label">{anchor.sessionName}</span>}
description={`${anchor.target.sessionId === props.focusSessionId ? props.copy.focused : anchor.projectName} · ${state}`}
startContent={variant ? <StatusDot variant={variant} label={state} /> : undefined}
isSelected={anchor.target.sessionId === props.focusSessionId}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx
index fb466370da..23f49b02ba 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx
@@ -17,12 +17,25 @@
* under the License.
*/
+import { useContext } from 'react';
+import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js';
import { useChatLayoutContext } from '@astryxdesign/core/Chat';
import { PromptAnchorRail, type PromptAnchorRailTurn } from '@maka/ui';
/** Uses the enclosing chat layout's scroller, never the Session navigation. */
-export function WorkHubPromptRail({ turns }: { turns: readonly PromptAnchorRailTurn[] }) {
+export function WorkHubPromptRail({ turns }: { turns: readonly (PromptAnchorRailTurn & { sessionId?: string })[] }) {
+ const highlight = useContext(WorkHubHighlightContext);
const layout = useChatLayoutContext();
if (!layout) throw new Error('WorkHubPromptRail requires ChatSurfaceLayout');
- return <PromptAnchorRail turns={turns} scrollRef={layout.scrollContainerRef} />;
+ return <PromptAnchorRail
+ turns={turns.map((turn) => ({
+ ...turn,
+ accentColor: turn.sessionId
+ ? `oklch(var(--workhub-${highlight.sessionId === turn.sessionId ? 'highlight' : 'tone'}) ${workHubIdentityHue(turn.sessionId)})`
+ : undefined,
+ highlighted: Boolean(turn.sessionId && highlight.sessionId === turn.sessionId),
+ }))}
+ onHighlightTurn={(turn) => highlight.highlight(turns.find((candidate) => candidate.turnId === turn?.turnId)?.sessionId)}
+ scrollRef={layout.scrollContainerRef}
+ />;
}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx
new file mode 100644
index 0000000000..bc66f69034
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createContext, useState, type ReactNode } from 'react';
+
+export const WorkHubHighlightContext = createContext<{
+ sessionId: string | undefined;
+ highlight(sessionId: string | undefined): void;
+}>({ sessionId: undefined, highlight: () => {} });
+
+/** Stable across refreshes and reordering; color supplements the visible work name. */
+export function workHubIdentityHue(sessionId: string): number {
+ let hash = 0;
+ for (const char of sessionId) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) >>> 0;
+ const hues = [250, 165, 65, 315, 205, 25];
+ return hues[hash % hues.length]!;
+}
+
+
+/** Work identity hover is local presentation state shared by the three rails. */
+export function WorkHubHighlightProvider({ children }: { children: ReactNode }) {
+ const [sessionId, highlight] = useState<string>();
+ return <WorkHubHighlightContext.Provider value={{ sessionId, highlight }}>
+ {children}
+ </WorkHubHighlightContext.Provider>;
+}
diff --git a/apps/desktop/src/renderer/locales/workhub-copy.ts b/apps/desktop/src/renderer/locales/workhub-copy.ts
index d3f4ffdff6..2c717ef125 100644
--- a/apps/desktop/src/renderer/locales/workhub-copy.ts
+++ b/apps/desktop/src/renderer/locales/workhub-copy.ts
@@ -84,3 +84,67 @@ const COPY = {
export function getWorkHubRailCopy(locale: UiLocale): WorkHubRailCopy {
return COPY[locale];
}
+
+interface WorkHubComposerCopy {
+ readonly settingsUpdateFailed: string;
+ readonly sendTo: string;
+ readonly currentWork: string;
+ readonly routeAutomatically: string;
+ readonly workUnavailable: string;
+ readonly selectedWorkSettings: string;
+ readonly newWorkSettings: string;
+ readonly attachmentLimitExceeded: string;
+ readonly attachmentPrompt: string;
+ readonly sendFailed: string;
+ readonly tryAgain: string;
+ readonly settingsLocked: string;
+}
+
+const COMPOSER_COPY = {
+ 'zh-CN': {
+ settingsUpdateFailed: '配置更新失败,请重试',
+ sendTo: '发送到',
+ currentWork: '当前 Work',
+ routeAutomatically: '自动识别工作',
+ workUnavailable: '工作不可用',
+ selectedWorkSettings: '模型与权限用于此 Work',
+ newWorkSettings: '模型与权限用于新 Work',
+ attachmentLimitExceeded: '附件数量或大小超过限制',
+ attachmentPrompt: '请查看附件。',
+ sendFailed: '发送失败',
+ tryAgain: '请重试',
+ settingsLocked: '当前无法修改配置',
+ },
+ 'zh-TW': {
+ settingsUpdateFailed: '設定更新失敗,請重試',
+ sendTo: '傳送至',
+ currentWork: '目前 Work',
+ routeAutomatically: '自動識別工作',
+ workUnavailable: '工作無法使用',
+ selectedWorkSettings: '模型與權限用於此 Work',
+ newWorkSettings: '模型與權限用於新 Work',
+ attachmentLimitExceeded: '附件數量或大小超過限制',
+ attachmentPrompt: '請查看附件。',
+ sendFailed: '傳送失敗',
+ tryAgain: '請重試',
+ settingsLocked: '目前無法修改設定',
+ },
+ 'en': {
+ settingsUpdateFailed: 'Could not update settings. Try again.',
+ sendTo: 'Send to',
+ currentWork: 'Current Work',
+ routeAutomatically: 'Route automatically',
+ workUnavailable: 'Work unavailable',
+ selectedWorkSettings: 'Settings apply to this Work',
+ newWorkSettings: 'Settings apply to new Work',
+ attachmentLimitExceeded: 'Attachment count or size exceeds the limit',
+ attachmentPrompt: 'Please review the attachments.',
+ sendFailed: 'Could not send',
+ tryAgain: 'Try again',
+ settingsLocked: 'Settings are currently locked',
+ },
+} satisfies UiCatalog<WorkHubComposerCopy>;
+
+export function getWorkHubComposerCopy(locale: UiLocale): WorkHubComposerCopy {
+ return COMPOSER_COPY[locale];
+}
diff --git a/apps/desktop/src/renderer/pending-items.ts b/apps/desktop/src/renderer/pending-items.ts
index eb1963ac22..2c5bd8afac 100644
--- a/apps/desktop/src/renderer/pending-items.ts
+++ b/apps/desktop/src/renderer/pending-items.ts
@@ -17,58 +17,4 @@
* under the License.
*/
-/** Generic keyed staging registry shared by composer surfaces. */
-export type PendingByKey<T> = Record<string, T[]>;
-
-/**
- * The bucket the session-less composer stages into, whatever the workspace
- * picker points at (#3408).
- *
- * The rest of the new-task surface is keyed by (profileId, hostId, projectId)
- * since #3122, and staged files and quotes were keyed with it. But they are
- * in-memory intent, not Host state: nothing persists them, and nothing restores
- * them per target. Keying them by the target only made them drop out of the
- * composer when the picker moved.
- *
- * A key that never moves is also the only owner an in-flight submission can
- * safely have. `send()` captures the key it submitted from and clears that key
- * when it resolves, so a key that followed the picker would leave the files it
- * just sent staged under the new target, ready to be sent a second time. This
- * one cannot go stale, so no re-keying rule is needed to keep it honest.
- */
-export const NEW_TASK_PENDING_KEY = 'new-task';
-
-export function selectPending<T>(map: PendingByKey<T>, key: string): T[] {
- return map[key] ?? [];
-}
-
-export function appendPending<T>(
- map: PendingByKey<T>,
- key: string,
- items: readonly T[],
-): PendingByKey<T> {
- return { ...map, [key]: [...(map[key] ?? []), ...items] };
-}
-
-export function removePending<T>(map: PendingByKey<T>, key: string, index: number): PendingByKey<T> {
- const current = map[key] ?? [];
- return { ...map, [key]: current.filter((_, i) => i !== index) };
-}
-
-export function removePendingItems<T>(
- map: PendingByKey<T>,
- key: string,
- items: readonly T[],
- identityOf: (item: T) => unknown = (item) => item,
-): PendingByKey<T> {
- const submitted = new Set(items.map(identityOf));
- const remaining = (map[key] ?? []).filter((item) => !submitted.has(identityOf(item)));
- if (remaining.length === 0) return clearPending(map, key);
- return { ...map, [key]: remaining };
-}
-
-export function clearPending<T>(map: PendingByKey<T>, key: string): PendingByKey<T> {
- const next = { ...map };
- delete next[key];
- return next;
-}
+export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending, type PendingByKey } from './features/conversation/index.js';
diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts
similarity index 67%
copy from apps/desktop/src/renderer/features/workhub/index.ts
copy to apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts
index 10f1cf43e5..af4cb1632a 100644
--- a/apps/desktop/src/renderer/features/workhub/index.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts
@@ -17,9 +17,12 @@
* under the License.
*/
-export * from './model/anchor-rail.js';
-export * from './model/route-policy.js';
-export * from './model/routing-strategy.js';
-export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js';
-
-export { WorkHubPromptRail } from './ui/workhub-prompt-rail.js';
+import type { MakaBridge } from '../../../preload/bridge-contract.js';
+export function createDesktopWorkHubComposerServices(bridge: MakaBridge = window.maka) {
+ return {
+ attachments: bridge.attachments,
+ prepareAttachments: bridge.workHub.prepareAttachments,
+ setModelConfiguration: bridge.sessions.setModelConfiguration,
+ setPermissionMode: bridge.sessions.setPermissionMode,
+ };
+}
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 5bd40e0efd..bec8666f5d 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -51,8 +51,7 @@
box-sizing: border-box;
align-items: flex-start;
margin-top: var(--space-6, 24px);
- padding: 0 var(--space-3, 12px) 24px;
- border-bottom: 1px solid var(--color-border-subtle, var(--border));
+ padding: 0 var(--space-4, 16px) 12px;
}
.workhub-header h1,
@@ -76,16 +75,14 @@
.workhub-message-list {
flex: 1 1 auto;
+ padding-inline: 0;
}
.workhub-body {
display: grid;
width: 100%;
min-width: 0;
- grid-template-columns: minmax(180px, 220px) minmax(
- 0,
- var(--maka-reading-measure)
- ) minmax(180px, 220px);
+ grid-template-columns: minmax(0, 220px) var(--maka-reading-measure) minmax(0, 220px);
gap: var(--space-6, 24px);
justify-content: center;
}
@@ -174,8 +171,7 @@
display: flex;
flex-direction: column;
gap: 8px;
- padding: 24px 0;
- border-bottom: 1px solid var(--color-border-subtle, var(--border));
+ padding: 16px 0;
}
.workhub-message {
@@ -310,9 +306,6 @@
padding-inline: 16px;
}
- .workhub-turn {
- padding-inline: 4px;
- }
}
@media (max-width: 1240px) {
@@ -320,6 +313,12 @@
display: block;
}
+ .workhub-conversation-shell,
+ .workhub-header {
+ width: min(var(--maka-reading-measure), calc(100% - 2 * (var(--space-6) + var(--space-3))));
+ margin-inline: auto;
+ }
+
.workhub-anchor-rail {
position: static;
width: min(var(--maka-reading-measure), 100%);
@@ -343,3 +342,136 @@
}
}
+
+/* Identity sits next to the prompt; shared chat bubbles remain neutral. */
+.workhub-message-identity {
+ display: flex;
+ justify-content: flex-end;
+ min-width: 0;
+}
+
+.workhub-message-identity button {
+ height: auto;
+ min-height: 28px;
+ max-width: 100%;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ text-align: right;
+ color: var(--workhub-work-label);
+ font-size: var(--font-size-body-sm, 12px);
+}
+
+.workhub-bound-turn .workhub-user-bubble,
+.workhub-bound-turn .workhub-assistant-bubble {
+ position: relative;
+}
+
+.workhub-header p {
+ display: none;
+}
+
+.workhub-submitted > button {
+ border-bottom: 0;
+}
+
+.workhub-bound-turn .workhub-assistant-bubble {
+ padding-inline-start: var(--space-3, 12px);
+}
+
+.workhub-bound-turn,
+.workhub-work-identity {
+ --workhub-work-color: oklch(0.58 0.1 var(--workhub-work-hue));
+ --workhub-work-label: oklch(0.42 0.075 var(--workhub-work-hue));
+ --workhub-work-highlight: oklch(0.48 0.18 var(--workhub-work-hue));
+}
+
+.dark .workhub-bound-turn,
+.dark .workhub-work-identity {
+ --workhub-work-color: oklch(0.76 0.105 var(--workhub-work-hue));
+ --workhub-work-label: oklch(0.8 0.09 var(--workhub-work-hue));
+ --workhub-work-highlight: oklch(0.85 0.15 var(--workhub-work-hue));
+}
+
+.workhub-work-rail {
+ position: absolute;
+ top: 4px;
+ bottom: 4px;
+ width: 11px;
+}
+
+.workhub-work-rail::before {
+ position: absolute;
+ inset-block: 0;
+ left: 4px;
+ width: 3px;
+ border-radius: var(--radius-pill, 999px);
+ background: var(--workhub-work-color);
+ transition: background-color 180ms ease-out;
+ content: '';
+}
+
+.workhub-user-bubble > .workhub-work-rail {
+ right: -11px;
+}
+
+.workhub-assistant-bubble > .workhub-work-rail {
+ left: -4px;
+}
+
+.workhub-bound-turn[data-work-highlighted='true'],
+.workhub-work-identity[data-work-highlighted='true'] {
+ --workhub-work-color: var(--workhub-work-highlight);
+ --workhub-work-label: var(--workhub-work-highlight);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .workhub-work-rail::before {
+ transition: none;
+ }
+}
+
+.workhub-surface {
+ --workhub-tone: 0.58 0.1;
+ --workhub-highlight: 0.48 0.18;
+}
+
+.dark .workhub-surface {
+ --workhub-tone: 0.76 0.105;
+ --workhub-highlight: 0.85 0.15;
+}
+
+.workhub-navigation-item {
+ color: var(--workhub-work-label);
+}
+
+.workhub-navigation-item[data-work-highlighted='true'] {
+ background: color-mix(in oklch, var(--workhub-work-color) 8%, transparent);
+}
+
+.workhub-surface .maka-prompt-rail:has([data-highlighted='true']) {
+ opacity: 1;
+}
+
+.workhub-navigation-label {
+ display: block;
+ overflow: hidden;
+ color: var(--workhub-work-label);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workhub-composer-scope {
+ box-sizing: border-box;
+ width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6)));
+ margin-inline: auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 16px 8px;
+ font-size: var(--font-size-body-sm, 12px);
+ color: var(--color-text-secondary);
+}
+.workhub-composer-target { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.workhub-composer-target > :last-child { max-width: 240px; min-width: 0; }
+@media (max-width: 600px) { .workhub-composer-scope { flex-wrap: wrap; } }
diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts
index 6eea15a9c6..56f4fbbab0 100644
--- a/apps/desktop/src/renderer/workhub-controller.ts
+++ b/apps/desktop/src/renderer/workhub-controller.ts
@@ -120,6 +120,7 @@ export interface WorkHubProjectedTurn {
}
export interface WorkHubCoordinationTurn {
+ attachments?: WorkHubCoordinationActInput['attachments'];
messageId: string;
turnId: string;
text: string;
@@ -160,6 +161,8 @@ export interface WorkHubProjection {
}
export interface WorkHubSubmitInput {
+ attachments?: WorkHubCoordinationActInput['attachments'];
+ newWorkDefaults?: WorkHubCoordinationActInput['newWorkDefaults'];
requestId: string;
text: string;
retryAction?: true;
@@ -583,13 +586,13 @@ export function createWorkHubController(deps: {
text: input.text,
sessions: ordinary,
});
- const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume', routingStrategy.strategyId);
+ const resume = input.attachments?.length ? undefined : await submitNamedDelegationAction(input, resumeDecision, 'resume', routingStrategy.strategyId);
if (resume) return resume;
const stopDecision = submissionPolicy.resolveStop({
text: input.text,
sessions: ordinary,
});
- const stop = await submitNamedDelegationAction(input, stopDecision, 'stop', routingStrategy.strategyId);
+ const stop = input.attachments?.length ? undefined : await submitNamedDelegationAction(input, stopDecision, 'stop', routingStrategy.strategyId);
if (stop) return stop;
const candidateSet = await coordination.candidates();
const candidateBySessionId = new Map(
@@ -657,6 +660,7 @@ export function createWorkHubController(deps: {
await coordination.act({
actionId: input.requestId,
userText: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
proposal: { disposition: 'answer_here' },
});
return {
@@ -676,6 +680,8 @@ export function createWorkHubController(deps: {
? {
actionId: input.requestId,
userText: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}),
confirmation: { kind: 'user_correction' },
proposal: {
disposition: 'replace',
@@ -686,6 +692,8 @@ export function createWorkHubController(deps: {
: {
actionId: input.requestId,
userText: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}),
proposal: { disposition: 'create_new', title },
});
if (
@@ -731,6 +739,7 @@ export function createWorkHubController(deps: {
? {
actionId: input.requestId,
userText: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
candidateSetId: candidateSet.candidateSetId,
confirmation: { kind: 'user_correction' },
proposal: {
@@ -745,6 +754,7 @@ export function createWorkHubController(deps: {
: {
actionId: input.requestId,
userText: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
candidateSetId: candidateSet.candidateSetId,
proposal: {
disposition: 'delegate_existing',
diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts
index 954e68286b..88f9c82fd8 100644
--- a/apps/desktop/src/renderer/workhub-coordination-port.ts
+++ b/apps/desktop/src/renderer/workhub-coordination-port.ts
@@ -194,6 +194,7 @@ export function projectWorkHubCoordinationTurns(
messageId: message.id,
turnId: message.coordinationTurnId,
text: boundedWorkHubTimelineText(message.userText),
+ ...(message.attachments ? { attachments: message.attachments } : {}),
state: 'completed',
assignment: {
actionId: message.actionId,
@@ -217,6 +218,7 @@ export function projectWorkHubCoordinationTurns(
messageId: message.id,
turnId: message.turnId,
text,
+ ...(message.attachments ? { attachments: message.attachments } : {}),
state: stateByTurnId.get(message.turnId) ?? 'running',
updatedAt: message.ts,
});
diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx
index db8dd711ce..b62a4f5955 100644
--- a/apps/desktop/src/renderer/workhub-surface.tsx
+++ b/apps/desktop/src/renderer/workhub-surface.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { useContext, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
ChatMessage,
ChatMessageBubble,
@@ -26,7 +26,7 @@ import {
} from '@astryxdesign/core';
import { Button } from '@astryxdesign/core/Button';
import type { UiLocale } from '@maka/core/ui-locale';
-import { ChatSurfaceLayout, Composer } from '@maka/ui';
+import { ChatSurfaceLayout } from '@maka/ui';
import {
type WorkHubController,
type WorkHubCoordinationTurn,
@@ -36,12 +36,13 @@ import {
type WorkHubSubmission,
type WorkHubSubmitInput,
} from './workhub-controller.js';
+import type { AttachmentRef } from '@maka/core/events';
import { WorkHubCoordinationFailure } from './workhub-coordination-port.js';
import {
WorkHubSendLease,
type WorkHubSendAttempt,
} from './workhub-send-lease.js';
-import { WorkHubNavigationRail, WorkHubPromptRail } from './features/workhub/index.js';
+import { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue, WorkHubNavigationRail, WorkHubPromptRail, WorkHubComposer, type WorkHubComposerServices, type WorkHubComposerSelection } from './features/workhub/index.js';
import { getWorkHubRailCopy } from './locales/workhub-copy.js';
export interface WorkHubConversationTurn {
@@ -50,6 +51,7 @@ export interface WorkHubConversationTurn {
state: 'routing' | 'settled' | 'failed';
outcome?: WorkHubSubmission;
failure?: WorkHubSurfaceFailure;
+ submissionContext?: WorkHubComposerSelection & { attachments?: AttachmentRef[] };
}
export type WorkHubSurfaceFailure =
@@ -220,13 +222,16 @@ export async function submitLeasedWorkHubSurfaceInput(input: {
* The persistent Coordination Session transcript is the primary conversation.
* Ordinary Sessions remain a read-only status/routing projection.
*/
-export function WorkHubSurface(props: {
+interface WorkHubSurfaceProps {
controller: WorkHubController;
leaseScope: string;
locale: UiLocale;
initialFocusSessionId?: string;
+ composerServices?: WorkHubComposerServices;
onOpenSession(sessionId: string): void;
-}) {
+}
+
+export function WorkHubSurface(props: WorkHubSurfaceProps) {
const copy = workHubCopy(props.locale);
const railCopy = getWorkHubRailCopy(props.locale);
const [projection, setProjection] = useState<WorkHubProjection>({ sessions: [], turns: [] });
@@ -355,26 +360,31 @@ export function WorkHubSurface(props: {
});
}, [copy, projection, props.controller, refresh, routeGate]);
- const send = useCallback(async (value: string) => {
+ const send = useCallback(async (value: string, selection: WorkHubComposerSelection, onAccepted?: () => void) => {
const text = value.trim();
if (!text || !initialLoadSettled || !conversationReady || routeGate.pending) return false;
- return submitLeasedWorkHubSurfaceInput({
+ const submissionContext = selection;
+ const accepted = await submitLeasedWorkHubSurfaceInput({
lease: sendLease,
text,
submit: async (attempt) => {
const { requestId } = attempt;
setTurns((current) => current.some((turn) => turn.requestId === requestId)
? current.map((turn) => turn.requestId === requestId
- ? { requestId, text: attempt.text, state: 'routing' }
+ ? { requestId, text: attempt.text, state: 'routing', submissionContext }
: turn)
- : [...current, { requestId, text: attempt.text, state: 'routing' }]);
- return route({
+ : [...current, { requestId, text: attempt.text, state: 'routing', submissionContext }]);
+ const result = await route({
requestId,
text: attempt.text,
+ ...submissionContext,
...(attempt.retrying ? { retryAction: true as const } : {}),
});
+ if (workHubSubmissionClearsDraft(result)) onAccepted?.();
+ return result;
},
});
+ return accepted;
}, [conversationReady, initialLoadSettled, route, routeGate, sendLease]);
const visible = visibleWorkHubConversation(coordination.turns, turns);
const visibleCoordinationTurns = visible.coordination;
@@ -383,16 +393,24 @@ export function WorkHubSurface(props: {
const surfaceReady = initialLoadSettled && conversationReady;
return (
+ <WorkHubHighlightProvider>
<ChatSurfaceLayout
className="workhub-surface"
composer={(
- <Composer
+ <WorkHubComposer
+ services={props.composerServices ? {
+ ...props.composerServices,
+ sessions: props.composerServices.sessions.filter((session) => projection.sessions.some((work) => work.target.sessionId === session.id)),
+ } : undefined}
+ locale={props.locale}
+ attachmentScope={props.leaseScope}
draftKey="workhub"
draftPersistence={sendLease}
onSend={send}
onStop={() => {}}
sendBlocked={pending || !surfaceReady}
modelLabel="WorkHub"
+ showStaticModelUnavailableStatus={false}
/>
)}
>
@@ -423,10 +441,13 @@ export function WorkHubSurface(props: {
turnId: `workhub-message-${turn.messageId}`,
label: turn.text,
reply: turn.result,
+ sessionId: turn.assignment?.targetSessionId ?? turn.stop?.targetSessionId,
})),
...visibleLocalTurns.map((turn) => ({
turnId: `workhub-request-${turn.requestId}`,
label: turn.text,
+ sessionId: turn.outcome?.kind === 'submitted' || turn.outcome?.kind === 'stop' || turn.outcome?.kind === 'resume'
+ ? turn.outcome.target.sessionId : undefined,
})),
]} />
<ChatMessageList
@@ -473,6 +494,7 @@ export function WorkHubSurface(props: {
submit: (attempt) => route({
requestId: attempt.requestId,
text: attempt.text,
+ ...turn.submissionContext,
explicitTarget: target,
...(attempt.retrying ? { retryAction: true as const } : {}),
...(turn.outcome?.kind === 'clarification' && turn.outcome.correction
@@ -493,6 +515,7 @@ export function WorkHubSurface(props: {
</div>
</section>
</ChatSurfaceLayout>
+ </WorkHubHighlightProvider>
);
}
@@ -518,12 +541,14 @@ export function WorkHubCoordinationStatus(props: {
<ChatSurfaceLayout
className="workhub-surface"
composer={(
- <Composer
+ <WorkHubComposer
+ locale={props.locale}
draftKey="workhub"
onSend={async () => false}
onStop={() => {}}
sendBlocked
modelLabel="WorkHub"
+ showStaticModelUnavailableStatus={false}
/>
)}
>
@@ -609,11 +634,18 @@ export function WorkHubCoordinationTurnView(props: {
<WorkHubMessageFrame
anchorId={`workhub-message-${props.turn.messageId}`}
text={props.turn.text}
+ attachments={props.turn.attachments}
state={props.turn.stop?.outcome ?? (assignment?.linkState === 'active'
? assignment.feedbackState
: assignment?.linkState ?? props.turn.state)}
linkState={assignment?.linkState}
projected
+ work={assignment || props.turn.stop ? {
+ sessionId: assignment?.targetSessionId ?? props.turn.stop!.targetSessionId,
+ name: session?.sessionName ?? stoppedSession?.sessionName ?? assignment?.targetSessionName ?? props.turn.stop!.targetSessionName,
+ projectName: session?.projectName ?? stoppedSession?.projectName,
+ } : undefined}
+ onOpenSession={props.onOpenSession}
>
{props.turn.stop ? (
<SubmittedWorkView
@@ -728,7 +760,18 @@ function WorkHubTurnView(props: {
: undefined;
return (
- <WorkHubMessageFrame anchorId={`workhub-request-${turn.requestId}`} text={turn.text} state={turn.state}>
+ <WorkHubMessageFrame
+ anchorId={`workhub-request-${turn.requestId}`}
+ text={turn.text}
+ attachments={turn.submissionContext?.attachments}
+ state={turn.state}
+ work={submitted ? {
+ sessionId: submitted.target.sessionId,
+ name: target?.sessionName ?? copy.sessionFallback,
+ projectName: target?.projectName,
+ } : undefined}
+ onOpenSession={props.onOpenSession}
+ >
{turn.state === 'routing' ? (
<p className="workhub-status" role="status">{copy.routing}</p>
) : turn.state === 'failed' ? (
@@ -811,22 +854,57 @@ function WorkHubTurnView(props: {
function WorkHubMessageFrame(props: {
anchorId: string;
text: string;
+ attachments?: AttachmentRef[];
state: string;
linkState?: WorkHubDelegationLinkState;
projected?: boolean;
+ work?: { sessionId: string; name: string; projectName?: string };
+ onOpenSession?(sessionId: string): void;
children: ReactNode;
}) {
+ const highlight = useContext(WorkHubHighlightContext);
+ const work = props.work;
+ const rail = work ? (
+ <span
+ className="workhub-work-rail"
+ aria-hidden="true"
+ onMouseEnter={() => highlight.highlight(work.sessionId)}
+ onMouseLeave={() => highlight.highlight(undefined)}
+ />
+ ) : null;
return (
<section
- className={`workhub-turn${props.projected ? ' workhub-projected-turn' : ''}`}
+ className={`workhub-turn${props.projected ? ' workhub-projected-turn' : ''}${props.work ? ' workhub-bound-turn' : ''}`}
+ style={props.work ? { '--workhub-work-hue': workHubIdentityHue(props.work.sessionId) } as CSSProperties : undefined}
+ aria-label={props.text}
data-turn-id={props.anchorId}
data-transcript-turn-id={props.anchorId}
+ data-work-session-id={props.work?.sessionId}
+ data-work-highlighted={Boolean(work && highlight.sessionId === work.sessionId)}
data-state={props.state}
data-link-state={props.linkState}
>
+ {props.work ? (
+ <div className="workhub-message-identity">
+ <Button
+ variant="ghost"
+ label={[props.work.projectName, props.work.name].filter(Boolean).join(' / ')}
+ onMouseEnter={() => highlight.highlight(work!.sessionId)}
+ onMouseLeave={() => highlight.highlight(undefined)}
+ onFocus={() => highlight.highlight(work!.sessionId)}
+ onBlur={() => highlight.highlight(undefined)}
+ onClick={() => props.onOpenSession?.(props.work!.sessionId)}
+ >
+ {props.work.projectName ? <span>{props.work.projectName}<span aria-hidden="true"> / </span></span> : null}
+ <strong>{props.work.name}</strong>
+ </Button>
+ </div>
+ ) : null}
<ChatMessage sender="user" className="workhub-message">
<ChatMessageBubble className="maka-chat-message-bubble maka-chat-message-bubble-user workhub-user-bubble">
+ {rail}
<p>{props.text}</p>
+ {props.attachments?.length ? <ul className="workhub-message-attachments">{props.attachments.map((attachment, index) => <li key={index}>{attachment.name}</li>)}</ul> : null}
</ChatMessageBubble>
</ChatMessage>
<ChatMessage sender="assistant" className="workhub-message">
@@ -835,6 +913,7 @@ function WorkHubMessageFrame(props: {
width="100%"
className="maka-chat-message-bubble maka-chat-message-bubble-assistant workhub-assistant-bubble"
>
+ {rail}
{props.children}
</ChatMessageBubble>
</ChatMessage>
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index c447e788cb..6fa53ec942 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -17,6 +17,10 @@
* under the License.
*/
+import { ToastProvider } from '@maka/ui';
+import { useState } from 'react';
+import type { SessionSummary } from '@maka/core/session';
+import { WorkHubComposerServicesProvider } from '../src/renderer/features/workhub/index.js';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within, waitFor } from 'storybook/test';
import type {
@@ -275,3 +279,189 @@ export const ConversationPromptAnchors: Story = {
export const ConversationPromptAnchorsNarrow: Story = {
...ConversationPromptAnchors,
};
+
+// Repeated messages for one work must highlight together, independently of
+// intervening work. This is the same durable assignment seam as production.
+export const WorkIdentityAcrossTurns: Story = {
+ render: () => {
+ const first = submittedTurn();
+ const other: WorkHubCoordinationTurn = {
+ ...first, messageId: 'other-message', turnId: 'other-turn',
+ text: '检查另一个工作的界面布局。',
+ assignment: { ...first.assignment!, targetSessionId: 'other-work', targetSessionName: '界面布局' },
+ };
+ const followup = { ...first, messageId: 'followup-message', turnId: 'followup-turn', text: '继续支付回调,检查失败重试。' };
+ return <Surface turns={[first, other, followup]} />;
+ },
+ play: async ({ canvasElement }) => {
+ await waitFor(() => expect(canvasElement.querySelectorAll('.workhub-bound-turn')).toHaveLength(3));
+ const turns = Array.from(canvasElement.querySelectorAll<HTMLElement>('.workhub-bound-turn'));
+ const rail = turns[0]!.querySelector<HTMLElement>('.workhub-work-rail')!;
+ await userEvent.hover(rail);
+ await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['true', 'false', 'true']));
+ await userEvent.unhover(rail);
+ await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['false', 'false', 'false']));
+ const ticks = Array.from(canvasElement.querySelectorAll<HTMLElement>('.maka-prompt-rail-tick'));
+ expect(ticks).toHaveLength(3);
+ const navigation = canvasElement.querySelector<HTMLElement>('.workhub-navigation-item')!;
+ await userEvent.hover(navigation);
+ await waitFor(() => {
+ expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['true', 'false', 'true']);
+ expect(ticks.map(tick => tick.dataset.highlighted)).toEqual(['true', undefined, 'true']);
+ expect(getComputedStyle(ticks[0]!).color).toBe(getComputedStyle(ticks[2]!).color);
+ expect(getComputedStyle(navigation.querySelector('.workhub-navigation-label')!).color)
+ .toBe(getComputedStyle(ticks[0]!).color);
+ expect(getComputedStyle(ticks[0]!).color).not.toBe(getComputedStyle(ticks[1]!).color);
+ });
+ await userEvent.unhover(navigation);
+ await userEvent.hover(ticks[1]!);
+ await waitFor(() => {
+ expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['false', 'true', 'false']);
+ expect(navigation.dataset.workHighlighted).toBe('false');
+ });
+ await userEvent.unhover(ticks[1]!);
+ ticks[0]!.focus();
+ await waitFor(() => expect(navigation.dataset.workHighlighted).toBe('true'));
+ ticks[0]!.blur();
+ await userEvent.hover(rail);
+ await waitFor(() => {
+ expect(navigation.dataset.workHighlighted).toBe('true');
+ expect(ticks.map(tick => tick.dataset.highlighted)).toEqual(['true', undefined, 'true']);
+ });
+ await userEvent.unhover(rail);
+ },
+};
+
+const composerWrites = { model: fn(), permission: fn(), send: fn(), upload: fn() };
+const composerModels = ['model-a', 'model-b'].map((model, index) => ({
+ connectionId: 'connection-test', connectionSlug: 'test', providerType: 'openai' as const,
+ providerLabel: 'OpenAI', model, label: model, isDefault: index === 0, thinkingLevels: [],
+}));
+function ConfiguredComposerSurface({ failFirst = false }: { failFirst?: boolean }) {
+ const [failures] = useState(() => ({ remaining: failFirst ? 1 : 0 }));
+ const [session, setSession] = useState<SessionSummary>({
+ id: TARGET_SESSION_ID, name: SESSION_NAME, isFlagged: false, isArchived: false, labels: [],
+ hasUnread: false, status: 'active', backend: 'ai-sdk', llmConnectionId: 'connection-test',
+ llmConnectionSlug: 'test', connectionLocked: false, model: 'model-a', permissionMode: 'ask',
+ });
+ const [fixture] = useState<WorkHubController>(() => ({
+ ...controller([submittedTurn()]),
+ submit: async (input) => {
+ composerWrites.send(input);
+ if (failures.remaining-- > 0) throw new Error('Temporary Host failure');
+ return { kind: 'discussion', requestId: input.requestId, text: input.text, strategyId: 'wh-r2.4-session-context-continuity' };
+ },
+ }));
+ return <ToastProvider><WorkHubComposerServicesProvider services={{
+ attachments: {
+ pickFiles: async () => ({ ok: true, files: [{ approvalId: 'file-1', name: 'requirements.txt', size: 12, mimeType: 'text/plain' }] }),
+ previewApproval: async () => ({ ok: false, reason: 'not-image' }),
+ },
+ prepareAttachments: async (sessionId, items) => {
+ composerWrites.upload(sessionId, items);
+ return [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }];
+ },
+ setModelConfiguration: async (sessionId, model) => {
+ composerWrites.model(sessionId, model);
+ setSession((current) => ({ ...current, ...model, thinkingLevel: undefined }));
+ },
+ setPermissionMode: async (sessionId, permissionMode) => {
+ composerWrites.permission(sessionId, permissionMode);
+ setSession((current) => ({ ...current, permissionMode }));
+ },
+ }}>
+ <div className="maka-detail-with-artifacts" style={{ height: '100dvh' }}><div className="mainColumn">
+ <WorkHubSurface controller={fixture} leaseScope="composer-story" locale={LOCALE} onOpenSession={() => {}} composerServices={{
+ sessions: [session], modelChoices: composerModels,
+ defaults: { model: { llmConnectionId: 'connection-test', llmConnectionSlug: 'test', model: 'model-a' }, permissionMode: 'ask' },
+ confirmBypass: async () => true, onOpenModelSettings: () => {},
+ }} />
+ </div></div>
+ </WorkHubComposerServicesProvider></ToastProvider>;
+}
+
+// Production Composer, with only native file picking and Host writes replaced.
+export const StandardComposer: Story = {
+ render: () => <ConfiguredComposerSurface />,
+ play: async ({ canvasElement }) => {
+ Object.values(composerWrites).forEach((spy) => spy.mockClear());
+ const canvas = within(canvasElement);
+ await waitFor(() => expect(canvas.getByRole('combobox', { name: '当前 Work' })).toBeEnabled());
+ await expect(canvas.getByText('模型与权限用于新 Work')).toBeVisible();
+ await expect(canvas.getByRole('button', { name: '添加上下文' })).toBeEnabled();
+ await userEvent.click(canvas.getByRole('combobox', { name: '当前 Work' }));
+ await userEvent.click(within(canvasElement.ownerDocument.body).getByRole('option', { name: SESSION_NAME }));
+ await expect(canvas.getByText('模型与权限用于此 Work')).toBeVisible();
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(canvas.getByRole('button', { name: /切换当前任务模型/ }));
+ await userEvent.click(page.getByRole('menuitemradio', { name: 'model-b' }));
+ await waitFor(() => expect(composerWrites.model).toHaveBeenCalledWith(TARGET_SESSION_ID, expect.objectContaining({ model: 'model-b' })));
+ await userEvent.click(canvas.getByRole('button', { name: /权限模式/ }));
+ await userEvent.click(page.getByRole('menuitemradio', { name: '完全权限' }));
+ await waitFor(() => expect(composerWrites.permission).toHaveBeenCalledWith(TARGET_SESSION_ID, 'bypass'));
+ await userEvent.click(canvas.getByRole('combobox', { name: '当前 Work' }));
+ await userEvent.click(page.getByRole('option', { name: '自动识别工作' }));
+ await userEvent.click(canvas.getByRole('button', { name: /选择新任务模型/ }));
+ await userEvent.click(page.getByRole('menuitemradio', { name: 'model-b' }));
+ await userEvent.click(canvas.getByRole('button', { name: '添加上下文' }));
+ await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ }));
+ await waitFor(() => expect(canvas.getByText('requirements.txt')).toBeVisible());
+ const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
+ await userEvent.click(editor);
+ await userEvent.type(editor, 'Review requirements');
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(composerWrites.send).toHaveBeenCalledWith(expect.objectContaining({
+ newWorkDefaults: expect.objectContaining({ model: expect.objectContaining({ model: 'model-b' }), permissionMode: 'ask' }),
+ attachments: [expect.objectContaining({ name: 'requirements.txt' })],
+ })));
+ expect(composerWrites.send.mock.lastCall?.[0].explicitTarget).toBeUndefined();
+ expect(composerWrites.model).toHaveBeenCalledTimes(1);
+ expect(composerWrites.permission).toHaveBeenCalledTimes(1);
+ const composer = canvasElement.querySelector('.maka-composer-astryx') as HTMLElement;
+ await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
+ await userEvent.click(canvas.getByRole('button', { name: '添加上下文' }));
+ await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ }));
+ await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(1));
+ await userEvent.click(editor);
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(composerWrites.send).toHaveBeenCalledTimes(2));
+ expect(composerWrites.send.mock.lastCall?.[0].text).toBe('请查看附件。');
+ await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
+ const scopeBox = canvasElement.querySelector('.workhub-composer-scope')!.getBoundingClientRect();
+ const plateBox = composer.firstElementChild!.getBoundingClientRect();
+ expect(Math.abs(scopeBox.left - plateBox.left)).toBeLessThanOrEqual(1);
+ expect(Math.abs(scopeBox.right - plateBox.right)).toBeLessThanOrEqual(1);
+ const conversationBox = canvasElement.querySelector('.workhub-conversation-shell')!.getBoundingClientRect();
+ expect(Math.abs(conversationBox.left - plateBox.left)).toBeLessThanOrEqual(1);
+ expect(Math.abs(conversationBox.right - plateBox.right)).toBeLessThanOrEqual(1);
+ canvasElement.dataset.workhubComposerVerified = 'true';
+
+ },
+};
+
+export const StandardComposerNarrow: Story = { ...StandardComposer, parameters: { viewport: { defaultViewport: 'tablet' } } };
+
+export const ComposerRetainsFailedAttachment: Story = {
+ render: () => <ConfiguredComposerSurface failFirst />,
+ play: async ({ canvasElement }) => {
+ Object.values(composerWrites).forEach((spy) => spy.mockClear());
+ const canvas = within(canvasElement);
+ const page = within(canvasElement.ownerDocument.body);
+ await waitFor(() => expect(canvas.getByRole('combobox', { name: '当前 Work' })).toBeEnabled());
+ await userEvent.click(canvas.getByRole('button', { name: '添加上下文' }));
+ await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ }));
+ const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
+ await userEvent.click(editor);
+ await userEvent.type(editor, 'Review requirements');
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(canvasElement.querySelector('.workhub-turn[data-state="failed"]')).not.toBeNull());
+ const composer = canvasElement.querySelector('.maka-composer-astryx') as HTMLElement;
+ expect(within(composer).getByText('requirements.txt')).toBeVisible();
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(composerWrites.send).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
+ expect(composerWrites.upload).toHaveBeenCalledTimes(1);
+ expect(composerWrites.send.mock.calls[0]?.[0].requestId).toBe(composerWrites.send.mock.calls[1]?.[0].requestId);
+ canvasElement.dataset.workhubComposerVerified = 'true';
+ },
+};
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 4d02b3de02..fe3841cf0f 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 256 files — blocker 0, reimplementation 0, polish 1, aligned 255.
+**Totals:** 259 files — blocker 0, reimplementation 0, polish 1, aligned 258.
## Exclusions (explicit)
@@ -97,8 +97,11 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx` | shell-chrome-or-panel | Card, ResizeHandle, Spinner | aligned — uses Astryx (Card, ResizeHandle, Spinner) | aligned |
| `apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx` | shell-chrome-or-panel | Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List, ListItem, Section, Spinner, Tab, TabList | aligned — uses Astryx (Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List) | aligned |
| `apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx` | shell-chrome-or-panel | Icon, IconButton, Tooltip | aligned — uses Astryx (Icon, IconButton, Tooltip) | aligned |
+| `apps/desktop/src/renderer/features/workhub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx` | shell-chrome-or-panel | Selector | aligned — uses Astryx (Selector) | 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-prompt-rail.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 |
| `apps/desktop/src/renderer/keyboard-help.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned |
| `apps/desktop/src/renderer/live-turn-reconciler.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/maka-tokens.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index fd44277143..da07d479ce 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -68,8 +68,11 @@ apps/desktop/src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx
apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx
+apps/desktop/src/renderer/features/workhub/services-context.tsx
+apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx
apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx
apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx
+apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx
apps/desktop/src/renderer/keyboard-help.tsx
apps/desktop/src/renderer/live-turn-reconciler.tsx
apps/desktop/src/renderer/maka-tokens.css
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index b495caed0d..e06eddfe00 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -27,6 +27,7 @@ import {
decodeMessageContent,
TOOL_ACTIVITY_KINDS,
type MessageContent,
+ type AttachmentRef,
type ToolActivityKind,
type ToolResultContent,
} from './events.js';
@@ -930,9 +931,39 @@ export type WorkHubDelegationWorkspace =
| { readonly kind: 'project'; readonly projectId: string }
| { readonly kind: 'host_path'; readonly path: string };
+/** User-selected creation defaults; never applied to an existing Work. */
+export interface WorkHubCreateDefaults {
+ readonly model?: {
+ readonly llmConnectionId: string;
+ readonly llmConnectionSlug: string;
+ readonly model: string;
+ };
+ readonly permissionMode?: PermissionMode;
+}
+
+export function isWorkHubCreateDefaults(value: unknown): value is WorkHubCreateDefaults {
+ if (
+ !isRecord(value) ||
+ Object.keys(value).some((key) => key !== 'model' && key !== 'permissionMode')
+ )
+ return false;
+ if (value.permissionMode !== undefined && !isPermissionMode(value.permissionMode)) return false;
+ if (value.model === undefined) return true;
+ const model = value.model;
+ return (
+ isRecord(model) &&
+ Object.keys(model).length === 3 &&
+ ['llmConnectionId', 'llmConnectionSlug', 'model'].every(
+ (key) =>
+ typeof model[key] === 'string' && model[key].trim().length > 0 && model[key].length <= 512,
+ )
+ );
+}
+
export interface WorkHubDelegationCreateSpec {
readonly title: string;
readonly workspace: WorkHubDelegationWorkspace;
+ readonly defaults?: WorkHubCreateDefaults;
}
interface WorkHubCoordinationMessageEnvelope {
@@ -951,6 +982,7 @@ interface WorkHubCoordinationMessageEnvelope {
disposition: WorkHubDelegationDisposition;
/** Exact target payload; retained so retry does not depend on renderer memory. */
userText: string;
+ attachments?: AttachmentRef[];
/** Present exactly for create_new. */
create?: WorkHubDelegationCreateSpec;
}
@@ -1292,7 +1324,7 @@ const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE =
'targetMessageId',
'targetSessionName',
],
- ['create', 'steered', 'replacesActionId', 'replacesDelegationId'],
+ ['attachments', 'create', 'steered', 'replacesActionId', 'replacesDelegationId'],
);
const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE =
defineObjectShape<WorkHubDelegationReplacementRequestedMessage>()(
@@ -1315,7 +1347,7 @@ const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE =
'replacedTargetMessageId',
'targetSessionName',
],
- ['create'],
+ ['attachments', 'create'],
);
const WORKHUB_DELEGATION_SUPERSEDED_MESSAGE_SHAPE =
defineObjectShape<WorkHubDelegationSupersededMessage>()(
@@ -1396,7 +1428,7 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE =
);
const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape<WorkHubDelegationCreateSpec>()(
['title', 'workspace'],
- [],
+ ['defaults'],
);
const WORKHUB_DELEGATION_PROJECT_WORKSPACE_SHAPE = defineObjectShape<
Extract<WorkHubDelegationWorkspace, { kind: 'project' }>
@@ -1660,6 +1692,7 @@ function isWorkHubCoordinationMessage(message: Record<string, unknown>): boolean
typeof message.targetSessionId === 'string' &&
typeof message.userText === 'string' &&
message.userText.trim().length > 0 &&
+ isWorkHubMessageAttachments(message.attachments) &&
((message.disposition === 'delegate_existing' && message.create === undefined) ||
(message.disposition === 'create_new' && isWorkHubDelegationCreateSpec(message.create))) &&
(message.disposition === 'delegate_existing' || message.disposition === 'create_new');
@@ -1712,10 +1745,21 @@ function isWorkHubActionIdentity(message: Record<string, unknown>): boolean {
);
}
+function isWorkHubMessageAttachments(value: unknown): boolean {
+ if (value === undefined) return true;
+ try {
+ decodeMessageContent({ text: '', attachments: value });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec {
if (
!isRecord(value) ||
!hasExactShape(value, WORKHUB_DELEGATION_CREATE_SHAPE) ||
+ (value.defaults !== undefined && !isWorkHubCreateDefaults(value.defaults)) ||
typeof value.title !== 'string' ||
value.title.trim().length === 0 ||
!isRecord(value.workspace)
diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts
index 22d7fd17b4..3d818742d2 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts
@@ -1279,6 +1279,23 @@ describe('WorkHub Coordination Action Gate', () => {
);
const input = {
actionId: 'create',
+ attachments: [
+ {
+ name: 'requirements.txt',
+ kind: 'other' as const,
+ mimeType: 'text/plain',
+ bytes: 12,
+ ref: {
+ kind: 'session_file' as const,
+ sessionId: 'maka_workhub_coordination',
+ relativePath: 'artifact-1',
+ },
+ },
+ ],
+ newWorkDefaults: {
+ model: { llmConnectionId: 'conn', llmConnectionSlug: 'test', model: 'chosen-model' },
+ permissionMode: 'ask' as const,
+ },
userText: 'Create an accessibility audit',
proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' },
create: { workspace: { kind: 'host_path' as const, path: '/workspace' } },
@@ -1291,15 +1308,24 @@ describe('WorkHub Coordination Action Gate', () => {
assert.deepEqual(restartedReplay, first);
assert.equal(effects.assignments.length, 2);
assert.deepEqual(effects.assignments[0], effects.assignments[1]);
+ assert.deepEqual(effects.assignments[0]?.attachments, input.attachments);
assert.match(effects.assignments[0]!.targetSessionId, /^whs_[a-f0-9]{48}$/u);
assert.deepEqual(effects.assignments[0]!.create, {
title: 'Accessibility audit',
workspace: input.create.workspace,
+ defaults: input.newWorkDefaults,
});
await assert.rejects(
gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT),
(error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
);
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ { ...input, newWorkDefaults: { ...input.newWorkDefaults, permissionMode: 'bypass' } },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
assert.equal(effects.assignments.length, 2);
});
@@ -3032,6 +3058,7 @@ function assignmentRecord(
delegationId: `delegation-${input.actionId}`,
disposition: input.disposition,
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
...(input.create ? { create: input.create } : {}),
...(input.replacesActionId ? { replacesActionId: input.replacesActionId } : {}),
...(input.replacesDelegationId ? { replacesDelegationId: input.replacesDelegationId } : {}),
diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
index 04a51c1043..30c0f9b40e 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
@@ -507,3 +507,62 @@ test('WorkHub Coordination action results preserve the admitted disposition', ()
);
}
});
+
+test('WorkHub decodes attachment context and user-selected creation defaults without strategy authority', () => {
+ const attachments = [
+ {
+ name: 'requirements.txt',
+ kind: 'other',
+ mimeType: 'text/plain',
+ bytes: 12,
+ ref: {
+ kind: 'session_file',
+ sessionId: 'maka_workhub_coordination',
+ relativePath: 'artifact-1',
+ },
+ },
+ ];
+ const input = {
+ actionId: 'composer-action',
+ userText: 'Create an audit',
+ proposal: { disposition: 'create_new', title: 'Audit' },
+ create: { workspace: { kind: 'host_path', path: '/workspace' } },
+ newWorkDefaults: {
+ model: {
+ llmConnectionId: 'connection-1',
+ llmConnectionSlug: 'primary',
+ model: 'chosen-model',
+ },
+ permissionMode: 'ask',
+ },
+ attachments,
+ };
+ assert.deepEqual(decodeWorkHubCoordinationActInput(input), input);
+ assert.deepEqual(
+ decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-1', text: 'Review file', attachments })
+ .attachments,
+ attachments,
+ );
+ assert.throws(() =>
+ decodeWorkHubCoordinationActInput({
+ ...input,
+ newWorkDefaults: { permissionMode: 'invented' },
+ }),
+ );
+ assert.throws(() =>
+ decodeWorkHubCoordinationActInput({
+ ...input,
+ newWorkDefaults: { ...input.newWorkDefaults, workspace: '/forged' },
+ }),
+ );
+ assert.throws(() =>
+ decodeWorkHubCoordinationActInput({
+ ...input,
+ proposal: { disposition: 'answer_here' },
+ create: undefined,
+ }),
+ );
+ assert.throws(() =>
+ decodeWorkHubCoordinationActInput({ ...input, attachments: Array(9).fill(attachments[0]) }),
+ );
+});
diff --git a/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts b/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts
new file mode 100644
index 0000000000..6a094772cd
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
+import type { AttachmentRef } from '@maka/core/events';
+import { HostArtifactCoordinator } from '../server/artifact-coordinator.js';
+import { SessionAdmissionGate } from '../server/session-admission-gate.js';
+import { copyWorkHubAttachmentsToTarget } from '../server/workhub-message-attachments.js';
+
+test('delegation copies only selected canonical attachments into the target Work', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'workhub-attachments-'));
+ const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
+ const owner = await tryAcquireInteractiveRootOwner(capability);
+ assert.ok(owner);
+ try {
+ const store = await openInteractiveArtifactStoreForWrite(owner.lease);
+ try {
+ const selected = await store.create({
+ sessionId: WORKHUB_COORDINATION_SESSION_ID,
+ turnId: 'upload-1',
+ name: 'requirements.txt',
+ kind: 'file',
+ source: 'user_upload',
+ mimeType: 'text/plain',
+ content: 'requirements',
+ });
+ await store.create({
+ sessionId: WORKHUB_COORDINATION_SESSION_ID,
+ turnId: 'upload-2',
+ name: 'unrelated.txt',
+ kind: 'file',
+ source: 'user_upload',
+ mimeType: 'text/plain',
+ content: 'private draft',
+ });
+ const artifacts = new HostArtifactCoordinator(store, () => {}, new SessionAdmissionGate(), {
+ probeSessionRemoval: async () => ({ kind: 'present' }),
+ });
+ const source: AttachmentRef = {
+ name: selected.name,
+ kind: 'other',
+ mimeType: 'text/plain',
+ bytes: selected.sizeBytes,
+ ref: {
+ kind: 'session_file',
+ sessionId: WORKHUB_COORDINATION_SESSION_ID,
+ relativePath: selected.id,
+ },
+ };
+ const copied = await copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [
+ source,
+ ]);
+ assert.equal(await artifacts.validateTurnAttachments('target-work', copied), undefined);
+ assert.equal(copied[0]!.ref.kind, 'session_file');
+ if (copied[0]!.ref.kind !== 'session_file') throw new Error('Expected canonical ref');
+ const binary = await store.readTextInSession('target-work', copied[0]!.ref.relativePath);
+ assert.deepEqual(binary, { ok: true, text: 'requirements' });
+ assert.notEqual(copied[0]!.ref.relativePath, selected.id);
+ await assert.rejects(
+ copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [
+ { ...source, bytes: 999 },
+ ]),
+ /metadata/,
+ );
+ await assert.rejects(
+ copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [
+ { ...source, ref: { ...source.ref, sessionId: 'foreign-work' } } as AttachmentRef,
+ ]),
+ /different Session/,
+ );
+ } finally {
+ store.close();
+ }
+ } finally {
+ await owner.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index ac0d00407f..3078a049b2 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,8 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 133 as const;
+// 133: WorkHub actions carry attachments and new-Work model/permission defaults.
+// Epoch-132 peers reject these additional fields on strict action shapes.
// 132: new Tool Result archives use versioned ledger references, not Artifact payloads.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 132 as const;
// 131: Logical model steps bind durable Request Composition identities.
// 130: Turn contributions carry the optional bounded `failureMessage` diagnostic.
// Epoch-129 peers reject this added field on the strict contribution shape.
diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts
index ebbd1092ef..fb9b6a3e5a 100644
--- a/packages/runtime-host/src/protocol/workhub-coordination.ts
+++ b/packages/runtime-host/src/protocol/workhub-coordination.ts
@@ -17,6 +17,9 @@
* under the License.
*/
+import type { AttachmentRef } from '@maka/core/events';
+import { decodeMessageContent } from './turn.js';
+import { isWorkHubCreateDefaults, type WorkHubCreateDefaults } from '@maka/core/session';
import {
requireCount,
requireEntityId,
@@ -80,6 +83,7 @@ export interface WorkHubCoordinationResolveResult {
export interface WorkHubCoordinationAnswerInput {
readonly turnId: string;
readonly text: string;
+ readonly attachments?: AttachmentRef[];
}
export interface WorkHubCoordinationRecordInput {
@@ -177,6 +181,8 @@ export interface WorkHubCoordinationCreateContext {
export interface WorkHubCoordinationActInput {
readonly actionId: string;
readonly userText: string;
+ readonly newWorkDefaults?: WorkHubCreateDefaults;
+ readonly attachments?: AttachmentRef[];
readonly proposal: WorkHubCoordinationProposal;
readonly candidateSetId?: string;
readonly create?: WorkHubCoordinationCreateContext;
@@ -295,8 +301,19 @@ export function decodeWorkHubCoordinationResolveResult(
export function decodeWorkHubCoordinationAnswerInput(
value: unknown,
): WorkHubCoordinationAnswerInput {
- const input = requireExactRecord(value, 'WorkHub Coordination answer input', ['turnId', 'text']);
+ const input = requireShapedRecord(
+ value,
+ 'WorkHub Coordination answer input',
+ ['turnId', 'text'],
+ ['attachments'],
+ );
return {
+ ...(input.attachments !== undefined
+ ? {
+ attachments: decodeMessageContent({ text: input.text, attachments: input.attachments })
+ .attachments!,
+ }
+ : {}),
turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'),
text: requireUtf8String(
input.text,
@@ -367,9 +384,25 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi
value,
'WorkHub Coordination action input',
['actionId', 'userText', 'proposal'],
- ['candidateSetId', 'create', 'confirmation'],
+ ['candidateSetId', 'create', 'confirmation', 'newWorkDefaults', 'attachments'],
);
const proposal = decodeWorkHubCoordinationProposal(input.proposal);
+ if (
+ input.newWorkDefaults !== undefined &&
+ (!isWorkHubCreateDefaults(input.newWorkDefaults) ||
+ !(
+ proposal.disposition === 'create_new' ||
+ (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new')
+ ))
+ ) {
+ throw invalidProtocolFrame('Invalid WorkHub creation defaults');
+ }
+ if (
+ input.attachments !== undefined &&
+ !['answer_here', 'delegate_existing', 'create_new', 'replace'].includes(proposal.disposition)
+ ) {
+ throw invalidProtocolFrame('This WorkHub action does not accept attachments');
+ }
const base = {
actionId: requireEntityId(input.actionId, 'WorkHub Coordination action id'),
userText: requireUtf8String(
@@ -378,6 +411,17 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi
WORKHUB_COORDINATION_TEXT_MAX_BYTES,
),
proposal,
+ ...(input.attachments !== undefined
+ ? {
+ attachments: decodeMessageContent({
+ text: input.userText,
+ attachments: input.attachments,
+ }).attachments!,
+ }
+ : {}),
+ ...(input.newWorkDefaults !== undefined
+ ? { newWorkDefaults: input.newWorkDefaults as WorkHubCreateDefaults }
+ : {}),
};
if (proposal.disposition === 'delegate_existing') {
if (
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 9caabd01ad..f098c3ab81 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { copyWorkHubAttachmentsToTarget } from './workhub-message-attachments.js';
import { createHash, randomUUID } from 'node:crypto';
import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments';
import type { ContextOffloadLimits } from '@maka/core/context-offload';
@@ -1560,7 +1561,17 @@ export async function createExecutionRuntimeHostComposition(
sessionId: input.targetSessionId,
workspace: input.create.workspace,
name: input.create.title,
- modelTarget: { kind: 'default' },
+ modelTarget: input.create.defaults?.model
+ ? {
+ kind: 'explicit',
+ connectionId: input.create.defaults.model.llmConnectionId,
+ connectionSlug: input.create.defaults.model.llmConnectionSlug,
+ model: input.create.defaults.model.model,
+ }
+ : { kind: 'default' },
+ ...(input.create.defaults?.permissionMode
+ ? { permissionMode: input.create.defaults.permissionMode }
+ : {}),
collaborationMode: 'agent',
orchestrationMode: 'default',
})
@@ -1570,7 +1581,19 @@ export async function createExecutionRuntimeHostComposition(
.digest('hex')
.slice(0, 48);
const messageId = `whm_${suffix}`;
- const content = normalizeMessageContent({ text: input.userText });
+ const targetAttachments =
+ !durable && input.attachments?.length
+ ? await copyWorkHubAttachmentsToTarget(
+ openedArtifactStore,
+ artifacts,
+ input.targetSessionId,
+ input.attachments,
+ )
+ : input.attachments;
+ const content = normalizeMessageContent({
+ text: input.userText,
+ ...(targetAttachments ? { attachments: targetAttachments } : {}),
+ });
const persisted =
durable ??
(await sessionAdmission.runMany(
@@ -1628,6 +1651,7 @@ export async function createExecutionRuntimeHostComposition(
delegationId,
disposition: input.disposition,
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
...(steered ? { steered: true as const } : {}),
...(input.create ? { create: input.create } : {}),
...(input.replacesActionId && input.replacesDelegationId
diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts
index 8d701e46d9..b9c9e88019 100644
--- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts
+++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import type { AttachmentRef } from '@maka/core/events';
import { createHash } from 'node:crypto';
import type {
SessionHeader,
@@ -116,7 +117,11 @@ export interface WorkHubActionGateEffects {
delegationId: string,
): Promise<WorkHubDelegationStopResolvedMessage | undefined>;
answer(
- input: { readonly turnId: string; readonly text: string },
+ input: {
+ readonly turnId: string;
+ readonly text: string;
+ readonly attachments?: AttachmentRef[];
+ },
context: ConnectionContext,
): Promise<void>;
clarify(input: {
@@ -181,6 +186,7 @@ export interface WorkHubDelegationAssignmentInput {
readonly targetSessionName: string;
readonly disposition: WorkHubDelegationDisposition;
readonly userText: string;
+ readonly attachments?: AttachmentRef[];
readonly create?: WorkHubDelegationCreateSpec;
readonly replacesActionId?: string;
readonly replacesDelegationId?: string;
@@ -360,7 +366,14 @@ export class WorkHubCoordinationActionGate {
if (proposal.disposition === 'answer_here') {
const turnId = coordinationTurnId(input.actionId, 'answer');
await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId);
- await this.#effects.answer({ turnId, text: input.userText }, context);
+ await this.#effects.answer(
+ {
+ turnId,
+ text: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ },
+ context,
+ );
return { disposition: 'answer_here', coordinationTurnId: turnId };
}
if (proposal.disposition === 'clarify') {
@@ -762,7 +775,12 @@ export class WorkHubCoordinationActionGate {
targetSessionName: target.title,
disposition: 'create_new',
userText: input.userText,
- create: { title: target.title, workspace: input.create.workspace },
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ create: {
+ title: target.title,
+ workspace: input.create.workspace,
+ ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}),
+ },
replacesActionId: replaced.actionId,
replacesDelegationId: replaced.delegationId,
replacedTargetSessionId: replaced.targetSessionId,
@@ -807,6 +825,7 @@ export class WorkHubCoordinationActionGate {
targetSessionName: destination.sessionName,
disposition: 'delegate_existing',
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
replacesActionId: replaced.actionId,
replacesDelegationId: replaced.delegationId,
replacedTargetSessionId: replaced.targetSessionId,
@@ -1085,6 +1104,7 @@ function delegationAssignment(
targetSessionName,
disposition: input.proposal.disposition,
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
} as const;
if (input.proposal.disposition === 'delegate_existing') return base;
if (!create) {
@@ -1098,6 +1118,7 @@ function delegationAssignment(
create: {
title: input.proposal.title,
workspace: create.workspace,
+ ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}),
},
};
}
@@ -1137,6 +1158,8 @@ function digest(value: unknown): `sha256:${string}` {
function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string}` {
return digest({
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}),
disposition: input.proposal.disposition,
...(input.proposal.disposition === 'delegate_existing'
? { candidateRef: input.proposal.candidateRef }
@@ -1171,6 +1194,8 @@ function replacementActionFingerprint(
}
return digest({
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}),
disposition: input.proposal.disposition,
replacesActionId: input.proposal.replacesActionId,
target: {
@@ -1261,6 +1286,7 @@ function assignmentInputFromRecord(
targetSessionName: assignment.targetSessionName,
disposition: assignment.disposition,
userText: assignment.userText,
+ ...(assignment.attachments ? { attachments: assignment.attachments } : {}),
...(assignment.create ? { create: assignment.create } : {}),
...(assignment.replacesActionId && assignment.replacesDelegationId
? {
@@ -1281,6 +1307,7 @@ function assignmentInputFromReplacement(
targetSessionName: replacement.targetSessionName,
disposition: replacement.disposition,
userText: replacement.userText,
+ ...(replacement.attachments ? { attachments: replacement.attachments } : {}),
...(replacement.create ? { create: replacement.create } : {}),
replacesActionId: replacement.replacesActionId,
replacesDelegationId: replacement.replacesDelegationId,
diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
index 6c953ed7e3..1084180500 100644
--- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
+++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
@@ -197,7 +197,7 @@ export class HostWorkHubCoordinationCoordinator {
readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId),
readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId),
answer: async (input, context) => {
- const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context);
+ const outcome = await this.#answer(input, context);
if (!outcome.ok) {
throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message);
}
@@ -248,6 +248,7 @@ export class HostWorkHubCoordinationCoordinator {
targetSessionName: input.targetSessionName,
disposition: input.disposition,
userText: input.userText,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
replacesActionId: input.replacesActionId,
replacesDelegationId: input.replacesDelegationId,
replacedTargetSessionId: input.replacedTargetSessionId,
@@ -642,7 +643,10 @@ export class HostWorkHubCoordinationCoordinator {
turnId: input.turnId,
execution: {
kind: 'workhub_coordination',
- inputDigest: digest({ text: input.text }),
+ inputDigest: digest({
+ text: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ }),
},
archivedMessage: 'WorkHub Coordination Session is unavailable',
// A recorded summary owns its Turn identity durably but is admitted
@@ -662,7 +666,13 @@ export class HostWorkHubCoordinationCoordinator {
}
return recorded.length > 0
? { kind: 'rejected', outcome: turnIdentityConflict() }
- : { kind: 'ready', content: normalizeMessageContent({ text: input.text }) };
+ : {
+ kind: 'ready',
+ content: normalizeMessageContent({
+ text: input.text,
+ ...(input.attachments ? { attachments: input.attachments } : {}),
+ }),
+ };
},
},
context,
diff --git a/packages/runtime-host/src/server/workhub-message-attachments.ts b/packages/runtime-host/src/server/workhub-message-attachments.ts
new file mode 100644
index 0000000000..5972453a79
--- /dev/null
+++ b/packages/runtime-host/src/server/workhub-message-attachments.ts
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { AttachmentRef } from '@maka/core/events';
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
+import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
+import type { HostArtifactCoordinator } from './artifact-coordinator.js';
+import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js';
+
+/** Preserve canonical ownership when a Coordination message is delegated. */
+export async function copyWorkHubAttachmentsToTarget(
+ store: Pick<InteractiveArtifactStoreWriter, 'copyConversationArtifacts'>,
+ artifacts: Pick<HostArtifactCoordinator, 'validateTurnAttachments'>,
+ targetSessionId: string,
+ attachments: readonly AttachmentRef[],
+): Promise<AttachmentRef[]> {
+ const invalid = await artifacts.validateTurnAttachments(
+ WORKHUB_COORDINATION_SESSION_ID,
+ attachments,
+ );
+ if (invalid) throw new WorkHubActionEffectFailure('operation_conflict', invalid);
+ const ids = attachments.map((attachment) => {
+ if (attachment.ref.kind !== 'session_file') throw new Error('Invalid WorkHub attachment');
+ return attachment.ref.relativePath;
+ });
+ const copied = await store.copyConversationArtifacts({
+ sourceSessionId: WORKHUB_COORDINATION_SESSION_ID,
+ targetSessionId,
+ turnIds: [],
+ includeArtifactIds: ids,
+ });
+ return attachments.map((attachment, index) => {
+ const relativePath = copied.artifactIds.get(ids[index]!);
+ if (!relativePath)
+ throw new WorkHubActionEffectFailure(
+ 'operation_conflict',
+ 'WorkHub attachment was not copied',
+ );
+ return {
+ ...attachment,
+ ref: { kind: 'session_file', sessionId: targetSessionId, relativePath },
+ };
+ });
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index d9f5513126..22e7137cda 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -12,7 +12,10 @@
"./assistant-stream": "./dist/assistant-stream.js",
"./icons": "./dist/icons.js",
"./maka-uri": "./dist/maka-uri.js",
- "./styles.css": "./src/styles.css"
+ "./styles.css": "./src/styles.css",
+ "./composer-attachments": "./dist/composer-attachments.js",
+ "./pending-items": "./dist/pending-items.js",
+ "./use-composer-attachments": "./dist/use-composer-attachments.js"
},
"scripts": {
"clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo",
diff --git a/apps/desktop/src/renderer/composer-attachments.ts b/packages/ui/src/composer-attachments.ts
similarity index 100%
copy from apps/desktop/src/renderer/composer-attachments.ts
copy to packages/ui/src/composer-attachments.ts
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index de7c8407e8..2692348122 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -314,6 +314,8 @@ export const Composer = forwardRef<
pendingDirectories?: readonly import('@maka/core/events').DirectoryReference[];
onRemoveDirectory?(index: number): void;
onAttachFilePaths?(files: File[]): void | Promise<void>;
+ /** Hosts that can submit context without a text prompt opt in. */
+ allowAttachmentOnlySend?: boolean;
pendingAttachments?: readonly {
displayName: string;
kind: AttachmentRef['kind'];
@@ -1265,7 +1267,7 @@ export const Composer = forwardRef<
// `text`. The optional metadata below is a send-time rendering snapshot of
// file chips that still exist in the editor, not a second draft state.
const text = composerWireText(textPort.getValue());
- if (!text) return;
+ if (!text && !(props.allowAttachmentOnlySend && props.pendingAttachments?.length)) return;
const editable = editableNode();
const workspaceFileReferences = editable ? workspaceFileReferencePositions(editable) : [];
const submittedDraftKey = activeDraftKey();
@@ -1453,7 +1455,7 @@ export const Composer = forwardRef<
props.sendBlocked ||
sendPending ||
importActionBusy ||
- !text.trim() ||
+ (!text.trim() && !(props.allowAttachmentOnlySend && props.pendingAttachments?.length)) ||
noModelConnection;
// The disabled Send is explanatory only in the no-model dead-end; other
// disabled reasons (empty draft, in-flight import) keep the neutral label.
diff --git a/apps/desktop/src/renderer/pending-items.ts b/packages/ui/src/pending-items.ts
similarity index 100%
copy from apps/desktop/src/renderer/pending-items.ts
copy to packages/ui/src/pending-items.ts
diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx
index 29e80605cc..b397ad069c 100644
--- a/packages/ui/src/prompt-anchor-rail.tsx
+++ b/packages/ui/src/prompt-anchor-rail.tsx
@@ -212,6 +212,9 @@ export function observeActivePromptRailVisibility(
}
export interface PromptAnchorRailTurn {
+ /** Optional host identity color; ordinary Session ticks remain neutral. */
+ accentColor?: string;
+ highlighted?: boolean;
turnId: string;
label: string;
reply?: string;
@@ -240,6 +243,8 @@ export function mergePromptAnchorRailTurns(
}
export interface PromptAnchorRailProps {
+ /** Presentation-only hover/focus linkage; never navigates the transcript. */
+ onHighlightTurn?: (turn: PromptAnchorRailTurn | undefined) => void;
turns: readonly PromptAnchorRailTurn[];
scrollRef: RefObject<HTMLElement | null>;
/** When the indexed Turn is outside the Host's active transcript range. */
@@ -376,7 +381,7 @@ export function selectPromptRailTickForMountedTurn(input: {
}
/** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */
-export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart }: PromptAnchorRailProps): React.ReactElement | null {
+export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null {
const copy = getConversationCopy(useUiLocale()).sessions;
const [activeSelection, setActiveSelection] = useState<{
turnId: string;
@@ -743,7 +748,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
className="maka-prompt-rail"
aria-label={copy.promptRailAriaLabel}
ref={railRef}
- onPointerLeave={() => setHoveredIndex(null)}
+ onPointerLeave={() => { setHoveredIndex(null); onHighlightTurn?.(undefined); }}
>
{railTurns.map((turn, index) => {
const isActive = turn.turnId === activeRailTurnId;
@@ -775,12 +780,16 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
label={copy.jumpToPrompt(preview)}
className="maka-prompt-rail-tick"
data-prompt-turn-id={turn.turnId}
+ data-highlighted={turn.highlighted || undefined}
data-active={isActive ? 'true' : undefined}
aria-current={isActive ? 'true' : undefined}
onClick={() => jumpTo(turn)}
- onPointerEnter={() => setHoveredIndex(index)}
+ onPointerEnter={() => { setHoveredIndex(index); onHighlightTurn?.(turn); }}
+ onFocus={() => onHighlightTurn?.(turn)}
+ onBlur={() => onHighlightTurn?.(undefined)}
style={
{
+ color: turn.accentColor,
'--maka-prompt-rail-index': index,
'--maka-prompt-rail-scale': scale,
} as CSSProperties
diff --git a/apps/desktop/src/renderer/use-composer-attachments.ts b/packages/ui/src/use-composer-attachments.ts
similarity index 97%
rename from apps/desktop/src/renderer/use-composer-attachments.ts
rename to packages/ui/src/use-composer-attachments.ts
index 4f762d8847..e5110fb283 100644
--- a/apps/desktop/src/renderer/use-composer-attachments.ts
+++ b/packages/ui/src/use-composer-attachments.ts
@@ -29,13 +29,10 @@ import {
type AttachmentRef,
type DirectoryReference,
} from '@maka/core/events';
-import { useUiLocale } from '@maka/ui';
import {
pendingAttachmentSourceKey,
type PendingAttachment,
} from './composer-attachments.js';
-import { getDesktopConversationCopy } from './locales/conversation-copy.js';
-import { localizedShellErrorMessage } from './locales/shell-copy.js';
import {
appendPending,
removePending,
@@ -174,7 +171,16 @@ function releasePreviewUrl(url: string | undefined): void {
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
}
+export interface ComposerAttachmentCopy {
+ attachmentFailedTitle: string;
+ tryAgain: string;
+ imageAttachmentNotDirectTitle: string;
+ imageAttachmentNotDirectDescription: string;
+}
+
export function useComposerAttachments(options: {
+ copy: ComposerAttachmentCopy;
+ formatError(error: unknown, fallback: string): string;
draftKey: string;
directoryHostId?: string;
toastApi: ToastApi;
@@ -187,8 +193,7 @@ export function useComposerAttachments(options: {
}
| undefined;
}) {
- const uiLocale = useUiLocale();
- const copy = getDesktopConversationCopy(uiLocale).actions;
+ const copy = options.copy;
const [pendingState, setPendingState] = useState<ComposerPendingState>({
attachments: {},
directories: {},
@@ -332,7 +337,7 @@ export function useComposerAttachments(options: {
} catch (error) {
options.toastApi.error(
copy.attachmentFailedTitle,
- localizedShellErrorMessage(error, copy.tryAgain, uiLocale),
+ options.formatError(error, copy.tryAgain),
);
}
}
@@ -365,7 +370,7 @@ export function useComposerAttachments(options: {
} catch (error) {
owner.toastApi.error(
copy.attachmentFailedTitle,
- localizedShellErrorMessage(error, copy.tryAgain, uiLocale),
+ options.formatError(error, copy.tryAgain),
);
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org