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..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,10 +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, WorkHubDelegationStatus } from '../../renderer/features/workhub/testing.js'; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; @@ -38,8 +39,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([ @@ -50,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', @@ -64,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', () => { @@ -186,3 +186,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/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 8d3e4c705a..509abc47ec 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -109,6 +109,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; @@ -176,19 +179,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/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index f5c058d12a..123f49e025 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -56,7 +56,10 @@ export function registerRuntimeHostWorkHubIpc( }); const submit = async (attempt: Attempt): Promise => { const { originHostEpoch: _originHostEpoch, ...input } = attempt; - return { kind: 'admitted', ...await client.answerWorkHubCoordination(input) }; + const result = await client.answerWorkHubCoordination(input); + return result.targetSelection + ? { kind: 'selection_required', turnId: result.turnId, request: result.targetSelection } + : { kind: 'admitted', turnId: result.turnId }; }; const reconcile = async (attempt: Attempt): Promise => { try { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 57d097c7e1..9cb4d5b294 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2042,10 +2042,11 @@ const makaBridge = { }, async answer(coordinationSessionId: string, input: WorkHubAnswerInput) { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); - return ipcRenderer.invoke('workhub:answer', scope, { + const result = await ipcRenderer.invoke('workhub:answer', scope, { ...input, ...(input.attachments ? { attachments: hostAttachmentRefs({ scope, sessionId: parseDesktopSessionKey(coordinationSessionId).sessionId }, input.attachments) } : {}), - }) as Promise; + }) as WorkHubAnswerResult; + return result.kind === 'selection_required' ? { ...result, request: { ...result.request, candidates: result.request.candidates.map((candidate) => ({ ...candidate, sessionId: recordRuntimeHostSessionScope(scope, candidate.sessionId) })) } } : result; }, async configureModel(coordinationSessionId: string, input: OperationInput<'workhub.coordination.configureModel'>) { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); 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 6f405f05d8..ea1eea20aa 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, @@ -42,11 +43,14 @@ const emptyTranscript: WorkHubTranscriptSnapshot = { hasNewer: false, ready: false, }; +type TargetSelection = import('@maka/runtime-host/protocol').WorkHubTargetSelection; +type TargetSelectionRequest = import('@maka/runtime-host/protocol').WorkHubTargetSelectionRequest; interface SendAttempt { sessionId: string; input: WorkHubAnswerInput; admission: 'pending' | 'unknown' | 'admitted' | 'terminal' | 'rejected'; reconciling?: boolean; + selectionDismissed?: boolean; stop?: 'requested' | 'sending' | 'resend'; } export function useWorkHubController() { @@ -65,6 +69,16 @@ 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); + useEffect(() => () => { resolveSelection.current?.(undefined); }, []); const [sending, setSending] = useState(false); const [stopPending, setStopPending] = useState(false); const [error, setError] = useState(); @@ -132,6 +146,7 @@ export function useWorkHubController() { function acceptAnswer(attempt: SendAttempt, result: WorkHubAnswerResult): boolean { if (pendingSend.current !== attempt) return result.kind !== 'not_admitted'; const current = currentSessionId.current === attempt.sessionId; + if (result.kind === 'selection_required') return false; if (result.kind === 'unknown') { // Late Host evidence outranks a missing response; never turn confirmed // execution back into an uncertain local submission. @@ -148,9 +163,10 @@ 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(workHubLiveCopy[localeRef.current].sendNotAdmitted); + setError(attempt.selectionDismissed ? undefined : workHubLiveCopy[localeRef.current].sendNotAdmitted); } return false; } @@ -167,12 +183,37 @@ export function useWorkHubController() { return true; } + async function answerWithSelection(attempt: SendAttempt): Promise { + try { + let result = await services.answer(attempt.sessionId, attempt.input); + while (result.kind === 'selection_required' && currentSessionId.current === attempt.sessionId) { + setLiveTurn(undefined); + setTargetSelection(result.request); + setSelectionSubmitting(false); + const selection = await new Promise((resolve) => { resolveSelection.current = resolve; }); + resolveSelection.current = undefined; + if (!selection || currentSessionId.current !== attempt.sessionId) { + attempt.selectionDismissed = true; + setTargetSelection(undefined); + return { kind: 'not_admitted' }; + } + setSelectionSubmitting(true); + attempt.input = { ...attempt.input, selection }; + result = await services.answer(attempt.sessionId, attempt.input); + } + return result; + } finally { + setTargetSelection(undefined); + setSelectionSubmitting(false); + } + } + async function recoverSend(): Promise { const attempt = pendingSend.current; if (!attempt || attempt.sessionId !== currentSessionId.current || attempt.admission !== 'unknown' || attempt.reconciling) return; attempt.reconciling = true; try { - acceptAnswer(attempt, await services.answer(attempt.sessionId, attempt.input)); + acceptAnswer(attempt, await answerWithSelection(attempt)); } catch (reason) { // A failed recovery read says nothing about the original admission. if (pendingSend.current === attempt && currentSessionId.current === attempt.sessionId) report(reason); @@ -188,6 +229,8 @@ export function useWorkHubController() { subscribeHostChanges: services.subscribeHosts, subscribeAvailabilityChanges: services.subscribeAvailability, onResolving: () => { + resolveSelection.current?.(undefined); + setTargetSelection(undefined); currentSessionId.current = undefined; setSessionId(undefined); setStopPending(false); @@ -243,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: [] }); @@ -269,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 }); @@ -312,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) => { @@ -410,9 +478,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', }]); @@ -421,7 +491,7 @@ export function useWorkHubController() { void range.current?.loadLatest().catch((reason: unknown) => { if (currentSessionId.current === target) report(reason); }); - const result = await services.answer(target, attempt.input); + const result = await answerWithSelection(attempt); return acceptAnswer(attempt, result); } catch (reason) { if (queuedTurnId) { @@ -436,12 +506,14 @@ 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); } return false; } finally { + setTargetSelection(undefined); + setSelectionSubmitting(false); sendingRef.current = false; setSending(false); } @@ -495,11 +567,23 @@ export function useWorkHubController() { } return { services, + targetSelection, + selectionSubmitting, + chooseTarget: (selection: TargetSelection) => resolveSelection.current?.(selection), + dismissTargetSelection: () => resolveSelection.current?.(undefined), sessionId, session, 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/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 55a7723ef1..23caa699c3 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: { 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: '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', 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.', @@ -41,11 +41,11 @@ 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', }, - 'zh-CN': { attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', + 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', olderConversations: '更早的历史', newerConversations: '更新的历史', navigationGesture: '双击筛选对话;Enter 打开任务', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', retrySteering: '请先保留原文和附件,用 Shift+Enter 重试并确认上次提交结果。', retryFollowup: '请先保留原文和附件,用 Enter 重试并确认上次提交结果。', sendUnknown: 'Host 尚未确认这条消息。重试会核对原提交。', @@ -67,11 +67,11 @@ export const workHubLiveCopy = { hint: '问个问题、管理任务,或让我帮你操作 Maka。', floating: '工作台已在浮窗中打开', restore: '收回工作台', - delegationAccepted: '已接收', delegationRunning: '进行中', delegationWaiting: '等待你', + delegationAccepted: '已接收', delegationRunning: '进行中', delegationWaiting: '等待用户', delegationCompleted: '已完成', delegationFailed: '失败', delegationAborted: '已中止', delegationRecovering: '正在恢复', openWork: '打开任务', openResult: '打开结果', }, - 'zh-TW': { attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', + 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', olderConversations: '更早的歷史', newerConversations: '更新的歷史', navigationGesture: '按兩下篩選對話;Enter 開啟任務', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', retrySteering: '請先保留原文和附件,用 Shift+Enter 重試並確認上次提交結果。', retryFollowup: '請先保留原文和附件,用 Enter 重試並確認上次提交結果。', sendUnknown: 'Host 尚未確認這則訊息。重試會核對原提交。', @@ -93,7 +93,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/locales/workhub-selection-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-selection-copy.ts new file mode 100644 index 0000000000..d94b74974a --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-selection-copy.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. + */ + +export const workHubSelectionCopy = { + en: { title: 'Which work do you want to continue?', hint: 'Choose the target work, or continue explaining.', confirm: 'Confirm target', submitting: 'Confirming…', explain: 'None of these, continue explaining', create: '+ New Work', empty: 'No available work. Continue explaining or create a new Work.' }, + 'zh-CN': { title: '你想继续哪个工作?', hint: '请选择目标工作,或继续说明。', confirm: '确认目标', submitting: '正在确认…', explain: '都不是,继续说明', create: '+ 新建 Work', empty: '没有可选的工作。你可以继续说明,或新建 Work。' }, + 'zh-TW': { title: '你想繼續哪個工作?', hint: '請選擇目標工作,或繼續說明。', confirm: '確認目標', submitting: '正在確認…', explain: '都不是,繼續說明', create: '+ 新建 Work', empty: '沒有可選的工作。你可以繼續說明,或新建 Work。' }, +} as const; 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/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index 5ba6d3f61e..1041f42ebb 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -67,6 +67,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/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 134c41208c..467b28f69b 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -17,20 +17,18 @@ * under the License. */ -import { useContext, useMemo, type ComponentProps, type CSSProperties } from 'react'; +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 { 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 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,53 +42,107 @@ 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.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 }) { - 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(); - const workByTurn = useMemo(() => new Map(assignments.map((assignment) => [assignment.coordinationTurnId, assignment.targetSessionId])), [assignments]); + const copy = workHubLiveCopy[locale]; + const [loadingHistory, setLoadingHistory] = useState(false); + const [historyError, setHistoryError] = useState(false); + async function loadHistory(edge: 'older' | 'newer') { + if (loadingHistory) return; + setLoadingHistory(true); + setHistoryError(false); + try { await chat.onPrefetchHistory?.(edge); } + catch { setHistoryError(true); } + finally { setLoadingHistory(false); } + } + // 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]); - return message.type === 'user' ? [[message.turnId, message.text.slice(0, 80)] as const] : [])); + const turnDecorations = new Map([...worksByTurn].map(([turnId, works]) => [turnId, { + accentColor: promptRailDecorations.get(turnId)?.accentColor, + promptStatus: <>{works.map((work, index) => + {index > 0 ? ' / ' : ''} 1} /> + )}, + messageRail: works.length === 1 ?