From 1eaabbc6666f5bcb5a422e643fc6ad5f98518070 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Fri, 11 Sep 2026 20:50:33 +0800 Subject: [PATCH 01/19] feat(desktop): color WorkHub turns and workspace identity Generated-by: Codex --- .../__tests__/workhub-anchor-rail.test.ts | 22 +++++++++- .../features/workhub/model/linked-work.ts | 12 ++++-- .../workhub/ui/workhub-conversation.tsx | 36 ++++++++++++++++- .../workhub/ui/workhub-navigation-rail.tsx | 2 +- apps/desktop/src/renderer/styles/workhub.css | 29 ++++++++++++++ apps/desktop/stories/workhub.stories.tsx | 40 +++++++++++++++++-- .../chat-view-empty-compaction.test.tsx | 14 +++++++ packages/ui/src/chat-view.tsx | 8 +++- 8 files changed, 150 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index b8dbab088a..7b50069280 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -31,6 +31,8 @@ import { workHubLinkedWork, workHubTurnResultPreview, } from "../../renderer/features/workhub/index.js"; +import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; +import { WorkHubConversation } from '../../renderer/features/workhub/ui/workhub-conversation.js'; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; @@ -38,8 +40,11 @@ test('durable task results restore Host-scoped work links without treating faile const target = JSON.stringify(['host-a', 'task-a']); const call: ToolCallMessage = { type: 'tool_call', id: 'task-call', turnId: 'turn', ts: 1, toolName: 'mcp__desktop_workhub__tasks', args: {} }; const result: ToolResultMessage = { type: 'tool_result', id: 'task-result', turnId: 'turn', ts: 2, toolUseId: call.id, isError: false, content: { kind: 'json', value: { disposition: 'create_new', targetSessionKey: target } } }; - const expected = [{ id: result.id, coordinationTurnId: call.turnId, targetSessionId: target, targetSessionName: 'Renamed task' }]; + const expected = [{ id: result.id, coordinationTurnId: call.turnId, targetSessionId: target, targetSessionName: 'Renamed task', workspaceName: undefined }]; assert.deepEqual(workHubLinkedWork([call, result], [{ id: target, name: 'Renamed task' }], 'Work'), expected); + for (const cwd of ['/projects/payments/', 'C:\\projects\\payments\\']) { + assert.deepEqual(workHubLinkedWork([call, result], [{ id: target, name: 'Renamed task', cwd }], 'Work'), [{ ...expected[0], workspaceName: 'payments' }]); + } assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'json', value: { content: [], structuredContent: { disposition: 'create_new', targetSessionKey: target } } } }], [{ id: target, name: 'Renamed task' }], 'Work'), expected); assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'text', text: JSON.stringify({ disposition: 'delegate_existing', targetSessionKey: target }) } }], [], 'Work'), [{ ...expected[0], targetSessionName: 'Work' }]); assert.deepEqual(workHubLinkedWork([ @@ -186,3 +191,18 @@ test("focus display is derived from the selected Session ID, not delegation prio assert.equal(markup.match(/aria-current="page"/gu)?.length, 1); assert.equal(markup.match(/Focused · Running/gu)?.length, 1); }); + + +test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, + createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, { + activeSession: { id: 'coordination', name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' }, + messages: [{ type: 'user', id: 'user', turnId: 'shared', text: 'Do both tasks', ts: 1 }], + scrollBehavior: 'auto', onNew: () => {}, onOpenWork: () => {}, + workLinks: ['Alpha', 'Beta'].map((name) => ({ id: name, coordinationTurnId: 'shared', targetSessionId: name, targetSessionName: name, workspaceName: 'Workspace' })), + })), + )); + assert.match(markup, /Workspace \/ Alpha/); + assert.match(markup, /Workspace \/ Beta/); + assert.doesNotMatch(markup, /data-turn-accent/); +}); diff --git a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts index f985bbcaa8..829cfe9145 100644 --- a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts +++ b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts @@ -47,6 +47,7 @@ export interface WorkHubLinkedWork { readonly coordinationTurnId: string; readonly targetSessionId: string; readonly targetSessionName: string; + readonly workspaceName?: string; readonly targetMessageId?: string; readonly targetTurnId?: string; readonly state?: WorkHubDelegationState; @@ -56,10 +57,11 @@ export interface WorkHubLinkedWork { /** Links come from successful tool results in the same durable conversation. */ export function workHubLinkedWork( messages: readonly StoredMessage[], - sessions: readonly { id: string; name: string }[], + sessions: readonly { id: string; name: string; cwd?: string }[], fallbackName: string, ): WorkHubLinkedWork[] { - const names = new Map(sessions.map((session) => [session.id, session.name])); + const sessionById = new Map(sessions.map((session) => [session.id, session])); + const workspaceName = (id: string) => sessionById.get(id)?.cwd?.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) || undefined; const taskCalls = new Set(messages.flatMap((message) => message.type === 'tool_call' && message.toolName === 'mcp__desktop_workhub__tasks' ? [message.id] : [], )); @@ -68,7 +70,8 @@ export function workHubLinkedWork( id: message.id, coordinationTurnId: message.coordinationTurnId, targetSessionId: message.targetSessionId, - targetSessionName: message.targetSessionName, + targetSessionName: sessionById.get(message.targetSessionId)?.name ?? message.targetSessionName, + workspaceName: workspaceName(message.targetSessionId), targetMessageId: message.targetMessageId, targetTurnId: message.targetTurnId, state: 'accepted', @@ -87,7 +90,8 @@ export function workHubLinkedWork( id: message.id, coordinationTurnId: message.turnId, targetSessionId: result.targetSessionKey, - targetSessionName: names.get(result.targetSessionKey) ?? fallbackName, + targetSessionName: sessionById.get(result.targetSessionKey)?.name ?? fallbackName, + workspaceName: workspaceName(result.targetSessionKey), }]; }); } diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index 134c41208c..065afe9963 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -55,7 +55,7 @@ export function WorkHubResultCard(props: { onBlur={() => props.onHighlight(false)}>
- {work.targetSessionName} + {work.workspaceName ? `${work.workspaceName} / ` : ''}{work.targetSessionName} {stateLabel}
{work.resultPreview ?

{work.resultPreview}

: null} @@ -72,12 +72,44 @@ export function WorkHubConversation(props: ComponentProps & { w const { onOpenWork, workLinks: assignments, ...chat } = props; const highlight = useContext(WorkHubHighlightContext); const locale = useUiLocale(); - const workByTurn = useMemo(() => new Map(assignments.map((assignment) => [assignment.coordinationTurnId, assignment.targetSessionId])), [assignments]); + // A coordination turn can delegate to several Works. Keep every label and + // leave its shared bar neutral rather than attributing the entire turn to one. + const worksByTurn = useMemo(() => { + const grouped = new Map(); + for (const work of assignments) { + const works = grouped.get(work.coordinationTurnId) ?? []; + if (!works.some((item) => item.targetSessionId === work.targetSessionId)) works.push(work); + grouped.set(work.coordinationTurnId, works); + } + return grouped; + }, [assignments]); + const workByTurn = useMemo(() => new Map([...worksByTurn].flatMap(([turnId, works]) => + works.length === 1 ? [[turnId, works[0]!.targetSessionId] as const] : [])), [worksByTurn]); const promptRailDecorations = useMemo(() => new Map([...workByTurn].map(([turnId, sessionId]) => [turnId, { accentColor: `oklch(var(--workhub-${highlight.sessionId === sessionId ? 'highlight' : 'tone'}) ${workHubIdentityHue(sessionId)})`, highlighted: highlight.sessionId === sessionId, }])), [workByTurn, highlight.sessionId]); + const turnDecorations = new Map([...worksByTurn].map(([turnId, works]) => [turnId, { + accentColor: promptRailDecorations.get(turnId)?.accentColor, + header:
+ {works.map((work) =>
, + }])); return highlight.highlight(turnId ? workByTurn.get(turnId) : undefined)} conversationItems={assignments.map((assignment) => ({ 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 80a625ba6f..bdd7e743f0 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 @@ -126,7 +126,7 @@ export function WorkHubNavigationRail(props: { onFocus={() => highlight.highlight(anchor.target.sessionId)} onBlur={() => highlight.highlight(undefined)} label={{anchor.sessionName}} - description={`${anchor.target.sessionId === props.focusSessionId ? props.copy.focused : anchor.projectName} · ${state}`} + description={<>{anchor.projectName}{` · ${anchor.target.sessionId === props.focusSessionId ? `${props.copy.focused} · ` : ''}${state}`}} startContent={variant ? : undefined} isSelected={anchor.target.sessionId === props.focusSessionId} aria-current={anchor.target.sessionId === props.focusSessionId ? 'page' : undefined} diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 991bb26399..8b2d1a4c32 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -299,3 +299,32 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after { @media (prefers-reduced-motion: reduce) { .workHubLive[data-placement='floating'], .workHubLive[data-placement='floating']::before, body:has(.workHubLive[data-placement='floating'])::after, .workHubLive[data-conversation-expanded] .workHubHistory { transition: none; } } + +/* The stripe identifies the linked Work, not execution status. */ +.workhub-surface .maka-transcript-turn[data-turn-accent='true'] { + border-inline-start: 3px solid var(--maka-turn-accent); + padding-inline-start: 12px; +} + +.workhub-turn-heading { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + margin-bottom: 4px; +} + +.workhub-turn-label { + max-width: 100%; + height: auto; + min-height: 28px; + color: var(--workhub-work-label); + font-size: var(--font-size-body-xs); + white-space: normal; + overflow-wrap: anywhere; + text-align: right; +} + +.workhub-navigation-workspace { + color: var(--workhub-work-label); +} diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 48eabd2cc2..c5d2ddcc46 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): WorkHubServices { +function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: boolean): WorkHubServices { let failures = failFirst ? 1 : 0; let session: SessionSummary & { revision: number } = { id: sessionId, name: 'WorkHub', revision: 1, isFlagged: false, isArchived: false, labels: [], hasUnread: false, @@ -46,6 +46,19 @@ function makeServices(failFirst: boolean, withHistory: boolean): WorkHubServices { type: 'assistant', id: 'answer-1', turnId: 'turn-1', ts: 2, modelId: 'model-a', text: '已将任务交给支付回调工作。完整说明保留在工作台。\n\n' + '重复请求需要保持同一响应。'.repeat(70) + '\n\nEND_OF_FULL_RESPONSE' }, { type: 'workhub_coordination', kind: 'delegation_assigned', id: 'link-1', turnId: 'turn-1', coordinationTurnId: 'turn-1', ts: 3, schemaVersion: 1, actionId: 'action-1', actionFingerprint: `sha256:${'0'.repeat(64)}`, disposition: 'delegate_existing', userText: '继续支付回调幂等性,补充重复投递测试点。', targetSessionId: targetId, targetSessionName: target.name, targetTurnId: 'target-turn', targetMessageId: 'target-message', delegationId: 'delegation-1' }, ] : []; + const secondTarget = { ...target, id: desktopSessionKey({ hostId: 'story-host', sessionId: 'release' }), name: '发布检查清单', cwd: '/projects/desktop' }; + if (coloredHistory) { + const link = messages.find((message) => message.type === 'workhub_coordination' && message.kind === 'delegation_assigned')!; + messages = [target, secondTarget, target].flatMap((work, index): StoredMessage[] => { + const turnId = `turn-${index + 1}`; + return [ + { type: 'user', id: `user-${index}`, turnId, ts: index * 3, text: index === 2 ? '继续补充异常场景。' : `请检查${work.name}。` }, + { type: 'assistant', id: `answer-${index}`, turnId, ts: index * 3 + 1, modelId: 'model-a', text: '任务已交给对应 Work,执行结果将在下方更新。' }, + { ...link, id: `link-${index}`, turnId, coordinationTurnId: turnId, targetSessionId: work.id, targetSessionName: work.name } as StoredMessage, + ]; + }); + messages.push({ type: 'user', id: 'unlinked', turnId: 'unlinked-turn', ts: 20, text: '先讨论一下整体计划。' }); + } let updateTranscript: ((snapshot: WorkHubTranscriptSnapshot) => void) | undefined; let updateSessions: (() => void) | undefined; const publish = () => updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true }); @@ -58,7 +71,7 @@ function makeServices(failFirst: boolean, withHistory: boolean): WorkHubServices control: { getSnapshot: async () => ({ revision: 0, phase: 'idle', canUndo: false }), subscribe: () => () => {}, stop: async () => {}, undo: async () => {} }, resolve: async () => sessionId, subscribeHosts: () => () => {}, subscribeAvailability: () => () => {}, getSession: async () => session, - listSessions: async () => [target], subscribeSessions: (handler) => { updateSessions = handler; return () => { updateSessions = undefined; }; }, modelChoices: async () => choices, + listSessions: async () => coloredHistory ? [target, secondTarget] : [target], subscribeSessions: (handler) => { updateSessions = handler; return () => { updateSessions = undefined; }; }, modelChoices: async () => choices, delegationFeedback: async (references) => references.map(({ id }) => ({ id, state: 'completed' as const, @@ -82,8 +95,8 @@ function makeServices(failFirst: boolean, withHistory: boolean): WorkHubServices stop: async () => [], }; } -function Surface({ failFirst = false, history = false }: { failFirst?: boolean; history?: boolean }) { - const [services] = useState(() => makeServices(failFirst, history)); +function Surface({ failFirst = false, history = false, colors = false }: { failFirst?: boolean; history?: boolean; colors?: boolean }) { + const [services] = useState(() => makeServices(failFirst, history, colors)); return
; } const meta = { title: 'Product/WorkHub', parameters: { layout: 'fullscreen' } } satisfies Meta; @@ -143,3 +156,22 @@ export const ComposerRetainsFailedAttachment: Story = { await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0)); }, }; + +export const ColoredWorkHistory: Story = { + render: () => , + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelectorAll('[data-turn-accent="true"]')).toHaveLength(3)); + const turns = canvasElement.querySelectorAll('[data-turn-accent="true"]'); + expect(turns[0]!.style.getPropertyValue('--maka-turn-accent')).toBe(turns[2]!.style.getPropertyValue('--maka-turn-accent')); + expect(turns[0]!.style.getPropertyValue('--maka-turn-accent')).not.toBe(turns[1]!.style.getPropertyValue('--maka-turn-accent')); + expect(turns[0]!.querySelector('.workhub-turn-label')).toHaveTextContent('maka / 支付回调幂等性'); + expect(turns[1]!.querySelector('.workhub-turn-label')).toHaveTextContent('desktop / 发布检查清单'); + expect(canvasElement.querySelector('[data-transcript-turn-id="unlinked-turn"]')).not.toHaveAttribute('data-turn-accent'); + const label = turns[0]!.querySelector('.workhub-turn-label')!; + await userEvent.hover(label); + await waitFor(() => expect(turns[2]!.querySelector('.workhub-turn-label')).toHaveAttribute('data-work-highlighted', 'true')); + await userEvent.click(label); + expect(writes.open).toHaveBeenCalledWith(targetId); + await userEvent.unhover(label); + }, +}; diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx index f01c5cf6e7..cbb85d4b4d 100644 --- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -152,3 +152,17 @@ test('ChatSurfaceLayout preserves the public emptyState for absent children', () assert.doesNotMatch(markup, /maka-prompt-rail-host/); } }); + + +test('turn identity stays on its exact durable anchor and is absent from ordinary transcripts', () => { + const messages = ['one', 'two'].map((turnId) => ({ type: 'user' as const, id: `user-${turnId}`, turnId, text: turnId, ts: 1 })); + const { document } = parseHTML(renderChat(undefined, { + messages, + turnDecorations: new Map([['one', { header: Workspace / Work, accentColor: 'red' }]]), + })); + const one = document.querySelector('[data-transcript-turn-id="one"]')!; + assert.equal(one.getAttribute('data-turn-accent'), 'true'); + assert.match(one.textContent!, /Workspace \/ Work/); + assert.equal(document.querySelector('[data-transcript-turn-id="two"]')!.getAttribute('data-turn-accent'), null); + assert.doesNotMatch(renderChat(undefined, { messages }), /data-turn-accent|Workspace \/ Work/); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index e278ca3ff7..dda5a653e7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { ICON_SIZE, AlertTriangle, @@ -253,6 +253,8 @@ export function ChatView(props: { * the regular prompt-suggestion hero shows. */ emptyOverride?: ReactNode; + /** Optional host-owned identity beside a turn; absent for ordinary transcripts. */ + turnDecorations?: ReadonlyMap; /** Session-owned records anchored after a durable conversation turn. */ conversationItems?: ReadonlyArray<{ id: string; @@ -858,12 +860,16 @@ export function ChatView(props: { ); } const turn = row.turn; + const decoration = props.turnDecorations?.get(turn.turnId); return (
+ {decoration?.header} Date: Fri, 11 Sep 2026 20:53:53 +0800 Subject: [PATCH 02/19] fix(desktop): separate WorkHub prompt and answer color bars Generated-by: Codex --- apps/desktop/src/renderer/styles/workhub.css | 9 +++++++-- apps/desktop/stories/workhub.stories.tsx | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 8b2d1a4c32..8a927236e5 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -300,8 +300,13 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after { .workHubLive[data-placement='floating'], .workHubLive[data-placement='floating']::before, body:has(.workHubLive[data-placement='floating'])::after, .workHubLive[data-conversation-expanded] .workHubHistory { transition: none; } } -/* The stripe identifies the linked Work, not execution status. */ -.workhub-surface .maka-transcript-turn[data-turn-accent='true'] { +/* Each message carries Work identity on its sender's edge. */ +.workhub-surface .maka-transcript-turn[data-turn-accent='true'] > .maka-turn > .maka-user-message { + border-inline-end: 3px solid var(--maka-turn-accent); + padding-inline-end: 12px; +} + +.workhub-surface .maka-transcript-turn[data-turn-accent='true'] > .maka-turn > .maka-assistant-answer { border-inline-start: 3px solid var(--maka-turn-accent); padding-inline-start: 12px; } diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index c5d2ddcc46..682fdef22a 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -167,6 +167,15 @@ export const ColoredWorkHistory: Story = { expect(turns[0]!.querySelector('.workhub-turn-label')).toHaveTextContent('maka / 支付回调幂等性'); expect(turns[1]!.querySelector('.workhub-turn-label')).toHaveTextContent('desktop / 发布检查清单'); expect(canvasElement.querySelector('[data-transcript-turn-id="unlinked-turn"]')).not.toHaveAttribute('data-turn-accent'); + for (const turn of turns) { + const prompt = getComputedStyle(turn.querySelector('.maka-user-message')!); + const answer = getComputedStyle(turn.querySelector('.maka-assistant-answer')!); + expect(prompt.borderRightWidth).toBe('3px'); + expect(prompt.borderLeftWidth).toBe('0px'); + expect(answer.borderLeftWidth).toBe('3px'); + expect(answer.borderRightWidth).toBe('0px'); + expect(getComputedStyle(turn).borderLeftWidth).toBe('0px'); + } const label = turns[0]!.querySelector('.workhub-turn-label')!; await userEvent.hover(label); await waitFor(() => expect(turns[2]!.querySelector('.workhub-turn-label')).toHaveAttribute('data-work-highlighted', 'true')); From e98094955bb84c7ccb6dc4b150f52f3412c807d4 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Fri, 11 Sep 2026 20:58:33 +0800 Subject: [PATCH 03/19] fix(desktop): compact WorkHub labels beside each message Generated-by: Codex --- apps/desktop/src/renderer/styles/workhub.css | 22 +++++++++++++++++--- apps/desktop/stories/workhub.stories.tsx | 2 ++ packages/ui/src/chat-turn.tsx | 5 +++++ packages/ui/src/chat-view.tsx | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 8a927236e5..864e673a47 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -316,15 +316,18 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after { flex-wrap: wrap; justify-content: flex-end; gap: 4px; - margin-bottom: 4px; + margin-bottom: 0; } .workhub-turn-label { max-width: 100%; height: auto; - min-height: 28px; + min-height: 18px; + padding: 0; color: var(--workhub-work-label); - font-size: var(--font-size-body-xs); + font-size: 11px; + font-weight: 400; + line-height: 16px; white-space: normal; overflow-wrap: anywhere; text-align: right; @@ -333,3 +336,16 @@ body:has(.workHubLive[data-placement='floating'][data-progress='true'])::after { .workhub-navigation-workspace { color: var(--workhub-work-label); } + +.workhub-surface .maka-user-message :has(> .workhub-turn-heading) { + row-gap: 4px; +} + +.maka-assistant-answer .workhub-turn-heading { + justify-content: flex-start; + margin-bottom: -4px; +} + +.maka-assistant-answer .workhub-turn-label { + text-align: left; +} diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 682fdef22a..a67884ee70 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -168,6 +168,8 @@ export const ColoredWorkHistory: Story = { expect(turns[1]!.querySelector('.workhub-turn-label')).toHaveTextContent('desktop / 发布检查清单'); expect(canvasElement.querySelector('[data-transcript-turn-id="unlinked-turn"]')).not.toHaveAttribute('data-turn-accent'); for (const turn of turns) { + expect(turn.querySelectorAll('.workhub-turn-label')).toHaveLength(2); + expect(getComputedStyle(turn.querySelector('.workhub-turn-label')!).fontSize).toBe('11px'); const prompt = getComputedStyle(turn.querySelector('.maka-user-message')!); const answer = getComputedStyle(turn.querySelector('.maka-assistant-answer')!); expect(prompt.borderRightWidth).toBe('3px'); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 1a70f9d461..5f13dd6ca5 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -368,6 +368,8 @@ function MessageCopyButton(props: { */ export const TurnView = memo(function TurnView(props: { turn: TurnViewModel; + /** Optional identity repeated beside each prompt and answer in this turn. */ + messageHeader?: ReactNode; transientMessages?: readonly TransientUserMessageProjection[]; userLabel?: string; /** @@ -560,6 +562,7 @@ export const TurnView = memo(function TurnView(props: { sender="user" className="maka-chat-message maka-user-message" > + {props.messageHeader} + {props.messageHeader}
+ {props.messageHeader} {/* The turn timeline is the rendering source of truth (materialize.ts): each step's 深度思考 disclosure, answer bubble, and Astryx tool group in the order the model produced them. diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index dda5a653e7..c7abe7dd51 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -869,9 +869,9 @@ export function ChatView(props: { data-turn-accent={decoration?.accentColor ? 'true' : undefined} style={decoration?.accentColor ? { '--maka-turn-accent': decoration.accentColor } as CSSProperties : undefined} > - {decoration?.header} Date: Fri, 11 Sep 2026 21:20:15 +0800 Subject: [PATCH 04/19] feat(desktop): show WorkHub status beside prompt timestamps Generated-by: Codex --- .../__tests__/workhub-anchor-rail.test.ts | 13 ++--- .../src/renderer/features/workhub/index.ts | 1 - .../workhub/locales/workhub-live-copy.ts | 6 +-- .../src/renderer/features/workhub/testing.ts | 2 + .../workhub/ui/workhub-conversation.tsx | 47 ++++------------- apps/desktop/src/renderer/styles/workhub.css | 51 +------------------ apps/desktop/stories/workhub.stories.tsx | 18 ++++--- .../chat-view-empty-compaction.test.tsx | 5 +- packages/ui/src/chat-turn.tsx | 27 ++++++---- packages/ui/src/chat-view.tsx | 3 +- packages/ui/src/styles.css | 2 + 11 files changed, 56 insertions(+), 119 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index 7b50069280..5c681424ec 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -27,12 +27,11 @@ import { matchesWorkHubFilter, MAX_WORKHUB_ANCHORS, WorkHubNavigationRail, - WorkHubResultCard, workHubLinkedWork, workHubTurnResultPreview, } from "../../renderer/features/workhub/index.js"; import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; -import { WorkHubConversation } from '../../renderer/features/workhub/ui/workhub-conversation.js'; +import { WorkHubConversation, WorkHubDelegationStatus } from '../../renderer/features/workhub/testing.js'; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; @@ -55,9 +54,9 @@ test('durable task results restore Host-scoped work links without treating faile ], [], 'Work'), []); }); -test('a completed delegation returns its bounded result in the WorkHub conversation', () => { +test('a completed delegation renders only its status beside the prompt timestamp', () => { const target = JSON.stringify(['host-a', 'task-a']); - const markup = renderToStaticMarkup(createElement(WorkHubResultCard, { + const markup = renderToStaticMarkup(createElement(WorkHubDelegationStatus, { work: { id: 'delegation-record', coordinationTurnId: 'coordination-turn', @@ -69,14 +68,10 @@ test('a completed delegation returns its bounded result in the WorkHub conversat resultPreview: 'All release checks passed. The report is ready.', }, locale: 'en', - highlighted: false, - onHighlight: () => undefined, - onOpenWork: () => undefined, })); assert.match(markup, /Completed/u); - assert.match(markup, /All release checks passed\. The report is ready\./u); - assert.match(markup, /Open result/u); + assert.doesNotMatch(markup, /All release checks|Open result/u); }); test('delegated result previews select the exact Turn and stay character-bounded', () => { diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index c668aa256b..95f98a27e9 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -24,7 +24,6 @@ export { } from './model/linked-work.js'; export { projectWorkHubDelegationState, workHubTurnResultPreview } from './model/delegation-feedback.js'; export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js'; -export { WorkHubResultCard } from './ui/workhub-conversation.js'; export type { WorkHubServices, WorkHubTranscriptSnapshot } from './ports.js'; export { WorkHubServicesProvider } from './services.js'; export { WorkHubRoot } from './ui/workhub-root.js'; diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 04ed11d84f..39934aa2cf 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -42,7 +42,7 @@ export const workHubLiveCopy = { hint: 'Ask a question, manage your tasks, or ask me to work in Maka.', floating: 'WorkHub is in a floating window', restore: 'Bring WorkHub back', - delegationAccepted: 'Accepted', delegationRunning: 'Running', delegationWaiting: 'Waiting for you', + delegationAccepted: 'Accepted', delegationRunning: 'Running', delegationWaiting: 'Waiting for user', delegationCompleted: 'Completed', delegationFailed: 'Failed', delegationAborted: 'Aborted', delegationRecovering: 'Recovering', openWork: 'Open task', openResult: 'Open result', }, @@ -69,7 +69,7 @@ export const workHubLiveCopy = { hint: '问个问题、管理任务,或让我帮你操作 Maka。', floating: '工作台已在浮窗中打开', restore: '收回工作台', - delegationAccepted: '已接收', delegationRunning: '进行中', delegationWaiting: '等待你', + delegationAccepted: '已接收', delegationRunning: '进行中', delegationWaiting: '等待用户', delegationCompleted: '已完成', delegationFailed: '失败', delegationAborted: '已中止', delegationRecovering: '正在恢复', openWork: '打开任务', openResult: '打开结果', }, @@ -96,7 +96,7 @@ export const workHubLiveCopy = { hint: '問個問題、管理任務,或讓我幫你操作 Maka。', floating: '工作台已在浮動視窗中開啟', restore: '收回工作台', - delegationAccepted: '已接收', delegationRunning: '進行中', delegationWaiting: '等待你', + delegationAccepted: '已接收', delegationRunning: '進行中', delegationWaiting: '等待使用者', delegationCompleted: '已完成', delegationFailed: '失敗', delegationAborted: '已中止', delegationRecovering: '正在恢復', openWork: '開啟任務', openResult: '開啟結果', }, diff --git a/apps/desktop/src/renderer/features/workhub/testing.ts b/apps/desktop/src/renderer/features/workhub/testing.ts index f313ca82de..c31289be98 100644 --- a/apps/desktop/src/renderer/features/workhub/testing.ts +++ b/apps/desktop/src/renderer/features/workhub/testing.ts @@ -18,3 +18,5 @@ */ export { useWorkHubController } from './controller/use-workhub-controller.js'; + +export { WorkHubConversation, WorkHubDelegationStatus } from './ui/workhub-conversation.js'; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index 065afe9963..a5191f0f9e 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -25,12 +25,10 @@ import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-iden import type { WorkHubLinkedWork } from '../model/linked-work.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; -export function WorkHubResultCard(props: { +export function WorkHubDelegationStatus(props: { work: WorkHubLinkedWork; locale: UiLocale; - highlighted: boolean; - onHighlight(highlighted: boolean): void; - onOpenWork(sessionId: string): void; + showName?: boolean; }) { const { work } = props; const copy = workHubLiveCopy[props.locale]; @@ -44,28 +42,9 @@ export function WorkHubResultCard(props: { aborted: copy.delegationAborted, recovering: copy.delegationRecovering, }[state]; - return
props.onHighlight(true)} - onMouseLeave={() => props.onHighlight(false)} - onFocus={() => props.onHighlight(true)} - onBlur={() => props.onHighlight(false)}> -
-
- {work.workspaceName ? `${work.workspaceName} / ` : ''}{work.targetSessionName} - {stateLabel} -
- {work.resultPreview ?

{work.resultPreview}

: null} -
-
; + return + {props.showName ? `${work.targetSessionName}: ` : ''}{stateLabel} + ; } export function WorkHubConversation(props: ComponentProps & { workLinks: readonly WorkHubLinkedWork[]; onOpenWork(sessionId: string): void }) { @@ -91,6 +70,9 @@ export function WorkHubConversation(props: ComponentProps & { 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
+
+
+

{copy.title}

+

{props.request.candidates.length ? copy.hint : copy.empty}

+
+ ({ + 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 ` { + test('target selection pauses admission, binds the exact selected Session, and refreshes removed targets', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-selection-')); + const store = createSessionStore(root); + const admission = new SessionAdmissionGate(); + const decisions: import('@maka/core/workhub-routing').WorkHubRoutingDecision[] = []; + let modelCalls = 0; + let workhub: ReturnType; + const executions: CoordinationExecutions = { + isSessionExecutionIdle: () => true, + readActiveWorkHubRoutingRequest: async () => undefined, + startWorkHubCoordinationMessage: async (request) => + admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { + const prepared = await request.prepareFreshContent(lease); + if (prepared.kind === 'rejected') return prepared.outcome; + const decision = + request.execution.routingDecision ?? + (await workhub.prepareRoutingDecision({ + header: await store.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID), + turnId: request.turnId, + content: prepared.content, + allowTargetSelection: true, + })); + decisions.push(decision); + return { + ok: true, + result: { + sessionId: request.sessionId, + turnId: request.turnId, + runId: `run-${request.turnId}`, + status: 'running', + }, + }; + }), + }; + workhub = coordinator( + root, + store, + undefined, + undefined, + executions, + admission, + {}, + { + decide: async ({ resolveCandidates }) => { + modelCalls++; + await resolveCandidates(); + return { kind: 'routing', disposition: 'clarify' }; + }, + }, + ); + try { + await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT); + const target = await store.create({ + name: 'Payments', + cwd: root, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const input = { turnId: 'selection-turn', text: 'Continue the payment work' }; + const paused = await workhub.handlers['workhub.coordination.answer'](input, CONTEXT); + assert(paused.ok && paused.result.targetSelection); + const request = paused.result.targetSelection; + assert.equal(decisions.length, 0); + assert.equal(request.candidates.length, 1); + const selected = request.candidates.find((candidate) => candidate.sessionId === target.id)!; + await assert.rejects( + workhub.handlers['workhub.coordination.answer']( + { + ...input, + text: 'Different request', + selection: { + requestId: request.requestId, + kind: 'existing', + candidateRef: selected.candidateRef, + }, + }, + CONTEXT, + ), + ); + await assert.rejects( + workhub.handlers['workhub.coordination.answer']( + { + ...input, + selection: { requestId: request.requestId, kind: 'existing', candidateRef: 'forged' }, + }, + CONTEXT, + ), + ); + const resumed = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + selection: { + requestId: request.requestId, + kind: 'existing', + candidateRef: selected.candidateRef, + }, + }, + CONTEXT, + ); + assert(resumed.ok && !resumed.result.targetSelection); + assert.deepEqual(decisions, [ + { + kind: 'routing', + disposition: 'delegate_existing', + candidateSetId: request.candidateSetId, + candidateRef: selected.candidateRef, + }, + ]); + assert.equal(modelCalls, 1); + const missing = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + turnId: 'lost-draft', + selection: { + requestId: 'expired-request', + kind: 'existing', + candidateRef: selected.candidateRef, + }, + }, + CONTEXT, + ); + assert(missing.ok && missing.result.targetSelection); + assert.equal(decisions.length, 1); + await store.remove(target.id); + const removed = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + turnId: 'lost-draft', + selection: { + requestId: missing.result.targetSelection.requestId, + kind: 'existing', + candidateRef: missing.result.targetSelection.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + assert(removed.ok && removed.result.targetSelection); + assert.equal(removed.result.targetSelection.candidates.length, 0); + assert.equal(decisions.length, 1); + const created = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + turnId: 'lost-draft', + selection: { requestId: removed.result.targetSelection.requestId, kind: 'create_new' }, + }, + CONTEXT, + ); + assert(created.ok && !created.result.targetSelection); + assert.deepEqual(decisions[1], { kind: 'routing', disposition: 'create_new' }); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('reads bounded recent history when preparing a routing decision', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-routing-history-')); const store = createSessionStore(root); 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 e065170ecb..dcdfe3aa55 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -309,3 +309,30 @@ test('action outcomes cannot invent a target Turn or revive removed local dispos ]) assert.throws(() => decodeWorkHubCoordinationActResult(result)); }); + +test('WorkHub target selection accepts only the offered opaque identity shape', () => { + const input = { + turnId: 'turn', + text: 'Continue work', + selection: { requestId: 'request', kind: 'existing', candidateRef: 'candidate' }, + }; + assert.deepEqual(decodeWorkHubCoordinationAnswerInput(input), input); + assert.deepEqual( + decodeWorkHubCoordinationAnswerInput({ + ...input, + selection: { requestId: 'request', kind: 'create_new' }, + }).selection, + { requestId: 'request', kind: 'create_new' }, + ); + for (const selection of [ + { requestId: 'request', kind: 'existing', sessionId: 'forged' }, + { + requestId: 'request', + kind: 'existing', + candidateRef: 'candidate', + text: 'replacement input', + }, + { requestId: 'request', kind: 'create_new', candidateRef: 'candidate' }, + ]) + assert.throws(() => decodeWorkHubCoordinationAnswerInput({ ...input, selection })); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 07170cf8b1..f77009fa68 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,8 @@ 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 = 142 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 143 as const; +// 143: WorkHub answers can pause before admission for explicit target selection. // 142: Invocable Skill queries expose missing and archived Session refusals explicitly. // 141: WorkHub root admissions bind model Intent/Recall decisions before actions. // 140: Plugin Platform queries expose scoped Command contribution projections. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index b4e43c2513..c06b815a27 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -116,14 +116,25 @@ export interface WorkHubCoordinationResolveResult { readonly sessionId: string; } +export type WorkHubTargetSelection = + | { readonly requestId: string; readonly kind: 'existing'; readonly candidateRef: string } + | { readonly requestId: string; readonly kind: 'create_new' }; + +export interface WorkHubTargetSelectionRequest extends WorkHubCoordinationCandidatesResult { + readonly requestId: string; +} + export interface WorkHubCoordinationAnswerInput { readonly turnId: string; readonly text: string; readonly attachments?: AttachmentRef[]; + readonly selection?: WorkHubTargetSelection; } export interface WorkHubCoordinationTurnResult { readonly turnId: string; + /** No Turn was admitted: explicit target selection is required first. */ + readonly targetSelection?: WorkHubTargetSelectionRequest; } export type WorkHubCoordinationCandidateState = @@ -330,7 +341,7 @@ export function decodeWorkHubCoordinationAnswerInput( value, 'WorkHub Coordination answer input', ['turnId', 'text'], - ['attachments'], + ['attachments', 'selection'], ); return { ...(input.attachments !== undefined @@ -339,6 +350,9 @@ export function decodeWorkHubCoordinationAnswerInput( .attachments!, } : {}), + ...(input.selection !== undefined + ? { selection: decodeWorkHubTargetSelection(input.selection) } + : {}), turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'), text: requireUtf8String( input.text, @@ -348,10 +362,47 @@ export function decodeWorkHubCoordinationAnswerInput( }; } +function decodeWorkHubTargetSelection(value: unknown): WorkHubTargetSelection { + const record = requireRecord(value, 'WorkHub target selection'); + const input = requireExactRecord( + value, + 'WorkHub target selection', + record.kind === 'existing' ? ['requestId', 'kind', 'candidateRef'] : ['requestId', 'kind'], + ); + const requestId = requireEntityId(input.requestId, 'WorkHub target selection request id'); + if (input.kind === 'create_new') return { requestId, kind: 'create_new' }; + if (input.kind !== 'existing') + throw invalidProtocolFrame('Invalid WorkHub target selection kind'); + return { + requestId, + kind: 'existing', + candidateRef: requireEntityId(input.candidateRef, 'WorkHub candidate ref'), + }; +} + export function decodeWorkHubCoordinationTurnResult(value: unknown): WorkHubCoordinationTurnResult { - const result = requireExactRecord(value, 'WorkHub Coordination Turn result', ['turnId']); + const result = requireShapedRecord( + value, + 'WorkHub Coordination Turn result', + ['turnId'], + ['targetSelection'], + ); + const turnId = requireEntityId(result.turnId, 'WorkHub Coordination Turn id'); + if (result.targetSelection === undefined) return { turnId }; + const selection = requireExactRecord(result.targetSelection, 'WorkHub target selection request', [ + 'requestId', + 'candidateSetId', + 'candidates', + ]); return { - turnId: requireEntityId(result.turnId, 'WorkHub Coordination Turn id'), + turnId, + targetSelection: { + requestId: requireEntityId(selection.requestId, 'WorkHub target selection request id'), + ...decodeWorkHubCoordinationCandidatesResult({ + candidateSetId: selection.candidateSetId, + candidates: selection.candidates, + }), + }, }; } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f99fb3ff36..d862c1f051 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -235,6 +235,7 @@ export type RootMessageStartRequest = }); export interface HostWorkHubRoutingDecisionPreparation { + readonly allowTargetSelection?: boolean; readonly header: SessionHeader; readonly turnId: string; readonly content: MessageContent; @@ -1565,14 +1566,20 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { content: MessageContent, execution: RootExecutionDescriptor, inputClosedSignal?: AbortSignal, + allowTargetSelection = false, ): Promise { - if (execution.kind !== 'workhub_coordination' || !this.prepareWorkHubRoutingDecision) { + if ( + execution.kind !== 'workhub_coordination' || + execution.routingDecision || + !this.prepareWorkHubRoutingDecision + ) { return execution; } return { ...execution, routingDecision: await this.prepareWorkHubRoutingDecision({ header, + allowTargetSelection, turnId, content, ...(inputClosedSignal ? { inputClosedSignal } : {}), @@ -2086,6 +2093,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { ...(capabilityBinding ? { capabilityBinding } : {}), }, context.inputClosedSignal, + true, ); if (!this.beginRootAdmission(reservation)) { return completedStart(sessionBusy('Root Turn reservation is no longer current')); diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 8725ff5363..08e10238df 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -17,7 +17,7 @@ * under the License. */ -import { createHash } from 'node:crypto'; +import { randomUUID, createHash } from 'node:crypto'; import { mkdir } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; @@ -77,6 +77,14 @@ const CREATE_FINGERPRINT = `sha256:${createHash('sha256') const COORDINATION_CWD_DIRECTORY = 'workhub-coordination'; const COORDINATION_TOOL_PROFILE = 'workhub-coordination-v2' as const; const COORDINATION_PERMISSION_MODE = 'bypass' as const; +type TargetSelectionRequest = + import('../protocol/workhub-coordination.js').WorkHubTargetSelectionRequest; +class TargetSelectionRequired extends Error { + constructor(readonly request: TargetSelectionRequest) { + super('WorkHub target selection required'); + } +} + const COORDINATION_COLLABORATION_MODE = 'agent' as const; const COORDINATION_ORCHESTRATION_MODE = 'default' as const; const COORDINATION_SUMMARY_MESSAGE_KINDS = ['user', 'assistant', 'state'] as const; @@ -161,6 +169,12 @@ export interface HostWorkHubCoordinationCoordinatorOptions { /** Resolves the one durable Coordination Session owned by this Runtime Host. */ export class HostWorkHubCoordinationCoordinator { + // These are unadmitted drafts, not execution state. Loss/expiry asks again; + // admitted retries replay their durable routing decision before this cache is read. + readonly #targetSelections = new Map< + string, + { digest: string; request: TargetSelectionRequest; expiresAt: number } + >(); readonly handlers: WorkHubCoordinationOperationHandlerMap = { 'workhub.coordination.resolve': () => this.#resolve(), 'workhub.coordination.query': () => this.#query(), @@ -723,51 +737,79 @@ export class HostWorkHubCoordinationCoordinator { if (!input.text.trim()) { return turnFailure('operation_conflict', 'WorkHub answer text is empty'); } - const outcome = await this.#executions.startWorkHubCoordinationMessage( - { - sessionId: WORKHUB_COORDINATION_SESSION_ID, - turnId: input.turnId, - execution: { - kind: 'workhub_coordination', - inputDigest: digest({ - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - }), - }, - archivedMessage: 'WorkHub Coordination Session is unavailable', - // Historical v1 summaries still own their Turn identities. Reject a - // fresh answer that would reuse one, even though new summaries are no longer written. - prepareFreshContent: async () => { - let recorded: readonly StoredMessage[]; - try { - recorded = await this.#readSummaryMessages(input.turnId); - } catch { - return { - kind: 'rejected', - outcome: operationUnavailable( - 'WorkHub Coordination Turn identity could not be verified', - ), - }; - } - return recorded.length > 0 - ? { kind: 'rejected', outcome: turnIdentityConflict() } - : { - kind: 'ready', - content: normalizeMessageContent({ - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - }), + const execution: { + kind: 'workhub_coordination'; + inputDigest: `sha256:${string}`; + routingDecision?: WorkHubRoutingDecision; + } = { + kind: 'workhub_coordination', + inputDigest: digest({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.selection ? { selection: input.selection } : {}), + }), + }; + try { + const outcome = await this.#executions.startWorkHubCoordinationMessage( + { + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: input.turnId, + execution, + archivedMessage: 'WorkHub Coordination Session is unavailable', + // Historical v1 summaries still own their Turn identities. Reject a + // fresh answer that would reuse one, even though new summaries are no longer written. + prepareFreshContent: async () => { + let recorded: readonly StoredMessage[]; + try { + recorded = await this.#readSummaryMessages(input.turnId); + } catch { + return { + kind: 'rejected', + outcome: operationUnavailable( + 'WorkHub Coordination Turn identity could not be verified', + ), }; + } + if (recorded.length === 0 && input.selection) { + execution.routingDecision = await this.#selectedRoutingDecision(input); + } + return recorded.length > 0 + ? { kind: 'rejected', outcome: turnIdentityConflict() } + : { + kind: 'ready', + content: normalizeMessageContent({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + }), + }; + }, }, - }, - context, - ); - return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; + context, + ); + return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; + } catch (error) { + if (error instanceof TargetSelectionRequired) + return { ok: true, result: { turnId: input.turnId, targetSelection: error.request } }; + throw error; + } } async prepareRoutingDecision( input: HostWorkHubRoutingDecisionPreparation, ): Promise { + const contentDigest = digest(input.content); + const pending = this.#targetSelections.get(input.turnId); + if ( + input.allowTargetSelection && + pending && + pending.digest === contentDigest && + pending.expiresAt > Date.now() + ) + throw new TargetSelectionRequired(pending.request); + let candidates: + | import('../protocol/workhub-coordination.js').WorkHubCoordinationCandidatesResult + | undefined; + let decision: WorkHubRoutingDecision; try { if (!this.#routingModel) throw new Error('WorkHub routing model is unavailable'); const page = await this.#stores.readMessagesAfter(WORKHUB_COORDINATION_SESSION_ID, { @@ -783,7 +825,7 @@ export class HostWorkHubCoordinationCoordinator { : [], ) .slice(-8); - return await this.#routingModel.decide({ + decision = await this.#routingModel.decide({ turnId: input.turnId, header: input.header, userText: input.content.text, @@ -791,6 +833,7 @@ export class HostWorkHubCoordinationCoordinator { resolveCandidates: async () => { const outcome = await this.#candidates(); if (!outcome.ok) throw new Error(outcome.error.message); + candidates = outcome.result; const now = Date.now(); return { candidateSetId: outcome.result.candidateSetId, @@ -820,6 +863,79 @@ export class HostWorkHubCoordinationCoordinator { // silently become creation or bind an arbitrary existing Session. return { kind: 'routing', disposition: 'clarify' }; } + if ( + input.allowTargetSelection && + decision.kind === 'routing' && + decision.disposition === 'clarify' && + candidates + ) { + this.#requireTargetSelection(input.turnId, contentDigest, candidates); + } + return decision; + } + + #requireTargetSelection( + turnId: string, + contentDigest: string, + candidates: import('../protocol/workhub-coordination.js').WorkHubCoordinationCandidatesResult, + ): never { + for (const [id, entry] of this.#targetSelections) + if (entry.expiresAt <= Date.now()) this.#targetSelections.delete(id); + if (this.#targetSelections.size >= 64) + this.#targetSelections.delete(this.#targetSelections.keys().next().value!); + const request = { ...candidates, requestId: randomUUID() }; + this.#targetSelections.set(turnId, { + digest: contentDigest, + request, + expiresAt: Date.now() + 10 * 60_000, + }); + throw new TargetSelectionRequired(request); + } + + async #selectedRoutingDecision( + input: WorkHubCoordinationAnswerInput, + ): Promise { + const contentDigest = digest( + normalizeMessageContent({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + }), + ); + const pending = this.#targetSelections.get(input.turnId); + const current = await this.#candidates(); + if (!current.ok) throw new Error(current.error.message); + if ( + !pending || + pending.expiresAt <= Date.now() || + pending.request.requestId !== input.selection!.requestId + ) { + return this.#requireTargetSelection(input.turnId, contentDigest, current.result); + } + if (pending.digest !== contentDigest) + throw new Error('WorkHub selection belongs to a different request'); + if (input.selection!.kind === 'create_new') + return { kind: 'routing', disposition: 'create_new' }; + const choice = input.selection!; + if (choice.kind !== 'existing') throw new Error('Invalid WorkHub target selection'); + const selected = pending.request.candidates.find( + (candidate) => candidate.candidateRef === choice.candidateRef, + ); + if (!selected) throw new Error('WorkHub target is not in the offered candidates'); + // Rebind the same Session to the fresh candidate snapshot. Never substitute + // another Session when the selected one was removed, archived, or moved. + const candidate = current.result.candidates.find( + (item) => + item.sessionId === selected.sessionId && + item.workspace.hostCwd === selected.workspace.hostCwd, + ); + if (!candidate) + return this.#requireTargetSelection(input.turnId, contentDigest, current.result); + return { + kind: 'routing', + disposition: 'delegate_existing', + candidateSetId: current.result.candidateSetId, + candidateRef: candidate.candidateRef, + }; } /** Reads a historical v1 summary to preserve its durable Turn identity. */ diff --git a/packages/ui/src/choice-panel.tsx b/packages/ui/src/choice-panel.tsx new file mode 100644 index 0000000000..8c6980812f --- /dev/null +++ b/packages/ui/src/choice-panel.tsx @@ -0,0 +1,72 @@ +/* + * 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 { useEffect, useRef, type ReactNode, type CSSProperties, type KeyboardEvent } from 'react'; +import { RadioList, RadioListItem } from '@astryxdesign/core'; + +export interface ChoicePanelOption { + readonly value: string; + readonly label: string; + readonly description?: string; + readonly accentColor?: string; +} + +/** Shared single-choice interaction for questions and explicit target selection. */ +export function ChoicePanel(props: { + label: string; + options: readonly ChoicePanelOption[]; + value: string; + disabled?: boolean; + onChange(value: string): void; + onConfirm(): void; + onEscape(): void; + children?: ReactNode; +}) { + const root = useRef(null); + useEffect(() => { root.current?.focus(); }, []); + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.nativeEvent.isComposing || event.altKey || event.metaKey || event.ctrlKey || props.disabled) return; + const target = event.target as HTMLElement; + if (target.closest('input:not([type="radio"]), textarea, [contenteditable="true"]')) return; + const digit = /^[1-9]$/.test(event.key) ? Number(event.key) - 1 : -1; + const index = props.options.findIndex((option) => option.value === props.value); + if (digit >= 0 && digit < props.options.length) { + event.preventDefault(); props.onChange(props.options[digit]!.value); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (!props.options.length) return; + event.preventDefault(); + const next = index < 0 ? (event.key === 'ArrowDown' ? 0 : props.options.length - 1) + : (index + (event.key === 'ArrowDown' ? 1 : -1) + props.options.length) % props.options.length; + props.onChange(props.options[next]!.value); + } else if (event.key === 'Enter' && !target.closest('button, a')) { + event.preventDefault(); if (props.value) props.onConfirm(); + } else if (event.key === 'Escape') { + event.preventDefault(); props.onEscape(); + } + } + return
+ + {props.options.map((option, index) => + {props.children} +
; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 187a1084ae..b097e375c9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -190,3 +190,5 @@ export { } from '@astryxdesign/core'; export { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; + +export { ChoicePanel, type ChoicePanelOption } from './choice-panel.js'; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 964f3a30fd..68cbe92559 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1142,3 +1142,6 @@ } .maka-message-status-time { display: inline-flex; align-items: center; gap: 4px; } + +.maka-choice-panel { outline: none; } +.maka-choice-shortcut { color: var(--muted-foreground); font-size: 11px; font-weight: 400; } diff --git a/packages/ui/src/user-question-prompt.tsx b/packages/ui/src/user-question-prompt.tsx index a26c6b29c5..591c646419 100644 --- a/packages/ui/src/user-question-prompt.tsx +++ b/packages/ui/src/user-question-prompt.tsx @@ -20,7 +20,8 @@ import { useEffect, useId, useRef, useState } from 'react'; import type { UserQuestionRequestEvent } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; -import { Button, RadioList, RadioListItem, TextInput } from '@astryxdesign/core'; +import { Button, TextInput } from '@astryxdesign/core'; +import { ChoicePanel } from './choice-panel.js'; import { useMountedRef } from './use-mounted-ref.js'; import { buildUserQuestionResponse, @@ -100,32 +101,21 @@ export function UserQuestionPrompt(props: {

{question.question}

- {questionIndex + 1} / {props.request.questions.length} + {props.request.questions.length > 1 ? {questionIndex + 1} / {props.request.questions.length} : null}
- - {question.options.map((option, optionIndex) => ( - - ))} - - + onConfirm={() => { if (canContinue) { if (isLast) void submit(); else setQuestionIndex((current) => current + 1); } }} + onEscape={() => select('other')} + options={[...question.options.map((option, index) => ({ value: `option:${index}`, label: option.label, description: option.description })), { value: 'other', label: copy.other, description: copy.otherDescription }]} + /> {draft?.kind === 'other' ? (
Date: Fri, 11 Sep 2026 22:12:52 +0800 Subject: [PATCH 06/19] feat(workhub): complete prompt status and question interactions Generated-by: Codex --- .../__tests__/workhub-send-visibility.test.ts | 10 ++- .../controller/use-workhub-controller.ts | 51 +++++++++++++-- .../src/renderer/features/workhub/ports.ts | 3 + .../workhub/ui/workhub-conversation.tsx | 12 +++- .../features/workhub/ui/workhub-root.tsx | 20 ++++-- .../desktop/create-workhub-services.ts | 3 + apps/desktop/stories/workhub.stories.tsx | 63 +++++++++++++++++-- .../hosted-execution-tool-profile.test.ts | 3 +- .../workhub-coordination-coordinator.test.ts | 10 ++- .../server/hosted-execution-tool-profile.ts | 8 ++- .../workhub-coordination-coordinator.ts | 8 ++- packages/ui/src/chat-turn.tsx | 2 + packages/ui/src/chat-view.tsx | 1 + packages/ui/src/choice-panel.tsx | 6 ++ packages/ui/src/styles.css | 2 + packages/ui/src/user-question-prompt.tsx | 6 ++ 16 files changed, 182 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 67edc84ba1..5b8cc0671c 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -101,6 +101,9 @@ async function mountController(failFirstRead = false) { updateQueueEntry: async (...input: Parameters) => { queueMutations.push(['update', ...input]); }, reorderQueueEntries: async (...input: Parameters) => { queueMutations.push(['reorder', ...input]); }, enqueueMessage: async (...input: Parameters) => { steers.push(input); onSteer?.(input); return steerResult; }, + listActiveInteractions: async () => [], + subscribeActiveInteractions: () => () => {}, + respondToUserQuestion: async () => {}, answer: (_id: string, input: Parameters[1]) => invoke('workhub:answer', input), stop: async (target: string, turnId: string) => { const result = await invoke('sessions:stop', target, { source: 'stop_button', expectedTurnId: turnId }) as DesktopSessionStopResult; @@ -167,19 +170,20 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its h.latestRead.resolve(); }); -test('WorkHub removes a failed submission from the conversation and preserves its retry identity', async () => { +test('WorkHub marks a failed submission and preserves its retry identity', async () => { const h = await mountController(); let sent!: Promise; await act(async () => { sent = h.controller.send('retry this prompt', []); }); const turnId = h.requests[0]!.turnId; await act(async () => { h.admission.reject(new Error('admission rejected')); assert.equal(await sent, false); }); - assert.equal(h.controller.transientMessages.length, 0); + assert.equal(h.controller.transientMessages.length, 1); + assert.equal(h.controller.turnStates[turnId], 'failed'); assert.equal(h.controller.error, 'admission rejected'); assert.equal(h.controller.liveTurn, undefined, 'rejected admission retires the waiting feedback'); assert.equal(h.controller.busy, false); await act(async () => { assert.equal(await h.controller.send('retry this prompt', []), false); }); assert.equal(h.requests[1]!.turnId, turnId); - assert.equal(h.controller.transientMessages.length, 0); + assert.equal(h.controller.transientMessages.length, 1); h.latestRead.resolve(); }); diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 36da2d357c..802275ec4b 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from 'react'; import { + activeInteractionFor, reduceInteractionQueues, reconcileInteractions, clearInteractions, type InteractionQueues, applyLiveTurnEvent, armLiveTurn, createTranscriptViewportNavigation, @@ -68,6 +69,12 @@ export function useWorkHubController() { const [transientMessages, setTransientMessages] = useState([]); const [messageQueue, setMessageQueue] = useState<{ entries: import('@maka/core/events').MessageQueueEntryProjection[]; revision?: number }>({ entries: [] }); const [liveTurn, setLiveTurn] = useState(); + const refreshInteractions = useRef<() => void>(() => {}); + const interactionRevision = useRef(0); + const [interactions, setInteractions] = useState({}); + const [turnStates, setTurnStates] = useState>({}); + const activeInteraction = activeInteractionFor(interactions, sessionId); + const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const [targetSelection, setTargetSelection] = useState(); const [selectionSubmitting, setSelectionSubmitting] = useState(false); const resolveSelection = useRef<((selection: TargetSelection | undefined) => void) | undefined>(undefined); @@ -156,7 +163,8 @@ export function useWorkHubController() { attempt.stop = undefined; if (current) { setStopPending(false); - setTransientMessages((messages) => messages.filter((message) => message.hostTurnId !== attempt.input.turnId)); + if (attempt.selectionDismissed) setTransientMessages((messages) => messages.filter((message) => message.hostTurnId !== attempt.input.turnId)); + else setTurnStates((states) => ({ ...states, [attempt.input.turnId]: 'failed' })); setLiveTurn((previous) => previous?.turnId === attempt.input.turnId ? undefined : previous); setError(attempt.selectionDismissed ? undefined : workHubLiveCopy[localeRef.current].sendNotAdmitted); } @@ -278,6 +286,27 @@ export function useWorkHubController() { }] : []); }, [sessionId]); + useEffect(() => { + if (!sessionId) return; + let disposed = false; + setInteractions({}); + setTurnStates({}); + const unsubscribe = services.subscribeActiveInteractions((event) => { + if (event.sessionId !== sessionId || disposed) return; + interactionRevision.current++; + setInteractions((current) => reconcileInteractions(current, sessionId, event.interactions)); + }); + const refresh = () => { + const readRevision = ++interactionRevision.current; + void services.listActiveInteractions(sessionId).then((requests) => { + if (!disposed && interactionRevision.current === readRevision) setInteractions((current) => reconcileInteractions(current, sessionId, requests)); + }).catch((reason: unknown) => { if (!disposed) report(reason); }); + }; + refreshInteractions.current = refresh; + refresh(); + return () => { disposed = true; unsubscribe(); if (refreshInteractions.current === refresh) refreshInteractions.current = () => {}; }; + }, [services, sessionId]); + useEffect(() => { if (!sessionId) return; setMessageQueue({ entries: [] }); @@ -304,6 +333,10 @@ export function useWorkHubController() { sessionId, (event) => { if (disposed) return; + interactionRevision.current++; + const terminal = event.type === 'complete' || event.type === 'abort' || event.type === 'error'; + setInteractions((current) => terminal ? clearInteractions(current, sessionId) : reduceInteractionQueues(current, sessionId, event)); + if (terminal) setTurnStates((current) => Object.fromEntries([...Object.entries(current), [event.turnId, event.type === 'complete' ? 'completed' : event.type === 'abort' ? 'aborted' : 'failed']].slice(-64))); if (event.type === 'queue_update') { const entries = [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]; setMessageQueue({ entries: entries.filter((entry) => entry.state === 'queued'), revision: event.queueRevision }); @@ -347,7 +380,7 @@ export function useWorkHubController() { if (disposed) return; observationPhase = phase; handle?.observationChanged(phase); - if (phase === 'ready') void recoverSend(); + if (phase === 'ready') { refreshInteractions.current(); void recoverSend(); } }, ); const opening = services.openTranscript(sessionId, (snapshot) => { @@ -441,9 +474,11 @@ export function useWorkHubController() { input: { turnId: sameRejected ? previous.input.turnId : crypto.randomUUID(), text, ...(attachments.length ? { attachments: [...attachments] } : {}) }, admission: 'pending', }; + const pendingRejectedTurnId = previous?.admission === 'rejected' ? previous.input.turnId : undefined; pendingSend.current = attempt; + setTurnStates((states) => ({ ...states, [attempt.input.turnId]: 'running' })); setLiveTurn(armLiveTurn(attempt.input.turnId)); - setTransientMessages((previous) => [...previous.filter((message) => message.hostTurnId !== attempt.input.turnId), { + setTransientMessages((previous) => [...previous.filter((message) => message.hostTurnId !== attempt.input.turnId && message.hostTurnId !== pendingRejectedTurnId), { id: attempt.input.turnId, hostTurnId: attempt.input.turnId, text, ts: Date.now(), attachments: [...attachments], transientPlacement: 'current_turn', }]); @@ -467,7 +502,7 @@ export function useWorkHubController() { attempt.stop = undefined; setStopPending(false); } - setTransientMessages((previous) => previous.filter((message) => message.hostTurnId !== failedTurnId)); + if (failedTurnId && attempt?.admission === 'rejected') setTurnStates((states) => ({ ...states, [failedTurnId]: 'failed' })); setLiveTurn((previous) => previous?.turnId === failedTurnId && previous?.unconfirmed ? undefined : previous); report(reason); } @@ -537,6 +572,14 @@ export function useWorkHubController() { sessions, choices, transcript, + activeQuestion, + activeInteraction, + turnStates, + pendingTurnId: pendingSend.current?.sessionId === sessionId ? pendingSend.current?.input.turnId : undefined, + respondToUserQuestion: async (response: import('@maka/core/user-question').UserQuestionResponse) => { + if (!sessionId) throw new Error('WorkHub Session is unavailable'); + await services.respondToUserQuestion(sessionId, response); + }, transientMessages, messageQueue, updateQueuedEntry: (entryId: string, revision: number, text: string) => mutateQueue((target) => services.updateQueueEntry(target, entryId, revision, text)), diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index da55cde5e9..ce03ffbdab 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -64,6 +64,9 @@ export interface WorkHubServices { readonly attachments: ComposerAttachmentService; readAttachmentBytes(sessionId: string, artifactId: string): Promise; prepareAttachments(sessionId: string, items: Array<{ approvalId: string; name: string; mimeType?: string } | { file: File }>): Promise; + listActiveInteractions(sessionId: string): Promise; + subscribeActiveInteractions(handler: (event: { sessionId: string; interactions: import('@maka/core/events').ActiveInteractionRequestEvent[] }) => void): () => void; + respondToUserQuestion(sessionId: string, response: import('@maka/core/user-question').UserQuestionResponse): Promise; answer(sessionId: string, input: WorkHubAnswerInput): Promise; enqueueMessage(sessionId: string, messageId: string, text: string, attachments: AttachmentRef[], placement: MessageQueuePlacement): Promise<'admitted' | 'unknown' | 'rejected'>; retractQueueEntry(sessionId: string, entryId: string): Promise; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index a5191f0f9e..9081875c85 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -22,7 +22,7 @@ import { ChatView, useUiLocale } from '@maka/ui'; import type { UiLocale } from '@maka/core/ui-locale'; import { Button } from '@astryxdesign/core'; import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js'; -import type { WorkHubLinkedWork } from '../model/linked-work.js'; +import type { WorkHubDelegationState, WorkHubLinkedWork } from '../model/linked-work.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; export function WorkHubDelegationStatus(props: { @@ -47,8 +47,8 @@ export function WorkHubDelegationStatus(props: { ; } -export function WorkHubConversation(props: ComponentProps & { workLinks: readonly WorkHubLinkedWork[]; onOpenWork(sessionId: string): void }) { - const { onOpenWork, workLinks: assignments, ...chat } = props; +export function WorkHubConversation(props: ComponentProps & { workLinks: readonly WorkHubLinkedWork[]; onOpenWork(sessionId: string): void; promptStates?: ReadonlyMap }) { + const { onOpenWork, workLinks: assignments, promptStates, ...chat } = props; const highlight = useContext(WorkHubHighlightContext); const locale = useUiLocale(); // A coordination turn can delegate to several Works. Keep every label and @@ -90,6 +90,12 @@ export function WorkHubConversation(props: ComponentProps & { w />)}
, }])); + for (const [turnId, state] of promptStates ?? []) { + if (!turnDecorations.has(turnId)) turnDecorations.set(turnId, { + header: <>, accentColor: undefined, + promptStatus: , + }); + } return (null); const [editingProgressRequest, setEditingProgressRequest] = useState(); const [expandedOverride, setConversationExpanded] = useState(); + const promptStates = new Map(); + for (const message of transcript.messages) if (message.type === 'turn_state') promptStates.set(message.turnId, message.status); + for (const [turnId, state] of Object.entries(controller.turnStates)) promptStates.set(turnId, state); + if (controller.liveTurn && !controller.liveTurn.terminal) promptStates.set(controller.liveTurn.turnId, 'running'); + if (controller.pendingTurnId && controller.sending) promptStates.set(controller.pendingTurnId, controller.targetSelection ? 'waiting_for_user' : 'running'); + if (controller.activeInteraction) promptStates.set(controller.activeInteraction.turnId, 'waiting_for_user'); const hasConversation = transcript.messages.length > 0 || busy || Boolean(controller.liveTurn); const conversationExpanded = expandedOverride ?? hasConversation; const hasConversationRef = useRef(hasConversation); @@ -92,11 +98,11 @@ export function WorkHubRoot() { const editingProgress = progress && editingProgressRequest === presentation.progressRequest; const floating = presentation?.placement === 'floating'; useEffect(() => { - if (controller.targetSelection) { + if (controller.targetSelection || controller.activeQuestion) { setConversationExpanded(true); void services.presentation.showConversation(presentation?.progressRequest).catch(controller.report); } - }, [controller.targetSelection]); + }, [controller.targetSelection, controller.activeQuestion]); const showConversation = !progress && (!floating || conversationExpanded); useLayoutEffect(() => { const element = surface.current; @@ -274,7 +280,10 @@ export function WorkHubRoot() { request={controller.targetSelection} submitting={controller.selectionSubmitting} onChoose={controller.chooseTarget} onDismiss={() => { controller.dismissTargetSelection(); requestAnimationFrame(() => composer.current?.focus()); }} />} - - + ); } diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 491bd96de4..a781b3be07 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -388,3 +388,18 @@ export const WorkFilterHoverAndToggle: Story = { expect(writes.open).not.toHaveBeenCalled(); }, }; + +export const SendWhileWorkFiltered: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvasElement.querySelector('.workhub-message-rail')).not.toBeNull()); + await userEvent.click(canvasElement.querySelector('.workhub-message-rail') as HTMLElement); + const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement; + await userEvent.click(editor); + await userEvent.keyboard('FILTERED_SEND_PROBE{Enter}'); + await waitFor(() => expect(canvas.getByText('FILTERED_SEND_PROBE')).toBeInTheDocument()); + await waitFor(() => expect(canvas.getByText('已收到。')).toBeInTheDocument()); + expect(canvas.queryByRole('button', { name: '显示全部对话' })).toBeNull(); + }, +}; diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 63da53e25a..9bddda97cd 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -6149,7 +6149,7 @@ async function createFailureFixture(options: { ): Promise; prepareWorkHubRoutingDecision?( input: HostWorkHubRoutingDecisionPreparation, - ): Promise; + ): Promise; }) { const base = await mkdtemp(join(tmpdir(), 'maka-root-turn-message-failure-')); const capability = await resolveStorageRoot({ @@ -7609,3 +7609,171 @@ async function waitForContinuityFrame( description, ); } + +test('WorkHub target selection crosses root admission without draining the Host', async () => { + const { HostWorkHubCoordinationCoordinator } = await import( + '../server/workhub-coordination-coordinator.js' + ); + let workhub: InstanceType; + const capabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), + activation: new RuntimePolicyActivationGate(), + onModelToolsChanged: () => undefined, + }); + capabilities.attachConnection(clientCapabilityConnectionIdentity('desktop'), { + send: async () => {}, + }); + const fixture = await createFailureFixture({ + clientCapabilities: capabilities, + registerBackend: (backends) => + backends.register('ai-sdk', (context) => new FakeBackend(context)), + prepareWorkHubRoutingDecision: (input) => workhub.prepareRoutingDecision(input), + }); + try { + const context = operationContext(fixture.hostEpoch, fixture.acquireResidency, 'desktop'); + await capabilities.handlers['client.capability.replace']( + { + registrationId: 'workhub-tools', + offers: [ + { + offerId: 'desktop-workhub', + version: '0', + affinity: 'session', + hostPathAccess: 'none', + label: 'Desktop WorkHub', + tools: ['control', 'tasks'].map((name) => ({ + serverId: 'desktop_workhub', + name, + inputSchema: { type: 'object' }, + })), + }, + ], + }, + context, + ); + const ordinary = await fixture.stores.sessionStore.readHeaderSnapshot(fixture.sessionId); + await fixture.stores.sessionStore.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: ordinary.cwd, + llmConnectionId: ordinary.llmConnectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + role: WORKHUB_COORDINATION_SESSION_ROLE, + toolProfile: 'workhub-coordination-v2', + permissionMode: 'bypass', + }, + }); + workhub = new HostWorkHubCoordinationCoordinator({ + stateRoot: ordinary.cwd!, + stores: fixture.stores.sessionStore, + admission: fixture.sessionAdmission, + continuity: { refreshCanonical: async () => undefined }, + executions: fixture.coordinator, + routingModel: { + decide: async ({ resolveCandidates, userText }) => { + if (userText !== 'Unclear intent') await resolveCandidates(); + return { kind: 'routing', disposition: 'clarify' }; + }, + }, + sessionActions: { + assign: async () => ({ turnId: 'unused' }), + readDelegationRetirement: async () => 'not_retired', + retireDelegation: async () => ({ outcome: 'cancelled_pending' }), + resumeDelegation: async () => ({ outcome: 'already_running' }), + }, + resolveCreateTarget: async () => ({ + llmConnectionId: ordinary.llmConnectionId!, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'bypass', + }), + requestDrain: () => { + throw new Error('Unexpected coordinator drain'); + }, + transitionConfiguration: async () => { + throw new Error('Unused'); + }, + configureModel: async () => ({ + ok: false, + error: { code: 'operation_unavailable', message: 'Unused' }, + }), + }); + const input = { turnId: 'target-choice', text: 'Continue the work' }; + const paused = await workhub.handlers['workhub.coordination.answer'](input, context).catch( + (error: unknown) => error, + ); + assert.equal( + fixture.drainRequested(), + false, + 'a target choice must not drain the real root authority', + ); + assert( + paused && typeof paused === 'object' && 'ok' in paused && paused.ok && 'result' in paused, + ); + const request = ( + paused as { + result: { + targetSelection: import('../protocol/workhub-coordination.js').WorkHubTargetSelectionRequest; + }; + } + ).result.targetSelection; + assert(request); + assert.equal( + await fixture.stores.agentRunStore.readRootTurnAdmission( + WORKHUB_COORDINATION_SESSION_ID, + input.turnId, + ), + undefined, + ); + const invalid = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + selection: { requestId: request.requestId, kind: 'existing', candidateRef: 'forged' }, + }, + context, + ); + assert(!invalid.ok && invalid.error.code === 'operation_conflict'); + assert.equal(fixture.drainRequested(), false); + const resumed = await workhub.handlers['workhub.coordination.answer']( + { + ...input, + selection: { + requestId: request.requestId, + kind: 'existing', + candidateRef: request.candidates[0]!.candidateRef, + }, + }, + context, + ); + assert(resumed.ok && !resumed.result.targetSelection); + const admitted = await fixture.stores.agentRunStore.readRootTurnAdmission( + WORKHUB_COORDINATION_SESSION_ID, + input.turnId, + ); + assert(admitted?.execution.kind === 'workhub_coordination'); + assert.deepEqual(admitted.execution.routingDecision, { + kind: 'routing', + disposition: 'delegate_existing', + candidateSetId: request.candidateSetId, + candidateRef: request.candidates[0]!.candidateRef, + }); + await fixture.coordinator.whenIdle(WORKHUB_COORDINATION_SESSION_ID); + const unclear = await workhub.handlers['workhub.coordination.answer']( + { turnId: 'unclear-intent', text: 'Unclear intent' }, + context, + ); + assert( + unclear.ok && !unclear.result.targetSelection, + 'intent ambiguity must reach the assistant', + ); + await fixture.coordinator.whenIdle(WORKHUB_COORDINATION_SESSION_ID); + assert.equal(fixture.drainRequested(), false); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await capabilities.close(); + await fixture.dispose(); + } +}); diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 76629b2070..a5dfac8248 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -87,6 +87,12 @@ describe('Host WorkHub Coordination coordinator', () => { content: prepared.content, allowTargetSelection: true, })); + if (decision.kind === 'target_selection') + return { + ok: false, + error: { code: 'operation_conflict', message: 'WorkHub target selection required' }, + targetSelection: decision.request, + }; decisions.push(decision); return { ok: true, @@ -131,28 +137,34 @@ describe('Host WorkHub Coordination coordinator', () => { assert.equal(decisions.length, 0); assert.equal(request.candidates.length, 1); const selected = request.candidates.find((candidate) => candidate.sessionId === target.id)!; - await assert.rejects( - workhub.handlers['workhub.coordination.answer']( - { - ...input, - text: 'Different request', - selection: { - requestId: request.requestId, - kind: 'existing', - candidateRef: selected.candidateRef, + assert.equal( + ( + await workhub.handlers['workhub.coordination.answer']( + { + ...input, + text: 'Different request', + selection: { + requestId: request.requestId, + kind: 'existing', + candidateRef: selected.candidateRef, + }, }, - }, - CONTEXT, - ), + CONTEXT, + ) + ).ok, + false, ); - await assert.rejects( - workhub.handlers['workhub.coordination.answer']( - { - ...input, - selection: { requestId: request.requestId, kind: 'existing', candidateRef: 'forged' }, - }, - CONTEXT, - ), + assert.equal( + ( + await workhub.handlers['workhub.coordination.answer']( + { + ...input, + selection: { requestId: request.requestId, kind: 'existing', candidateRef: 'forged' }, + }, + CONTEXT, + ) + ).ok, + false, ); const resumed = await workhub.handlers['workhub.coordination.answer']( { @@ -219,8 +231,8 @@ describe('Host WorkHub Coordination coordinator', () => { { turnId: 'unclear-intent', text: 'Unclear intent' }, CONTEXT, ); - assert(unclear.ok && unclear.result.targetSelection); - assert.equal(decisions.length, 2, 'intent clarification also waits before admission'); + assert(unclear.ok && !unclear.result.targetSelection); + assert.equal(decisions.length, 3, 'intent clarification must reach the assistant'); } finally { await store.close?.(); await rm(root, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index d862c1f051..8969810db2 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -192,10 +192,20 @@ export interface RootHandoffPreparation { cancel(): void; } +import type { WorkHubTargetSelectionRequest } from '../protocol/workhub-coordination.js'; + +export type HostWorkHubTargetSelection = { + readonly kind: 'target_selection'; + readonly request: WorkHubTargetSelectionRequest; +}; +export type HostWorkHubRoutingPreparation = WorkHubRoutingDecision | HostWorkHubTargetSelection; + export type TurnStartOutcome = OperationOutcome<'turn.start'>; type RootMessageStartOutcome = | { ok: true; result: TurnSnapshot } - | Extract; + | (Extract & { + readonly targetSelection?: WorkHubTargetSelectionRequest; + }); export type RootMessageExecution = Extract< RootExecutionDescriptor, @@ -381,7 +391,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private readonly directoryHostId?: string, private readonly prepareWorkHubRoutingDecision?: ( input: HostWorkHubRoutingDecisionPreparation, - ) => Promise, + ) => Promise, ) { this.stores = authenticateExecutionStoresWriter(stores, 'interactive'); this.executionProjection = new HostedExecutionProjectionReader(this.stores); @@ -1560,6 +1570,22 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } + private prepareFreshWorkHubExecution( + header: SessionHeader, + turnId: string, + content: MessageContent, + execution: RootExecutionDescriptor, + inputClosedSignal?: AbortSignal, + allowTargetSelection?: false, + ): Promise; + private prepareFreshWorkHubExecution( + header: SessionHeader, + turnId: string, + content: MessageContent, + execution: RootExecutionDescriptor, + inputClosedSignal: AbortSignal | undefined, + allowTargetSelection: true, + ): Promise; private async prepareFreshWorkHubExecution( header: SessionHeader, turnId: string, @@ -1567,7 +1593,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { execution: RootExecutionDescriptor, inputClosedSignal?: AbortSignal, allowTargetSelection = false, - ): Promise { + ): Promise { if ( execution.kind !== 'workhub_coordination' || execution.routingDecision || @@ -1575,15 +1601,20 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { ) { return execution; } + const prepared = await this.prepareWorkHubRoutingDecision({ + header, + allowTargetSelection, + turnId, + content, + ...(inputClosedSignal ? { inputClosedSignal } : {}), + }); + if (prepared.kind === 'target_selection' && allowTargetSelection) return prepared; return { ...execution, - routingDecision: await this.prepareWorkHubRoutingDecision({ - header, - allowTargetSelection, - turnId, - content, - ...(inputClosedSignal ? { inputClosedSignal } : {}), - }), + routingDecision: + prepared.kind === 'target_selection' + ? { kind: 'routing', disposition: 'clarify' } + : prepared, }; } @@ -2095,6 +2126,12 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { context.inputClosedSignal, true, ); + if (freshExecution.kind === 'target_selection') { + return completedStart({ + ...operationConflict('WorkHub target selection required'), + targetSelection: freshExecution.request, + }); + } if (!this.beginRootAdmission(reservation)) { return completedStart(sessionBusy('Root Turn reservation is no longer current')); } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 1a0996fb48..0c6d21aec8 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -55,6 +55,8 @@ import type { } from './operation-dispatcher.js'; import type { HostWorkHubRoutingDecisionPreparation, + HostWorkHubRoutingPreparation, + HostWorkHubTargetSelection, RootTurnCoordinator, } from './root-turn-coordinator.js'; import type { HostWorkHubRoutingModel } from './execution-model-authority.js'; @@ -79,11 +81,6 @@ const COORDINATION_TOOL_PROFILE = 'workhub-coordination-v2' as const; const COORDINATION_PERMISSION_MODE = 'bypass' as const; type TargetSelectionRequest = import('../protocol/workhub-coordination.js').WorkHubTargetSelectionRequest; -class TargetSelectionRequired extends Error { - constructor(readonly request: TargetSelectionRequest) { - super('WorkHub target selection required'); - } -} const COORDINATION_COLLABORATION_MODE = 'agent' as const; const COORDINATION_ORCHESTRATION_MODE = 'default' as const; @@ -749,54 +746,63 @@ export class HostWorkHubCoordinationCoordinator { ...(input.selection ? { selection: input.selection } : {}), }), }; - try { - const outcome = await this.#executions.startWorkHubCoordinationMessage( - { - sessionId: WORKHUB_COORDINATION_SESSION_ID, - turnId: input.turnId, - execution, - archivedMessage: 'WorkHub Coordination Session is unavailable', - // Historical v1 summaries still own their Turn identities. Reject a - // fresh answer that would reuse one, even though new summaries are no longer written. - prepareFreshContent: async () => { - let recorded: readonly StoredMessage[]; - try { - recorded = await this.#readSummaryMessages(input.turnId); - } catch { + const outcome = await this.#executions.startWorkHubCoordinationMessage( + { + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: input.turnId, + execution, + archivedMessage: 'WorkHub Coordination Session is unavailable', + // Historical v1 summaries still own their Turn identities. Reject a + // fresh answer that would reuse one, even though new summaries are no longer written. + prepareFreshContent: async () => { + let recorded: readonly StoredMessage[]; + try { + recorded = await this.#readSummaryMessages(input.turnId); + } catch { + return { + kind: 'rejected', + outcome: operationUnavailable( + 'WorkHub Coordination Turn identity could not be verified', + ), + }; + } + if (recorded.length === 0 && input.selection) { + const selected = await this.#selectedRoutingDecision(input); + if (selected.kind === 'rejected') return selected; + if (selected.kind === 'target_selection') return { kind: 'rejected', - outcome: operationUnavailable( - 'WorkHub Coordination Turn identity could not be verified', - ), + outcome: { + ...turnFailure('operation_conflict', 'WorkHub target selection required'), + targetSelection: selected.request, + }, + }; + execution.routingDecision = selected; + } + return recorded.length > 0 + ? { kind: 'rejected', outcome: turnIdentityConflict() } + : { + kind: 'ready', + content: normalizeMessageContent({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + }), }; - } - if (recorded.length === 0 && input.selection) { - execution.routingDecision = await this.#selectedRoutingDecision(input); - } - return recorded.length > 0 - ? { kind: 'rejected', outcome: turnIdentityConflict() } - : { - kind: 'ready', - content: normalizeMessageContent({ - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - }), - }; - }, }, - context, - ); - return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; - } catch (error) { - if (error instanceof TargetSelectionRequired) - return { ok: true, result: { turnId: input.turnId, targetSelection: error.request } }; - throw error; - } + }, + context, + ); + if (!outcome.ok && outcome.targetSelection) + return { + ok: true, + result: { turnId: input.turnId, targetSelection: outcome.targetSelection }, + }; + return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; } async prepareRoutingDecision( input: HostWorkHubRoutingDecisionPreparation, - ): Promise { + ): Promise { const contentDigest = digest(input.content); const pending = this.#targetSelections.get(input.turnId); if ( @@ -805,7 +811,7 @@ export class HostWorkHubCoordinationCoordinator { pending.digest === contentDigest && pending.expiresAt > Date.now() ) - throw new TargetSelectionRequired(pending.request); + return { kind: 'target_selection', request: pending.request }; let candidates: | import('../protocol/workhub-coordination.js').WorkHubCoordinationCandidatesResult | undefined; @@ -866,14 +872,12 @@ export class HostWorkHubCoordinationCoordinator { if ( input.allowTargetSelection && decision.kind === 'routing' && - decision.disposition === 'clarify' + decision.disposition === 'clarify' && + candidates !== undefined ) { - if (!candidates) { - const outcome = await this.#candidates(); - if (!outcome.ok) return decision; - candidates = outcome.result; - } - this.#requireTargetSelection(input.turnId, contentDigest, candidates); + // The production model resolves candidates only for execute/continue. + // Intent-only clarification must be admitted so the assistant can ask. + return this.#requireTargetSelection(input.turnId, contentDigest, candidates); } return decision; } @@ -882,7 +886,7 @@ export class HostWorkHubCoordinationCoordinator { turnId: string, contentDigest: string, candidates: import('../protocol/workhub-coordination.js').WorkHubCoordinationCandidatesResult, - ): never { + ): HostWorkHubTargetSelection { for (const [id, entry] of this.#targetSelections) if (entry.expiresAt <= Date.now()) this.#targetSelections.delete(id); if (this.#targetSelections.size >= 64) @@ -893,12 +897,19 @@ export class HostWorkHubCoordinationCoordinator { request, expiresAt: Date.now() + 10 * 60_000, }); - throw new TargetSelectionRequired(request); + return { kind: 'target_selection', request }; } async #selectedRoutingDecision( input: WorkHubCoordinationAnswerInput, - ): Promise { + ): Promise< + | HostWorkHubRoutingPreparation + | { kind: 'rejected'; outcome: Extract, { ok: false }> } + > { + const reject = (message: string) => ({ + kind: 'rejected' as const, + outcome: turnFailure('operation_conflict', message), + }); const contentDigest = digest( normalizeMessageContent({ text: input.text, @@ -907,7 +918,8 @@ export class HostWorkHubCoordinationCoordinator { ); const pending = this.#targetSelections.get(input.turnId); const current = await this.#candidates(); - if (!current.ok) throw new Error(current.error.message); + if (!current.ok) + return { kind: 'rejected', outcome: operationUnavailable(current.error.message) }; if ( !pending || pending.expiresAt <= Date.now() || @@ -916,15 +928,15 @@ export class HostWorkHubCoordinationCoordinator { return this.#requireTargetSelection(input.turnId, contentDigest, current.result); } if (pending.digest !== contentDigest) - throw new Error('WorkHub selection belongs to a different request'); + return reject('WorkHub selection belongs to a different request'); if (input.selection!.kind === 'create_new') return { kind: 'routing', disposition: 'create_new' }; const choice = input.selection!; - if (choice.kind !== 'existing') throw new Error('Invalid WorkHub target selection'); + if (choice.kind !== 'existing') return reject('Invalid WorkHub target selection'); const selected = pending.request.candidates.find( (candidate) => candidate.candidateRef === choice.candidateRef, ); - if (!selected) throw new Error('WorkHub target is not in the offered candidates'); + if (!selected) return reject('WorkHub target is not in the offered candidates'); // Rebind the same Session to the fresh candidate snapshot. Never substitute // another Session when the selected one was removed, archived, or moved. const candidate = current.result.candidates.find( @@ -1141,12 +1153,11 @@ function operationUnavailable(message: string) { return { ok: false, error: { code: 'operation_unavailable', message } } as const; } -function turnFailure( - code: Extract< +function turnFailure< + Code extends Extract< OperationOutcome<'workhub.coordination.answer'>, { readonly ok: false } >['error']['code'], - message: string, -): OperationOutcome<'workhub.coordination.answer'> { +>(code: Code, message: string): { ok: false; error: { code: Code; message: string } } { return { ok: false, error: { code, message } }; } From 08530964ebf4d9913413f8e1c960439b027ea218 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 12 Sep 2026 10:01:08 +0800 Subject: [PATCH 13/19] fix(workhub): address verified review follow-ups Preserve the chat view across filtering, require history navigation in the paged story, configure its narrow counterpart, and await keyboard state. Reuse Astryx text/link/keycap primitives and shared identity/path values while retaining the approved compact labels and filter gestures. Generated-by: Codex --- .../__tests__/workhub-anchor-rail.test.ts | 9 +++ .../features/workhub/model/linked-work.ts | 4 +- .../features/workhub/model/workspace-name.ts | 24 +++++++ .../src/renderer/features/workhub/testing.ts | 3 + .../workhub/ui/workhub-conversation.tsx | 17 +++-- .../workhub/ui/workhub-target-selector.tsx | 9 +-- apps/desktop/src/renderer/styles/workhub.css | 26 ++++---- .../stories/ask-user-question.stories.tsx | 4 +- apps/desktop/stories/workhub.stories.tsx | 66 +++++++++++-------- .../workhub-coordination-coordinator.ts | 12 ++-- packages/ui/src/choice-panel.tsx | 6 +- packages/ui/src/styles.css | 3 +- 12 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workhub/model/workspace-name.ts diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index 5c681424ec..aed1b09cc3 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -201,3 +201,12 @@ test('a shared coordination turn keeps every Work label without assigning one Wo assert.match(markup, /Workspace \/ Beta/); assert.doesNotMatch(markup, /data-turn-accent/); }); + + +test('WorkHub workspace display names handle Host paths independently of renderer platform', async () => { + const { workspaceNameFromCwd } = await import('../../renderer/features/workhub/testing.js'); + assert.equal(workspaceNameFromCwd('/projects/maka/'), 'maka'); + assert.equal(workspaceNameFromCwd('C:\\projects\\maka\\'), 'maka'); + assert.equal(workspaceNameFromCwd(undefined), undefined); + assert.equal(workspaceNameFromCwd('/'), undefined); +}); diff --git a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts index 829cfe9145..2b97a04075 100644 --- a/apps/desktop/src/renderer/features/workhub/model/linked-work.ts +++ b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts @@ -18,6 +18,8 @@ */ +import { workspaceNameFromCwd } from './workspace-name.js'; + import type { StoredMessage } from '@maka/core/session'; export type WorkHubDelegationState = @@ -61,7 +63,7 @@ export function workHubLinkedWork( fallbackName: string, ): WorkHubLinkedWork[] { const sessionById = new Map(sessions.map((session) => [session.id, session])); - const workspaceName = (id: string) => sessionById.get(id)?.cwd?.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) || undefined; + const workspaceName = (id: string) => workspaceNameFromCwd(sessionById.get(id)?.cwd); const taskCalls = new Set(messages.flatMap((message) => message.type === 'tool_call' && message.toolName === 'mcp__desktop_workhub__tasks' ? [message.id] : [], )); diff --git a/apps/desktop/src/renderer/features/workhub/model/workspace-name.ts b/apps/desktop/src/renderer/features/workhub/model/workspace-name.ts new file mode 100644 index 0000000000..bf493e8821 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/model/workspace-name.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + + +/** Display basename for a Host path, independent of the renderer platform. */ +export function workspaceNameFromCwd(cwd: string | undefined): string | undefined { + return cwd?.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) || undefined; +} diff --git a/apps/desktop/src/renderer/features/workhub/testing.ts b/apps/desktop/src/renderer/features/workhub/testing.ts index c31289be98..6229bcd9aa 100644 --- a/apps/desktop/src/renderer/features/workhub/testing.ts +++ b/apps/desktop/src/renderer/features/workhub/testing.ts @@ -20,3 +20,6 @@ export { useWorkHubController } from './controller/use-workhub-controller.js'; export { WorkHubConversation, WorkHubDelegationStatus } from './ui/workhub-conversation.js'; + +export { WorkHubHighlightContext } from './ui/workhub-work-identity.js'; +export { workspaceNameFromCwd } from './model/workspace-name.js'; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index 467b28f69b..724d247bb4 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -20,7 +20,7 @@ import { useContext, useMemo, useState, type ComponentProps, type CSSProperties } from 'react'; import { ChatView, useUiLocale } from '@maka/ui'; import type { UiLocale } from '@maka/core/ui-locale'; -import { Button } from '@astryxdesign/core'; +import { Button, Link, Text } from '@astryxdesign/core'; import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js'; import type { WorkHubDelegationState, WorkHubLinkedWork } from '../model/linked-work.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; @@ -42,9 +42,9 @@ export function WorkHubDelegationStatus(props: { aborted: copy.delegationAborted, recovering: copy.delegationRecovering, }[state]; - return + return {props.showName ? `${work.targetSessionName}: ` : ''}{stateLabel} - ; + ; } export function WorkHubConversation(props: ComponentProps & { workLinks: readonly WorkHubLinkedWork[]; onOpenWork(sessionId: string): void; promptStates?: ReadonlyMap }) { @@ -97,21 +97,20 @@ export function WorkHubConversation(props: ComponentProps & { w onClick={() => highlight.toggleWork({ sessionId: works[0]!.targetSessionId, name: works[0]!.targetSessionName })} /> : undefined, header:
- {works.map((work) =>
, }])); for (const [turnId, state] of promptStates ?? []) { @@ -126,13 +125,13 @@ export function WorkHubConversation(props: ComponentProps & { w const liveTurn = selected && chat.liveTurn && !matchingTurns.has(chat.liveTurn.turnId) ? undefined : chat.liveTurn; return <> {selected &&
- {selected.name} + {selected.name}
} - message.hostTurnId && matchingTurns.has(message.hostTurnId)) : chat.transientMessages} 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 index e2e05ba8ee..e8800bb61d 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx @@ -18,9 +18,10 @@ */ import { useId, useState } from 'react'; -import { Button } from '@astryxdesign/core'; +import { Button, Text } from '@astryxdesign/core'; import { ChoicePanel, presentSessionStatus, useUiLocale } from '@maka/ui'; import type { WorkHubTargetSelection, WorkHubTargetSelectionRequest } from '@maka/runtime-host/protocol'; +import { workspaceNameFromCwd } from '../model/workspace-name.js'; import { workHubIdentityHue } from './workhub-work-identity.js'; import { workHubSelectionCopy } from '../locales/workhub-selection-copy.js'; @@ -39,14 +40,14 @@ export function WorkHubTargetSelector(props: {

{copy.title}

-

{props.request.candidates.length ? copy.hint : copy.empty}

+ {props.request.candidates.length ? copy.hint : copy.empty}
({ 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}`, + accentColor: `oklch(var(--workhub-identity-label-tone) ${workHubIdentityHue(candidate.sessionId)})`, + description: `${workspaceNameFromCwd(candidate.workspace.hostCwd) ?? ''} · ${presentSessionStatus(candidate.state, locale).label}`, }))}>
; } diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 5a06f39b29..b4c68e8916 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1144,6 +1144,5 @@ .maka-message-status-time { display: inline-flex; align-items: center; gap: 4px; } .maka-choice-panel { outline: none; } -.maka-choice-shortcut { color: var(--muted-foreground); font-size: 11px; font-weight: 400; } -.maka-choice-hint { margin: 6px 0; font-size: 11px; color: var(--muted-foreground); } +.maka-choice-hint { margin: 8px 0; } From 469cf077ad469ea7e753e342f6e9034632d02675 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 12 Sep 2026 10:56:38 +0800 Subject: [PATCH 14/19] fix(workhub): locate conversations before toggling work filter Generated-by: Codex --- .../__tests__/workhub-anchor-rail.test.ts | 3 +-- .../workhub/locales/workhub-live-copy.ts | 6 ++--- .../workhub/ui/workhub-conversation.tsx | 3 +++ .../workhub/ui/workhub-navigation-rail.tsx | 23 ++--------------- .../features/workhub/ui/workhub-root.tsx | 2 +- .../workhub/ui/workhub-work-identity.tsx | 21 +++++++++++++--- apps/desktop/stories/workhub.stories.tsx | 25 +++++++++++++------ packages/ui/src/chat-view.tsx | 2 +- packages/ui/src/use-chat-scroll.ts | 9 ++++--- 9 files changed, 52 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index aed1b09cc3..7ba1ebae65 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -170,7 +170,6 @@ test("rail copy distinguishes bounded anchors from all matching work", () => { sessions: many, delegatedSessionIds: [], copy: getWorkHubRailCopy("en"), - onOpenSession: () => undefined, })); assert.match(markup, /8\/20 anchors · 20 total/u); @@ -181,7 +180,7 @@ test("rail copy distinguishes bounded anchors from all matching work", () => { test("focus display is derived from the selected Session ID, not delegation priority", () => { const markup = renderToStaticMarkup(createElement(WorkHubNavigationRail, { locale: "en", sessions, focusSessionId: "focus", delegatedSessionIds: ["delegated"], - copy: getWorkHubRailCopy("en"), onOpenSession: () => undefined, + copy: getWorkHubRailCopy("en"), })); assert.equal(markup.match(/aria-current="page"/gu)?.length, 1); assert.equal(markup.match(/Focused · Running/gu)?.length, 1); diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 23caa699c3..54f2e272c2 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -19,7 +19,7 @@ import type { UiCatalog } from '@maka/core/ui-locale'; export const workHubLiveCopy = { - en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', olderConversations: 'Earlier history', newerConversations: 'Later history', navigationGesture: 'Double-click to filter conversations; Enter to open task', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', + en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', olderConversations: 'Earlier history', newerConversations: 'Later history', navigationGesture: 'Click to locate conversations; click again to filter; click once more to show all', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', retrySteering: 'Retry the original text and attachments with Shift+Enter to resolve the previous submission first.', retryFollowup: 'Retry the original text and attachments with Enter to resolve the previous submission first.', sendUnknown: 'The Host has not confirmed this message. Retry checks the same submission.', @@ -45,7 +45,7 @@ export const workHubLiveCopy = { delegationCompleted: 'Completed', delegationFailed: 'Failed', delegationAborted: 'Aborted', delegationRecovering: 'Recovering', openWork: 'Open task', openResult: 'Open result', }, - 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', olderConversations: '更早的历史', newerConversations: '更新的历史', navigationGesture: '双击筛选对话;Enter 打开任务', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', + 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', olderConversations: '更早的历史', newerConversations: '更新的历史', navigationGesture: '点击定位对话;再点筛选;再次点击显示全部', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', retrySteering: '请先保留原文和附件,用 Shift+Enter 重试并确认上次提交结果。', retryFollowup: '请先保留原文和附件,用 Enter 重试并确认上次提交结果。', sendUnknown: 'Host 尚未确认这条消息。重试会核对原提交。', @@ -71,7 +71,7 @@ export const workHubLiveCopy = { delegationCompleted: '已完成', delegationFailed: '失败', delegationAborted: '已中止', delegationRecovering: '正在恢复', openWork: '打开任务', openResult: '打开结果', }, - 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', olderConversations: '更早的歷史', newerConversations: '更新的歷史', navigationGesture: '按兩下篩選對話;Enter 開啟任務', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', + 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', olderConversations: '更早的歷史', newerConversations: '更新的歷史', navigationGesture: '點擊定位對話;再點篩選;再次點擊顯示全部', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', retrySteering: '請先保留原文和附件,用 Shift+Enter 重試並確認上次提交結果。', retryFollowup: '請先保留原文和附件,用 Enter 重試並確認上次提交結果。', sendUnknown: 'Host 尚未確認這則訊息。重試會核對原提交。', diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index 724d247bb4..de31ace30d 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -123,6 +123,8 @@ export function WorkHubConversation(props: ComponentProps & { w const matchingTurns = new Set([...worksByTurn].filter(([, works]) => works.some((work) => work.targetSessionId === selected?.sessionId)).map(([turnId]) => turnId)); const messages = selected ? chat.messages.filter((message) => message.turnId !== undefined && matchingTurns.has(message.turnId)) : chat.messages; const liveTurn = selected && chat.liveTurn && !matchingTurns.has(chat.liveTurn.turnId) ? undefined : chat.liveTurn; + const navigationTurn = [...chat.messages].reverse().find((message) => + message.turnId && worksByTurn.get(message.turnId)?.some((work) => work.targetSessionId === highlight.navigationWork?.sessionId))?.turnId; return <> {selected &&
{selected.name} @@ -132,6 +134,7 @@ export function WorkHubConversation(props: ComponentProps & { w {historyError && {copy.controlFailed}}
} message.hostTurnId && matchingTurns.has(message.hostTurnId)) : chat.transientMessages} 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 1f02cb0f7c..0d8dbf75ca 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 { useContext, useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useContext, useRef, 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'; @@ -38,12 +38,8 @@ export function WorkHubNavigationRail(props: { readonly focusSessionId?: string; readonly delegatedSessionIds: readonly string[]; readonly copy: WorkHubRailCopy; - readonly onOpenSession: (sessionId: string) => void; }) { const highlight = useContext(WorkHubHighlightContext); - const clearedByClick = useRef(false); - const openTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(openTimer.current), []); const drag = useRef<{ pointerId: number; startX: number; scrollLeft: number; list: HTMLElement; moved: boolean } | undefined>(undefined); const [filter, setFilter] = useState('all'); const anchors = deriveWorkHubAnchors({ @@ -134,22 +130,7 @@ export function WorkHubNavigationRail(props: { startContent={variant ? : undefined} isSelected={anchor.target.sessionId === props.focusSessionId} aria-current={anchor.target.sessionId === props.focusSessionId ? 'page' : undefined} - onClick={(event) => { - clearTimeout(openTimer.current); - // A selected Work clears on the first click. Ignore the - // remainder of that double-click instead of selecting it again. - if (event.detail <= 1) clearedByClick.current = false; - if (event.detail >= 2 && clearedByClick.current) return; - if (highlight.selectedWork?.sessionId === anchor.target.sessionId) { - clearedByClick.current = true; - highlight.selectWork(undefined); - return; - } - if (event.shiftKey) highlight.selectWork({ sessionId: anchor.target.sessionId, name: anchor.sessionName }); - else if (event.detail === 0) props.onOpenSession(anchor.target.sessionId); - else if (event.detail >= 2) highlight.selectWork({ sessionId: anchor.target.sessionId, name: anchor.sessionName }); - else if (event.detail === 1) openTimer.current = setTimeout(() => props.onOpenSession(anchor.target.sessionId), 500); - }} + onClick={() => highlight.navigateWork({ sessionId: anchor.target.sessionId, name: anchor.sessionName })} /> ); })} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 388a4dbda8..4d0bcbe121 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -338,7 +338,7 @@ function WorkHubContents() { >
- call(services.presentation.openSession(id))} /> +
({ sessionId: undefined, highlight: () => {}, selectWork: () => {}, toggleWork: () => {} }); +}>({ sessionId: undefined, highlight: () => {}, navigateWork: () => {}, selectWork: () => {}, toggleWork: () => {} }); /** Stable across refreshes and reordering; color supplements the visible work name. */ export function workHubIdentityHue(sessionId: string): number { @@ -39,8 +41,21 @@ export function workHubIdentityHue(sessionId: string): number { /** Work identity hover and conversation filtering are local presentation state. */ export function WorkHubHighlightProvider({ children }: { children: ReactNode }) { const [sessionId, highlight] = useState(); - const [selectedWork, selectWork] = useState<{ sessionId: string; name: string }>(); - return selectWork((current) => current?.sessionId === work.sessionId ? undefined : work) }}> + const [navigationWork, setNavigationWork] = useState<{ sessionId: string; nonce: number }>(); + const [selectedWork, setSelectedWork] = useState<{ sessionId: string; name: string }>(); + const selectWork = (work: { sessionId: string; name: string } | undefined) => { + setNavigationWork(undefined); + setSelectedWork(work); + }; + const navigateWork = (work: { sessionId: string; name: string }) => { + if (selectedWork?.sessionId === work.sessionId) selectWork(undefined); + else if (navigationWork?.sessionId === work.sessionId) selectWork(work); + else { + setSelectedWork(undefined); + setNavigationWork({ sessionId: work.sessionId, nonce: Date.now() }); + } + }; + return selectWork(selectedWork?.sessionId === work.sessionId ? undefined : work) }}> {children} ; } diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 6e87607771..de6ecd504b 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -320,11 +320,13 @@ export const FilterWorkConversations: Story = { await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); const rail = canvasElement.querySelectorAll('.workhub-navigation-item')[1] as HTMLElement; - await userEvent.dblClick(rail); + await userEvent.click(rail); + await waitFor(() => expect(canvasElement.querySelector('[data-search-highlight="true"]')).toHaveTextContent('请检查发布检查清单。')); + expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4); + expect(writes.open).not.toHaveBeenCalled(); + await userEvent.click(rail); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(1)); expect(canvas.getByText('请检查发布检查清单。')).toBeInTheDocument(); - // Wait beyond the delayed single-click action: double-click must never open a Session. - await new Promise((resolve) => setTimeout(resolve, 550)); expect(writes.open).not.toHaveBeenCalled(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); const answerRail = canvasElement.querySelector('.maka-assistant-answer .workhub-message-rail') as HTMLElement; @@ -333,7 +335,14 @@ export const FilterWorkConversations: Story = { await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); await userEvent.click(rail); - await waitFor(() => expect(writes.open).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(canvasElement.querySelector('[data-search-highlight="true"]')).toHaveTextContent('请检查发布检查清单。')); + expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4); + (rail.querySelector('button') as HTMLButtonElement).focus(); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(1)); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + expect(writes.open).not.toHaveBeenCalled(); }, }; @@ -346,7 +355,7 @@ function PagedWorkConversation() { { type: 'user', id: 'discussion', turnId: 'discussion-turn', ts: 3, text: '先讨论一下整体计划。' }, ]; return - {}, selectedWork, selectWork, toggleWork: (work) => selectWork((current) => current?.sessionId === work.sessionId ? undefined : work) }}> + {}, navigateWork: () => {}, selectedWork, selectWork, toggleWork: (work) => selectWork((current) => current?.sessionId === work.sessionId ? undefined : work) }}>
{}} onNew={() => {}} scrollBehavior="auto" activeSession={{ id: sessionId, name: 'WorkHub', isFlagged: false, isArchived: false, labels: [], hasUnread: false, status: 'active', runningTurnIds: [], backend: 'ai-sdk', llmConnectionId: 'connection-test', llmConnectionSlug: 'test', connectionLocked: false, model: 'model-a', permissionMode: 'ask' }} hasOlderHistory={!loaded} onPrefetchHistory={async () => { setLoaded(true); return true; }} @@ -393,11 +402,11 @@ export const WorkFilterHoverAndToggle: Story = { await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); - await userEvent.dblClick(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); + await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); + await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); - await userEvent.dblClick(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); + await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); - await new Promise((resolve) => setTimeout(resolve, 550)); expect(writes.open).not.toHaveBeenCalled(); expect(canvasElement.querySelector('[data-turn-source-count]')).toBe(transcriptElement); }, diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 4a57dcf661..a1f2f0fe89 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -263,7 +263,7 @@ export function ChatView(props: { * switching and hands the matched turn id here after selection; the * chat view only scrolls/highlights the already-rendered turn. */ - scrollTargetTurn?: { turnId: string; nonce: number }; + scrollTargetTurn?: { turnId: string; nonce: number; preserveFocus?: boolean }; onScrollTargetHandled?(nonce: number): void; /** Runtime-only reading position restored without search focus or highlight. */ restoreTargetTurn?: { turnId: string; unavailable?: boolean }; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5359479af1..43d0841965 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -47,7 +47,7 @@ export function useChatScroll(input: { * requester that is already aiming this turn itself and only needs the * reveal to agree with it, instantly and at the same edge. */ - target?: { turnId: string; nonce: number; align?: 'start' | 'center' }; + target?: { turnId: string; nonce: number; preserveFocus?: boolean; align?: 'start' | 'center' }; restoreTarget?: { turnId: string; unavailable?: boolean }; onTargetHandled?(nonce: number): void; viewportNavigation?: TranscriptViewportNavigation; @@ -254,6 +254,7 @@ export function useChatScroll(input: { const explicitTarget = input.target?.turnId ? { kind: 'search' as const, + preserveFocus: input.target.preserveFocus, turnId: input.target.turnId, nonce: input.target.nonce, align: input.target.align ?? ('center' as const), @@ -314,8 +315,10 @@ export function useChatScroll(input: { // switching away still retains the position the command established. reportReadingAnchor.current?.(); if (target.kind === 'restore') return; - targetElement.setAttribute('tabindex', '-1'); - targetElement.focus({ preventScroll: true }); + if (!target.preserveFocus) { + targetElement.setAttribute('tabindex', '-1'); + targetElement.focus({ preventScroll: true }); + } setHighlightedTurnId(target.turnId); targetHandledRef.current?.(target.nonce); }); From 4a542fa9b57cba42365c7d067a8a26974f22d58f Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 12 Sep 2026 11:12:58 +0800 Subject: [PATCH 15/19] fix(workhub): use Astryx button for conversation stripes Replace the raw stripe control with the design-system Button and regenerate the surface inventory required by CI. Generated-by: Codex --- .../renderer/features/workhub/ui/workhub-conversation.tsx | 8 ++++---- apps/desktop/src/renderer/styles/workhub.css | 2 +- docs/astryx-surface-file-inventory.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index de31ace30d..7601f148fe 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -85,10 +85,10 @@ export function WorkHubConversation(props: ComponentProps & { w promptStatus: <>{works.map((work, index) => {index > 0 ? ' / ' : ''} 1} /> )}, - messageRail: works.length === 1 ?