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 = [{ 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, / + + ) : null} + {rail}

{props.text}

+ {props.attachments?.length ?
    {props.attachments.map((attachment, index) =>
  • {attachment.name}
  • )}
: null}
@@ -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} 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 ; + }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelectorAll('.workhub-bound-turn')).toHaveLength(3)); + const turns = Array.from(canvasElement.querySelectorAll('.workhub-bound-turn')); + const rail = turns[0]!.querySelector('.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('.maka-prompt-rail-tick')); + expect(ticks).toHaveLength(3); + const navigation = canvasElement.querySelector('.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({ + 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(() => ({ + ...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 ({ 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 })); + }, + }}> +
+ {}} composerServices={{ + sessions: [session], modelChoices: composerModels, + defaults: { model: { llmConnectionId: 'connection-test', llmConnectionSlug: 'test', model: 'model-a' }, permissionMode: 'ask' }, + confirmBypass: async () => true, onOpenModelSettings: () => {}, + }} /> +
+
; +} + +// Production Composer, with only native file picking and Host writes replaced. +export const StandardComposer: Story = { + render: () => , + 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: () => , + 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()( @@ -1315,7 +1347,7 @@ const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE = 'replacedTargetMessageId', 'targetSessionName', ], - ['create'], + ['attachments', 'create'], ); const WORKHUB_DELEGATION_SUPERSEDED_MESSAGE_SHAPE = defineObjectShape()( @@ -1396,7 +1428,7 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], - [], + ['defaults'], ); const WORKHUB_DELEGATION_PROJECT_WORKSPACE_SHAPE = defineObjectShape< Extract @@ -1660,6 +1692,7 @@ function isWorkHubCoordinationMessage(message: Record): 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): 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; answer( - input: { readonly turnId: string; readonly text: string }, + input: { + readonly turnId: string; + readonly text: string; + readonly attachments?: AttachmentRef[]; + }, context: ConnectionContext, ): Promise; 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, + artifacts: Pick, + targetSessionId: string, + attachments: readonly AttachmentRef[], +): Promise { + 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/packages/ui/src/composer-attachments.ts b/packages/ui/src/composer-attachments.ts new file mode 100644 index 0000000000..d978b4ec87 --- /dev/null +++ b/packages/ui/src/composer-attachments.ts @@ -0,0 +1,77 @@ +/* + * 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'; + +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)] + : [], + ); +} 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; + /** 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/packages/ui/src/pending-items.ts b/packages/ui/src/pending-items.ts new file mode 100644 index 0000000000..eb1963ac22 --- /dev/null +++ b/packages/ui/src/pending-items.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +/** Generic keyed staging registry shared by composer surfaces. */ +export type PendingByKey = Record; + +/** + * 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(map: PendingByKey, key: string): T[] { + return map[key] ?? []; +} + +export function appendPending( + map: PendingByKey, + key: string, + items: readonly T[], +): PendingByKey { + return { ...map, [key]: [...(map[key] ?? []), ...items] }; +} + +export function removePending(map: PendingByKey, key: string, index: number): PendingByKey { + const current = map[key] ?? []; + return { ...map, [key]: current.filter((_, i) => i !== index) }; +} + +export function removePendingItems( + map: PendingByKey, + key: string, + items: readonly T[], + identityOf: (item: T) => unknown = (item) => item, +): PendingByKey { + 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(map: PendingByKey, key: string): PendingByKey { + const next = { ...map }; + delete next[key]; + return next; +} 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; /** 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({ 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), ); } }