& { w
}])), [workByTurn, highlight.sessionId]);
const turnDecorations = new Map([...worksByTurn].map(([turnId, works]) => [turnId, {
accentColor: promptRailDecorations.get(turnId)?.accentColor,
+ promptStatus: <>{works.map((work, index) =>
+ {index > 0 ? ' / ' : ''} 1} />
+ )}>,
header:
{works.map((work) =>
)}
+ {controller.targetSelection && { controller.dismissTargetSelection(); requestAnimationFrame(() => composer.current?.focus()); }} />}
+
}
/>
+
{!progress && floating && !conversationExpanded && (
} label={t.expandConversation} aria-expanded={false} onClick={toggleConversation} />
)}
@@ -334,7 +347,7 @@ export function WorkHubRoot() {
viewportNavigation={controller.viewportNavigation}
liveTurn={controller.liveTurn}
onStreamingSettled={controller.streamingSettled}
- runningStatus={busy}
+ runningStatus={busy && !controller.targetSelection}
messageLoading={!transcript.ready}
activeSession={session}
activeModel={session?.model}
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx
new file mode 100644
index 0000000000..e2e05ba8ee
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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 { useId, useState } from 'react';
+import { Button } from '@astryxdesign/core';
+import { ChoicePanel, presentSessionStatus, useUiLocale } from '@maka/ui';
+import type { WorkHubTargetSelection, WorkHubTargetSelectionRequest } from '@maka/runtime-host/protocol';
+import { workHubIdentityHue } from './workhub-work-identity.js';
+import { workHubSelectionCopy } from '../locales/workhub-selection-copy.js';
+
+export function WorkHubTargetSelector(props: {
+ request: WorkHubTargetSelectionRequest;
+ submitting: boolean;
+ onChoose(selection: WorkHubTargetSelection): void;
+ onDismiss(): void;
+}) {
+ const locale = useUiLocale();
+ const copy = workHubSelectionCopy[locale];
+ const titleId = useId();
+ const [value, setValue] = useState('');
+ const confirm = () => { if (value && !props.submitting) props.onChoose({ requestId: props.request.requestId, kind: 'existing', candidateRef: value }); };
+ return
+
+
+
({
+ value: candidate.candidateRef,
+ label: candidate.sessionName,
+ accentColor: `oklch(var(--workhub-selection-label) ${workHubIdentityHue(candidate.sessionId)})`,
+ description: `${candidate.workspace.hostCwd.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) ?? ''} · ${presentSessionStatus(candidate.state, locale).label}`,
+ }))}>
+
+
+
+ ;
+}
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index eee9131f19..4388fae008 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -302,3 +302,12 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after {
.maka-assistant-answer .workhub-turn-label {
text-align: left;
}
+
+.workhub-target-selector { width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6))); margin-inline: auto; }
+.workhub-selection-hint { margin: 4px 0 0; color: var(--muted-foreground); font-size: 12px; }
+.workhub-target-selector [role="radiogroup"] { max-height: 260px; overflow-y: auto; }
+.workhub-selection-actions { flex-wrap: wrap; }
+.workhub-selection-actions > :last-child { margin-inline-start: auto; }
+
+.workhub-target-selector { --workhub-selection-label: 0.42 0.075; }
+.dark .workhub-target-selector { --workhub-selection-label: 0.8 0.09; }
diff --git a/apps/desktop/src/shared/workhub-conversation.d.ts b/apps/desktop/src/shared/workhub-conversation.d.ts
index b30a677957..cf355907c6 100644
--- a/apps/desktop/src/shared/workhub-conversation.d.ts
+++ b/apps/desktop/src/shared/workhub-conversation.d.ts
@@ -25,6 +25,7 @@ export type WorkHubAnswerInput = OperationInput<'workhub.coordination.answer'> &
};
export type WorkHubAnswerResult =
+ | { readonly kind: 'selection_required'; readonly turnId: string; readonly request: NonNullable }
| { readonly kind: 'admitted'; readonly turnId: string; readonly status?: TurnSnapshot['status'] }
| { readonly kind: 'unknown'; readonly originHostEpoch: string }
| { readonly kind: 'not_admitted' };
diff --git a/apps/desktop/stories/ask-user-question.stories.tsx b/apps/desktop/stories/ask-user-question.stories.tsx
index 53511f4188..5a96bc651a 100644
--- a/apps/desktop/stories/ask-user-question.stories.tsx
+++ b/apps/desktop/stories/ask-user-question.stories.tsx
@@ -19,6 +19,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { UserQuestionRequestEvent } from '@maka/core/events';
+import { expect, userEvent, within, waitFor } from 'storybook/test';
import { UserQuestionPrompt } from '@maka/ui';
// Fidelity convention (#1433): every story below names the real app path
@@ -94,3 +95,19 @@ export const PendingDecisions: Story = {
onStop: () => {},
},
};
+
+export const KeyboardChoices: Story = {
+ ...PendingDecisions,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.keyboard('2');
+ expect(canvas.getByRole('radio', { name: '公开测试' })).toBeChecked();
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(canvas.getByRole('heading', { name: '上线时间怎么安排?' })).toBeInTheDocument());
+ await userEvent.keyboard('{Escape}');
+ const input = canvas.getByRole('textbox');
+ await userEvent.type(input, '123');
+ expect(input).toHaveValue('123');
+ expect(canvas.getByRole('radio', { name: '其他' })).toBeChecked();
+ },
+};
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index c50bbf75b3..a0d81a3e70 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -33,7 +33,7 @@ const choices = ['model-a', 'model-b'].map((model, index) => ({
connectionId: 'connection-test', connectionSlug: 'test', connectionName: 'Test', providerType: 'openai' as const,
providerLabel: 'OpenAI', model, label: model, isDefault: index === 0, thinkingLevels: [],
}));
-function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: boolean): WorkHubServices {
+function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: boolean, selectTarget = false): WorkHubServices {
let failures = failFirst ? 1 : 0;
let session: SessionSummary & { revision: number } = {
id: sessionId, name: 'WorkHub', revision: 1, isFlagged: false, isArchived: false, labels: [], hasUnread: false,
@@ -82,6 +82,10 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
prepareAttachments: async (id, items) => { writes.upload(id, items); return [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }]; },
answer: async (id, input) => {
writes.answer(id, input);
+ if (selectTarget && !input.selection) return { kind: 'selection_required', turnId: input.turnId, request: {
+ requestId: 'selection-request', candidateSetId: `sha256:${'0'.repeat(64)}`,
+ candidates: [target, secondTarget].map((candidate, index) => ({ candidateRef: `candidate-${index}`, sessionId: candidate.id, sessionName: candidate.name, workspace: { target: { kind: 'host_path' as const, path: candidate.cwd }, hostCwd: candidate.cwd }, state: 'active' as const, updatedAt: 1 })),
+ } };
if (failures-- > 0) throw new Error('Temporary Host failure');
messages = [...messages, { type: 'user', id: input.turnId, turnId: input.turnId, ts: 4, text: input.text, attachments: input.attachments }, { type: 'assistant', id: `${input.turnId}-answer`, turnId: input.turnId, ts: 5, modelId: 'model-a', text: '已收到。' }, { type: 'turn_state', id: `${input.turnId}-done`, turnId: input.turnId, ts: 6, status: 'completed' }];
publish(); return { kind: 'admitted', turnId: input.turnId };
@@ -95,8 +99,8 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
stop: async () => [],
};
}
-function Surface({ failFirst = false, history = false, colors = false }: { failFirst?: boolean; history?: boolean; colors?: boolean }) {
- const [services] = useState(() => makeServices(failFirst, history, colors));
+function Surface({ failFirst = false, history = false, colors = false, selectTarget = false }: { failFirst?: boolean; history?: boolean; colors?: boolean; selectTarget?: boolean }) {
+ const [services] = useState(() => makeServices(failFirst, history, colors, selectTarget));
return
;
}
const meta = { title: 'Product/WorkHub', parameters: { layout: 'fullscreen' } } satisfies Meta;
@@ -190,3 +194,55 @@ export const ColoredWorkHistory: Story = {
await userEvent.unhover(label);
},
};
+
+export const TargetSelection: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
+ await userEvent.click(editor);
+ await userEvent.type(editor, '继续支付相关的工作,把异常场景补齐。');
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(canvasElement.querySelector('.workhub-target-selector')).toBeInTheDocument());
+ expect(canvasElement.querySelector('.maka-turn-processing')).toBeNull();
+ },
+};
+
+export const KeyboardTargetSelection: Story = {
+ render: () => ,
+ play: async (context) => {
+ Object.values(writes).forEach((spy) => spy.mockClear());
+ await TargetSelection.play!(context);
+ const canvas = within(context.canvasElement);
+ expect(canvas.getByRole('button', { name: '确认目标' })).toBeDisabled();
+ await userEvent.keyboard('2');
+ expect(canvas.getAllByRole('radio')[1]).toBeChecked();
+ expect(writes.answer).toHaveBeenCalledTimes(1);
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(context.canvasElement.querySelector('.workhub-target-selector')).toBeNull());
+ expect(writes.answer).toHaveBeenLastCalledWith(sessionId, expect.objectContaining({ text: '继续支付相关的工作,把异常场景补齐。', selection: { requestId: 'selection-request', kind: 'existing', candidateRef: 'candidate-1' } }));
+ const editor = context.canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
+ await waitFor(() => expect(editor).toHaveTextContent(''));
+ await userEvent.click(editor); await userEvent.type(editor, '需要进一步说明'); await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(context.canvasElement.querySelector('.workhub-target-selector')).toBeInTheDocument());
+ await userEvent.keyboard('{ArrowDown}');
+ expect(canvas.getAllByRole('radio')[0]).toBeChecked();
+ await userEvent.keyboard('{ArrowDown}');
+ expect(canvas.getAllByRole('radio')[1]).toBeChecked();
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => expect(context.canvasElement.querySelector('.workhub-target-selector')).toBeNull());
+ expect(editor).toHaveTextContent('需要进一步说明');
+ expect(writes.answer).toHaveBeenCalledTimes(3);
+ },
+};
+
+export const TargetSelectionFailure: Story = {
+ render: () => ,
+ play: async (context) => {
+ await TargetSelection.play!(context);
+ await userEvent.keyboard('1{Enter}');
+ await waitFor(() => expect(context.canvasElement.querySelector('.workhub-target-selector')).toBeNull());
+ const editor = context.canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
+ expect(editor).toHaveTextContent('继续支付相关的工作,把异常场景补齐。');
+ expect(within(context.canvasElement).getByRole('alert')).toHaveTextContent('Temporary Host failure');
+ },
+};
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 4d87e72bfa..93cbb9907f 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:** 268 files — blocker 0, reimplementation 0, polish 2, aligned 266.
+**Totals:** 270 files — blocker 0, reimplementation 0, polish 2, aligned 268.
## Exclusions (explicit)
@@ -110,6 +110,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workhub/ui/workhub-progress-card.tsx` | other | IconButton | aligned — uses Astryx (IconButton) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx` | other | Button, IconButton | aligned — uses Astryx (Button, IconButton) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx` | other | Button | aligned — uses Astryx (Button) | 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 |
@@ -244,6 +245,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned |
| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | aligned |
+| `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | RadioList, RadioListItem | aligned — uses Astryx (RadioList, RadioListItem) | aligned |
| `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem, Tooltip | raw `