diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 559ab04bba..773aef08f9 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -82,8 +82,8 @@ "electron": "the same WebContentsView and unsent draft survive reparenting between native main and floating windows, shared appearance changes and the explicit return-from-picture-in-picture button" }, "workhub-return-rail.spec.ts": { - "tests": 2, - "electron": "WorkHub lives in a separate native WebContentsView: compare its scrollport with the main Session after Host-backed sends, then open that Session and return through the shell while retaining the same WorkHub draft" + "tests": 3, + "electron": "WorkHub lives in a separate native WebContentsView: compare its scrollport with the main Session after Host-backed sends, then open that Session and return through the shell while retaining the same WorkHub draft; a Host-backed pending question survives renderer reload and native detach/dock with its selection and focus" }, "zh-tw-locale.spec.ts": { "tests": 1, diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 45048ffe72..3d1eac57f2 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -275,7 +275,9 @@ async function settled(page: Page): Promise { async function moveToTail(page: Page): Promise { await settled(page); - await page.locator(TURN).last().scrollIntoViewIfNeeded(); + // The bounded transcript can replace its last Turn between locator + // resolution and scrolling. The scroll container owns this movement. + await page.locator(SCROLLER).evaluate((element) => { element.scrollTop = element.scrollHeight; }); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), )); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index bc4bda14a0..97deb6089f 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -118,7 +118,16 @@ test('WorkHub uses its coordination model and shared attachment composer', async await expect.poll(nativeWorkHubVisible).toBe(true); await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays); await expect(page.locator('.workHubDockBackdrop')).toHaveCount(0); - await anchors.locator('.workhub-navigation-item').first().click(); + const workRail = anchors.locator('.workhub-navigation-item').first(); + await workRail.click(); + await expect(page.locator('.workHubDock')).toBeVisible(); + await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toHaveCount(0); + await workRail.click(); + await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toBeVisible(); + await workRail.click(); + await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toHaveCount(0); + await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays); + await page.getByRole('button').filter({ has: page.getByText('WorkHub navigation regression', { exact: true }) }).click(); await expect(page.locator('.workHubDock')).toBeHidden(); await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); await expect(page.locator('.workHubDock')).toBeVisible(); diff --git a/apps/desktop/e2e/workhub-return-rail.spec.ts b/apps/desktop/e2e/workhub-return-rail.spec.ts index 9c372ee8e9..f6348a58dc 100644 --- a/apps/desktop/e2e/workhub-return-rail.spec.ts +++ b/apps/desktop/e2e/workhub-return-rail.spec.ts @@ -17,6 +17,7 @@ * under the License. */ +import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime/test-only/fake-backend'; import type { Page } from '@playwright/test'; import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures'; @@ -90,7 +91,12 @@ test('Session keeps a return to WorkHub control when the sidebar is collapsed', await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); const workhub = await getWorkHubPage(app); await workhub.locator(COMPOSER_INPUT).fill('Keep my WorkHub draft'); + const sessionName = await workhub.locator('.workhub-navigation-label').first().innerText(); await workhub.locator('.workhub-navigation-item').first().click(); + await expect(page.locator('.workHubDock')).toBeVisible(); + const expand = page.getByRole('button', { name: '展开侧边栏', exact: true }); + if (await expand.isVisible()) await expand.click(); + await page.getByRole('button').filter({ has: page.getByText(sessionName, { exact: true }) }).click(); await expect(page.locator('.workHubDock')).toBeHidden(); const collapse = page.getByRole('button', { name: '收起侧边栏', exact: true }); if (await collapse.isVisible()) await collapse.click(); @@ -119,3 +125,28 @@ test('Session keeps a return to WorkHub control when the sidebar is collapsed', await expect(page.locator('.workHubDock')).toBeVisible(); await expect(workhub.locator(COMPOSER_INPUT)).toHaveText('Keep my WorkHub draft'); }); + + +test('a pending WorkHub question preserves docked placement, choices and focus across window transitions', async ({ sessionLocalWindow: { page, app } }) => { + await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); + const hub = await getWorkHubPage(app); + await hub.locator(COMPOSER_INPUT).fill(FAKE_ASK_USER_QUESTION_PROMPT); + await awaitSendReady(hub); + await hub.locator(COMPOSER_INPUT).press('Enter'); + await expect(hub.getByRole('radio', { name: /公开测试/ })).toBeVisible(); + await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked'); + // Reload rehydrates the pending question through Host interaction queries. + await hub.reload(); + const choice = hub.getByRole('radio', { name: /公开测试/ }); + await expect(choice).toBeVisible(); + await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked'); + await choice.click(); + await app.evaluate(async ({ app }) => { if (process.platform === 'darwin') await app.dock!.show(); }); + await hub.evaluate(() => window.maka.workHubPresentation.detach()); + await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'floating'); + await expect(choice).toBeChecked(); + await expect.poll(() => hub.locator('.maka-choice-panel').evaluate((panel) => panel.contains(document.activeElement))).toBe(true); + await hub.evaluate(() => window.maka.workHubPresentation.dock()); + await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked'); + await expect(choice).toBeChecked(); +}); 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..7ba1ebae65 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', () => { @@ -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,8 +180,32 @@ 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); }); + + +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/); +}); + + +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/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index d8e7a6565f..98fbef2b38 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -462,7 +462,7 @@ test('control shows a passive card only after it is painted and preserves manual assert.equal(h.main.visible, false, 'control does not reveal a hidden main window'); assert.equal(view.webContents.sent.some(([channel]) => channel.endsWith('focus-composer')), false); await h.command(view.webContents, 'ready'); - await h.command(view.webContents, 'show-conversation'); + await h.command(view.webContents, 'show-conversation', request); assert.equal(h.controller.getSnapshot().progressRequest, undefined); assert.equal(floating.bounds.width, 520); assert.equal(floating.bounds.height, 720); @@ -477,6 +477,34 @@ test('control shows a passive card only after it is painted and preserves manual h.controller.dispose(); }); +test('interaction content upgrades native progress in either paint order without activation or reopening dismissal', async () => { + for (const paintFirst of [false, true]) { + const h = await harness(); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } }); + const view = h.views[0]!; + await h.controller.prepareControl('interaction-turn'); + const request = h.controller.getSnapshot().progressRequest!; + const floating = h.windows[1]!; + if (paintFirst) await h.command(view.webContents, 'progress-ready', request); + const layout = { expanded: true, compactHeight: 250, interactionPending: true }; + await h.command(view.webContents, 'conversation-layout', layout); + assert.equal(h.controller.getSnapshot().progressRequest, undefined); + assert.equal(floating.visible, true); + assert.equal(floating.resizable, true); + assert.equal(floating.bounds.width, 520); + assert.equal(floating.bounds.height, 720); + assert.equal(floating.focused, 0); + assert.equal(h.main.focused, 0); + await h.command(view.webContents, 'progress-ready', request); + assert.equal(floating.bounds.height, 720, 'stale paint cannot restore progress'); + await h.command(view.webContents, 'hide'); + await h.command(view.webContents, 'conversation-layout', layout); + await h.controller.prepareControl('interaction-turn'); + assert.equal(floating.visible, false, 'content cannot override dismissal of this Turn'); + h.controller.dispose(); + } +}); + test('control preparation refuses disabled presentation before opening and rechecks a pending open', async () => { const h = await harness(); h.setEnabled(false); @@ -914,3 +942,42 @@ test('the progress card stays hidden in a hidden run and inactive everywhere els h.controller.dispose(); } }); + + +test('revealing an interaction cannot detach a docked WorkHub without a current progress request', async () => { + const h = await harness(); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } }); + const view = h.views[0]!; + for (const request of [undefined, 1, NaN]) { + if (request === undefined || Number.isNaN(request)) await assert.rejects(h.command(view.webContents, 'show-conversation', request), /Invalid progress request/); + else await h.command(view.webContents, 'show-conversation', request); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.equal(h.controller.getSnapshot().floatingVisible, false); + } +}); + + +test('expanding progress before its first paint reveals the conversation without taking focus', async () => { + for (const mode of ['active', 'hidden'] as const) { + const h = await harness(false, 60, mode); + await h.controller.prepareControl('turn-early'); + const view = h.views[0]!; + const floating = h.windows[1]!; + floating.isFocused = () => false; + const request = h.controller.getSnapshot().progressRequest!; + await h.command(view.webContents, 'ready'); + assert.equal(floating.visible, false); + const focusMessages = view.webContents.sent.filter(([channel]) => channel.endsWith('focus-composer')).length; + await h.command(view.webContents, 'show-conversation', request); + assert.equal(floating.visible, mode !== 'hidden', 'expansion must finish the native reveal without waiting for the unmounted progress card'); + assert.equal(h.controller.getSnapshot().progressRequest, undefined); + assert.equal(floating.focused, 0); + assert.equal(view.webContents.sent.filter(([channel]) => channel.endsWith('focus-composer')).length, focusMessages); + assert.equal(view.webContents.backgroundThrottling, true); + await h.command(view.webContents, 'hide'); + await h.command(view.webContents, 'progress-ready', request); + await h.command(view.webContents, 'show-conversation', request); + assert.equal(floating.visible, false, 'late acknowledgements and stale expansions cannot reopen a dismissed window'); + h.controller.dispose(); + } +}); 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 689312c150..b3fc364f8a 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -116,6 +116,10 @@ async function mountController(failFirstRead = false, overrides: Partial) => { 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: () => () => {}, + respondToUserForm: async () => {}, + 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; @@ -123,7 +127,8 @@ async function mountController(failFirstRead = false, overrides: Partial { submissions++; }); return null; } await act(async () => { root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(WorkHubServicesProvider, { services }, createElement(Probe)), @@ -131,6 +136,7 @@ async function mountController(failFirstRead = false, overrides: Partial { +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(); }); @@ -402,6 +409,7 @@ test('an unknown WorkHub submission converges through the original Host admissio assert.equal(h.controller.stopPending, true); assert.equal(h.controller.canRetry, true); const submitted = h.requests.length; + const submissions = h.submissions; if (outcome === 'running') h.admit(original.turnId); if (outcome === 'completed') h.complete(original.turnId); if (outcome === 'replay') h.resetAdmission(); @@ -414,6 +422,7 @@ test('an unknown WorkHub submission converges through the original Host admissio h.admission.resolve({ turnId: original.turnId }); }); } else assert.equal(h.requests.length, submitted, 'admission lookup must not send a new request after Host replacement'); + assert.equal(h.submissions, submissions, 'unknown-admission recovery is not a new submission'); assert.equal(h.controller.stopPending, false); if (outcome === 'not_admitted' || outcome === 'completed') { assert.equal(h.controller.busy, false); @@ -424,6 +433,7 @@ test('an unknown WorkHub submission converges through the original Host admissio if (outcome === 'not_admitted') { h.resetAdmission(); await act(async () => h.controller.retry()); + assert.equal(h.submissions, submissions + 1, 'explicit rejected Retry crosses the shared submission boundary'); assert.deepEqual(h.requests.at(-1), original, 'explicit Retry retains the text, attachments and unadmitted Turn identity'); await act(async () => { h.admit(original.turnId); @@ -454,6 +464,7 @@ test('Retry reopens a failed initial WorkHub read after Session resolution', asy const turnId = h.requests[0]!.turnId; await act(async () => h.controller.retry()); assert.equal(h.openCount, 2); + assert.equal(h.submissions, 1, 'read recovery does not resubmit'); assert.equal(h.controller.liveTurn?.turnId, turnId); assert.equal(h.controller.transientMessages[0]?.text, 'retain this in-flight message'); assert.equal(h.controller.transcript.ready, true); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 1bf8458f62..032d7f275a 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1011,6 +1011,10 @@ export class DesktopRuntimeHostClient { return this.request("workhub.coordination.candidates", {}); } + selectAndDelegateWorkHubTarget(input: OperationInput<'workhub.coordination.selectAndDelegate'>) { + return this.request('workhub.coordination.selectAndDelegate', input); + } + actWorkHubCoordinationFromTurn(input: OperationInput<'workhub.coordination.actFromTurn'>) { return this.request('workhub.coordination.actFromTurn', input); } diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts index 5d5f613953..5abfb9ee89 100644 --- a/apps/desktop/src/main/workhub-presentation.ts +++ b/apps/desktop/src/main/workhub-presentation.ts @@ -66,6 +66,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { let conversationExpanded = false; let compactHeight = 96; let expandedHeight = 720; + let interactionPending = false; let resizeTimer: ReturnType | undefined; let resizeTarget: Electron.Rectangle | undefined; let resizeViewportHeight: number | undefined; @@ -347,6 +348,31 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { changed(); } + /** Completes the native transition, regardless of which renderer paint arrived first. */ + function expandProgress(request: number): void { + if (request !== progressRequest || !floating || !deps.isEnabled()) return; + conversationExpanded = true; + expandOnFocus = true; + const current = floating.getBounds(); + const area = screen.getDisplayMatching(current).workArea; + const width = Math.min(conversationBounds?.width ?? 520, area.width); + const height = Math.min(expandedHeight, area.height); + clearProgressRequest(); + conversationBounds = undefined; + floating.setResizable(true); + changed(); + resizeFloating({ + width, height, + x: Math.max(area.x, Math.min(current.x + Math.round((current.width - width) / 2), area.x + area.width - width)), + y: Math.max(area.y, Math.min(current.y + current.height - height, area.y + area.height - height)), + }, true); + // Expansion may beat progress-ready and unmount its paint callback. + showWindowInactive(floating, deps.revealMode); + // A send acknowledgement can arrive after the user has switched + // apps. Growing the conversation must not steal focus back. + if (floating.isFocused()) focusComposer(); + } + function requestProgress(): void { const main = deps.mainWindow(); const dockVisible = placement === 'docked' && parent === main && main?.isVisible() && !main.isMinimized() @@ -369,6 +395,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const width = Math.min(360, area.width), height = Math.min(112, area.height); target.setBounds({ width, height, x: area.x + Math.round((area.width - width) / 2), y: Math.max(area.y, area.y + area.height - height - 96) }); fitFloating(); + if (interactionPending) { expandProgress(progressRequest); return; } // The renderer acknowledges its painted card before showInactive, avoiding // one frame of the old full conversation in the compact native window. changed(); @@ -457,7 +484,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { return { main, isMain }; }; authorize(); - if (command === 'show-conversation' && payload !== undefined) { + if (command === 'show-conversation') { if (typeof payload !== 'number' || !Number.isSafeInteger(payload)) throw new Error('Invalid progress request'); if (payload !== progressRequest) return; } @@ -534,38 +561,17 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { if (height !== bounds.height) resizeFloating({ ...bounds, height, y: Math.max(area.y, bounds.y + bounds.height - height) }, true); return; } - case 'show-conversation': { - if (payload !== undefined && payload !== progressRequest) return; - conversationExpanded = true; - expandOnFocus = true; - if (progressRequest !== undefined && floating) { - const current = floating.getBounds(); - const area = screen.getDisplayMatching(current).workArea; - const width = Math.min(conversationBounds?.width ?? 520, area.width); - const height = Math.min(expandedHeight, area.height); - clearProgressRequest(); - conversationBounds = undefined; - floating.setResizable(true); - changed(); - resizeFloating({ - width, height, - x: Math.max(area.x, Math.min(current.x + Math.round((current.width - width) / 2), area.x + area.width - width)), - y: Math.max(area.y, Math.min(current.y + current.height - height, area.y + area.height - height)), - }, true); - // A send acknowledgement can arrive after the user has switched - // apps. Growing the conversation must not steal focus back. - if (floating.isFocused()) focusComposer(); - return; - } - detach(true); - return; - } + case 'show-conversation': expandProgress(payload as number); return; case 'detach': detach(); return; case 'conversation-layout': { if (isMain) throw new Error('Only the WorkHub view can size its conversation'); - const value = payload as { expanded?: unknown; compactHeight?: unknown } | null; - if (!value || typeof value.expanded !== 'boolean' || typeof value.compactHeight !== 'number' || !Number.isFinite(value.compactHeight) || value.compactHeight <= 0) throw new Error('Invalid WorkHub conversation layout'); - if (progressRequest !== undefined) return; + const value = payload as { expanded?: unknown; compactHeight?: unknown; interactionPending?: unknown } | null; + if (!value || typeof value.expanded !== 'boolean' || typeof value.compactHeight !== 'number' || !Number.isFinite(value.compactHeight) || value.compactHeight <= 0 || (value.interactionPending !== undefined && typeof value.interactionPending !== 'boolean')) throw new Error('Invalid WorkHub conversation layout'); + interactionPending = value.interactionPending === true; + if (progressRequest !== undefined) { + if (interactionPending) expandProgress(progressRequest); + return; + } // Desktop's wider composer must not overwrite the remembered floating // height and force a second resize on the next shortcut summon. if (!floating || placement === 'floating') compactHeight = Math.max(80, Math.ceil(value.compactHeight)); diff --git a/apps/desktop/src/main/workhub-runtime.ts b/apps/desktop/src/main/workhub-runtime.ts index df8005cbbc..22a4c185cf 100644 --- a/apps/desktop/src/main/workhub-runtime.ts +++ b/apps/desktop/src/main/workhub-runtime.ts @@ -24,7 +24,7 @@ import type { WorkHubTasksInput } from '../shared/workhub-tool-schema.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; interface WorkHubRuntimeDeps { - client(scope: DesktopTargetScope): Pick; + client(scope: DesktopTargetScope): Pick; isCurrent(scope: DesktopTargetScope): boolean; createContext(scope: DesktopTargetScope): Promise<{ workspace: WorkspaceTarget; defaults: WorkHubCreateDefaults }>; changed(scope: DesktopTargetScope, reason: 'created' | 'status-change', sessionId: string): void; @@ -59,6 +59,16 @@ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { requireCurrent(scope); const client = deps.client(scope); if (input.operation === 'candidates') return client.listWorkHubCoordinationCandidates(); + if (input.operation === 'select_and_delegate') { + const outcome = await client.selectAndDelegateWorkHubTarget({ turnId, actionId, + candidateSetId: input.candidateSetId, candidateRefs: input.candidateRefs, delegationText: input.text }); + if (outcome.kind === 'cancelled') return outcome; + const result = outcome.result; + if ('targetSessionId' in result) deps.changed(scope, 'status-change', result.targetSessionId); + return { ...result, actionId, ...('targetSessionId' in result ? { + targetSessionKey: desktopSessionKey({ hostId: scope.hostId, sessionId: result.targetSessionId }), + } : {}) }; + } let proposal: WorkHubCoordinationProposal; switch (input.operation) { case 'delegate_existing': proposal = { disposition: 'delegate_existing', candidateRef: input.candidateRef }; break; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a8286fda98..fe6e89bf3b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2049,10 +2049,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; }, async configureModel(coordinationSessionId: string, input: OperationInput<'workhub.coordination.configureModel'>) { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); diff --git a/apps/desktop/src/preload/workhub-presentation.ts b/apps/desktop/src/preload/workhub-presentation.ts index 5fab11f768..db19a1b357 100644 --- a/apps/desktop/src/preload/workhub-presentation.ts +++ b/apps/desktop/src/preload/workhub-presentation.ts @@ -33,7 +33,7 @@ export const workHubPresentationBridge: WorkHubPresentationBridge = { setConversationLayout: (layout) => ipcRenderer.invoke('workhub-presentation:command', 'conversation-layout', layout), progressReady: (request) => ipcRenderer.invoke('workhub-presentation:command', 'progress-ready', request), resizeProgress: (request, height) => ipcRenderer.invoke('workhub-presentation:command', 'progress-layout', { request, height }), - showConversation: (request) => ipcRenderer.invoke('workhub-presentation:command', 'show-conversation', request), + expandProgress: (request) => ipcRenderer.invoke('workhub-presentation:command', 'show-conversation', request), detach: () => ipcRenderer.invoke('workhub-presentation:command', 'detach'), dock: () => ipcRenderer.invoke('workhub-presentation:command', 'dock'), hide: () => ipcRenderer.invoke('workhub-presentation:command', 'hide'), 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 80f9c98bb7..58b6be323c 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 @@ -23,6 +23,7 @@ import { applyLiveTurnBufferEvent, retainLiveTurn, type LiveTurnBuffer, + activeInteractionFor, reduceInteractionQueues, reconcileInteractions, clearInteractions, type InteractionQueues, armLiveTurn, createTranscriptViewportNavigation, reconcileLiveTurnBuffer, @@ -52,7 +53,7 @@ interface SendAttempt { reconciling?: boolean; stop?: 'requested' | 'sending' | 'resend'; } -export function useWorkHubController() { +export function useWorkHubController(onSubmit?: () => void) { const services = useWorkHubServices(); const locale = useUiLocale(); const localeRef = useRef(locale); @@ -73,6 +74,12 @@ export function useWorkHubController() { const [execution, setExecution] = useState(); const [liveTurns, setLiveTurns] = useState(); const liveTurn = liveTurns?.find((turn) => turn.turnId === execution?.rootTurn?.turnId) ?? liveTurns?.at(-1); + 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 [sending, setSending] = useState(false); const [stopPending, setStopPending] = useState(false); const [error, setError] = useState(); @@ -156,7 +163,7 @@ export function useWorkHubController() { attempt.stop = undefined; if (current) { setStopPending(false); - setTransientMessages((messages) => messages.filter((message) => message.hostTurnId !== attempt.input.turnId)); + setTurnStates((states) => ({ ...states, [attempt.input.turnId]: 'failed' })); setLiveTurns((previous) => previous?.filter((turn) => turn.turnId !== attempt.input.turnId || !turn.unconfirmed)); setError(workHubLiveCopy[localeRef.current].sendNotAdmitted); } @@ -257,6 +264,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: [] }); @@ -283,6 +311,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 }); @@ -326,7 +358,7 @@ export function useWorkHubController() { if (disposed) return; observationPhase = phase; handle?.observationChanged(phase); - if (phase === 'ready') void recoverSend(); + if (phase === 'ready') { refreshInteractions.current(); void recoverSend(); } }, (projection) => { if (!disposed) setExecution(projection); }, ); @@ -398,6 +430,7 @@ export function useWorkHubController() { return false; } const queuedTurnId = sameQueued?.turnId ?? runningTurnId; + onSubmit?.(); sendingRef.current = true; setSending(true); setError(undefined); @@ -432,9 +465,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' })); setLiveTurns((previous) => retainLiveTurn(previous, 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', }]); @@ -443,7 +478,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 services.answer(attempt.sessionId, attempt.input); return acceptAnswer(attempt, result); } catch (reason) { if (queuedTurnId) { @@ -458,7 +493,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' })); setLiveTurns((previous) => previous?.filter((turn) => turn.turnId !== failedTurnId || !turn.unconfirmed)); report(reason); } @@ -530,6 +565,19 @@ export function useWorkHubController() { sessions, choices, transcript, + activeForm: activeInteraction?.type === 'form_request' ? activeInteraction : undefined, + respondToUserForm: async (response: import('@maka/core/interaction').InteractionFormResponse) => { + if (!sessionId) throw new Error('WorkHub Session is unavailable'); + await services.respondToUserForm(sessionId, response); + }, + 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..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: { 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.', @@ -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: '点击定位对话;再点筛选;再次点击显示全部', 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: '點擊定位對話;再點篩選;再次點擊顯示全部', 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/model/linked-work.ts b/apps/desktop/src/renderer/features/workhub/model/linked-work.ts index f985bbcaa8..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 = @@ -47,6 +49,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 +59,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) => workspaceNameFromCwd(sessionById.get(id)?.cwd); const taskCalls = new Set(messages.flatMap((message) => message.type === 'tool_call' && message.toolName === 'mcp__desktop_workhub__tasks' ? [message.id] : [], )); @@ -68,7 +72,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 +92,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/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/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index e73f281d15..190a7210da 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -67,6 +67,10 @@ 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; + respondToUserForm(sessionId: string, response: import('@maka/core/interaction').InteractionFormResponse): Promise; + 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..6229bcd9aa 100644 --- a/apps/desktop/src/renderer/features/workhub/testing.ts +++ b/apps/desktop/src/renderer/features/workhub/testing.ts @@ -18,3 +18,8 @@ */ 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 134c41208c..8eb32efc98 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 { Button, Link, Text } 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,110 @@ 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 ?