diff --git a/apps/desktop/e2e/session-local-recovery.spec.ts b/apps/desktop/e2e/session-local-recovery.spec.ts index 75b6cdf8e7..743de76d37 100644 --- a/apps/desktop/e2e/session-local-recovery.spec.ts +++ b/apps/desktop/e2e/session-local-recovery.spec.ts @@ -56,7 +56,7 @@ test('a locally saved message survives renderer and application restart, then ex await awaitSendReady(page); await page.locator(COMPOSER_INPUT).press('Enter'); await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); - await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible(); + await expect(page.getByRole('button', { name: '取消发送', exact: true })).toBeVisible(); const before = await page.evaluate((id) => window.maka.sessionLocal.listMessages(id), sessionId!); const message = before.find((item) => item.text === pending)!; expect(message.state).toBe('saved'); @@ -65,7 +65,7 @@ test('a locally saved message survives renderer and application restart, then ex await page.reload(); await ensureSidebarExpanded(page); await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); - await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible(); + await expect(page.getByRole('button', { name: '取消发送', exact: true })).toBeVisible(); expect( (await page.evaluate((id) => window.maka.sessionLocal.listMessages(id), sessionId!)).find( (item) => item.text === pending, @@ -146,7 +146,7 @@ test('a new task is readable locally before the Host session exists', async ({ await awaitSendReady(page); await page.locator(COMPOSER_INPUT).press('Enter'); await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); - await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible(); + await expect(page.getByRole('button', { name: '取消发送', exact: true })).toBeVisible(); await ensureSidebarExpanded(page); const sessionId = await page .locator('[data-session-id]:has([aria-current="page"])') @@ -157,7 +157,7 @@ test('a new task is readable locally before the Host session exists', async ({ await page.reload(); await ensureSidebarExpanded(page); await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); - await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible(); + await expect(page.getByRole('button', { name: '取消发送', exact: true })).toBeVisible(); await expect(page.getByText('读取任务失败', { exact: true })).toHaveCount(0); page = await sessionLocalWindow.restart(); await ensureSidebarExpanded(page); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index ddfc2ed55a..89da64f58b 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -446,7 +446,7 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin await expect(prompt).toHaveCount(1); await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); await expect(stop).toBeVisible(); - const followups = workhub.locator('[data-queue-placement="next_turn"] .maka-composer-queue-text'); + const followups = workhub.locator('.maka-composer-queue .maka-composer-queue-text'); const queuedTexts = ['下一轮整理测试结果', '再下一轮补充使用说明'] as const; await workhub.locator(COMPOSER_INPUT).fill(queuedTexts[0]); await workhub.getByRole('button', { name: /^(发送|Send)$/ }).click(); @@ -457,17 +457,6 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin await awaitSendReady(workhub); await workhub.locator(COMPOSER_INPUT).press('Enter'); await expect(followups).toHaveText(queuedTexts); - const shortcuts = workhub.getByRole('button', { name: '发送快捷键', exact: true }); - await expect(shortcuts).toHaveCount(1); - await shortcuts.hover(); - const shortcutHint = workhub.getByRole('tooltip'); - const steerModifier = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'; - await expect(shortcutHint).toHaveText(`${steerModifier}+Enter:转向(Steering)\nEnter:下一轮(Follow-up)\nShift+Enter:换行`); - await expect.poll(() => shortcutHint.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return bounds.left >= 0 && bounds.right <= innerWidth && bounds.top >= 0 && bounds.bottom <= innerHeight; - })).toBe(true); - await workhub.screenshot({ path: testInfo.outputPath('workhub-queue-shortcuts.png') }); for (const text of queuedTexts) { await expect(workhub.locator('.maka-user-message').filter({ hasText: text })).toHaveCount(0); } diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1ad28e4726..10d00e9a0c 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -601,7 +601,7 @@ "@maka/ui": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2687 + "nonTriviaTokens": 2677 }, "src/renderer/app-shell-session-ui-state.ts": { "importDeclarations": 0, @@ -694,11 +694,7 @@ "window.maka.sessions.getPlanState": 1, "window.maka.sessions.listActiveInteractions": 1, "window.maka.sessions.listTurnLandmarks": 1, - "window.maka.sessions.promoteQueueEntry": 1, - "window.maka.sessions.reorderQueueEntries": 1, - "window.maka.sessions.retractQueueEntry": 1, "window.maka.sessions.subscribeActiveInteractions": 1, - "window.maka.sessions.updateQueueEntry": 1, "window.maka.settings.getClient": 1, "window.maka.settings.subscribeClientChanged": 1 }, @@ -725,7 +721,7 @@ "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 14, + "useRef": 13, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -831,7 +827,7 @@ "react": 1 }, "importSpecifiers": 98, - "nonTriviaTokens": 12730 + "nonTriviaTokens": 12498 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 214361cf4b..d2bb88058c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -38,10 +38,14 @@ import { describe, it } from 'node:test'; import { act, createElement } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; -import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; +import { + ConversationServicesProvider, + useAppShellSessionUiState, + type ConversationServices, +} from '../../renderer/features/conversation/index.js'; import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; -import type { LiveTurnProjection } from '@maka/ui'; +import { LocaleProvider, ToastProvider, type LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js'; @@ -715,6 +719,29 @@ describe('composer first-send cleanup', () => { const { root } = installReactRenderer(); let publication!: ReturnType['publication']; const catalog = createSessionCatalogController(); + const services: ConversationServices = { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => undefined, + skills: { listInvocable: async () => [] }, + sessions: { + readSnapshot: async () => { + throw new Error('Session snapshot is not used in this test'); + }, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + retractQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, + }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, + }; function Probe(): null { publication = useAppShellSessionUiState( catalog, undefined, deps.activeIdRef, @@ -723,7 +750,16 @@ describe('composer first-send cleanup', () => { return null; } try { - act(() => root.render(createElement(Probe))); + act(() => root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services, children: createElement(Probe), + }), + }), + }), + )); const actions = createAppShellChatActions({ ...deps, transcriptRangeRef: { current: controller }, isMessagePublished: publication.isMessagePublished, diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts index f3b093de2c..f7272a5210 100644 --- a/apps/desktop/src/main/__tests__/composer-directories.test.ts +++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts @@ -143,3 +143,13 @@ test('IPC validates directory references without turning them into attachments o }), /Invalid directory references/); } }); + +test('restoreDirectories stages references under another draft for a later visit', async () => { + const probe = await mount(); + await act(() => probe.state().restoreDirectories('draft-b', [reference])); + assert.deepEqual(probe.state().pendingDirectories, [], 'draft-a stays untouched'); + await probe.render({ draftKey: 'draft-b' }); + assert.deepEqual(probe.state().pendingDirectories, [reference]); + await act(() => probe.state().restoreDirectories('draft-b', [reference, reference])); + assert.deepEqual(probe.state().pendingDirectories, [reference], 'duplicates stay out'); +}); diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts index 6bc4a2a504..f2c5184028 100644 --- a/apps/desktop/src/main/__tests__/composer-mentions.test.ts +++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts @@ -94,6 +94,10 @@ function installCatalogRenderer(t: TestContext) { readSnapshot: async () => { throw new Error('Session snapshot is not used in catalog tests'); }, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + retractQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, }, workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, newTasks: { diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 81bf402eec..4690f6740b 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -20,8 +20,14 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider, type TransientUserMessageProjection } from '@maka/ui'; -import { ConversationServicesProvider, SessionLocalMessages } from '../../renderer/features/conversation/index.js'; +import { + LocaleProvider, + ToastProvider, + type ComposerHandle, + type TransientUserMessageProjection, +} from '@maka/ui'; +import { ConversationServicesProvider, SessionLocalMessages, useSessionMessageQueue } from '../../renderer/features/conversation/index.js'; +import type { RestoredDraftContent } from '../../renderer/application/contracts/transient-message-projection.js'; import type { DesktopLocalMessage } from '../../shared/session-local-contract.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js'; @@ -33,17 +39,24 @@ test('local delivery recovery cannot republish accepted Host queue rows', async const { root } = installReactRenderer(); const transient = new Map(); let changed!: (sessionId: string) => void; - let messages: DesktopLocalMessage[] = ['steering', 'followup', 'root'].map((messageId) => ({ - sessionId: 'session-1', messageId, createdAt: 1, state: 'unknown', canCancel: false, - text: messageId, attachments: [], inlineReferences: [], - placement: messageId === 'steering' ? 'current_turn' : 'next_turn', - })); + const cancelled: string[][] = []; + const reconciled: string[][] = []; + const restored: string[][] = []; + let messages: DesktopLocalMessage[] = [ + { sessionId: 'session-1', messageId: 'steering', createdAt: 1, state: 'unknown', canCancel: false, + text: 'steering', attachments: [], inlineReferences: [], placement: 'current_turn' }, + { sessionId: 'session-1', messageId: 'followup', createdAt: 2, state: 'saved', canCancel: true, + text: 'followup', attachments: [], inlineReferences: [], placement: 'next_turn' }, + { sessionId: 'session-1', messageId: 'root', createdAt: 3, state: 'unknown', canCancel: false, + text: 'root', attachments: [], inlineReferences: [], placement: 'next_turn' }, + ]; await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(ConversationServicesProvider, { services: { listMessages: async () => messages, subscribeChanges: (handler) => { changed = handler; return () => {}; }, - cancelMessage: async () => {}, reconcileMessage: async () => {}, - sessions: { readSnapshot: async () => { throw new Error('unexpected snapshot read'); } }, + cancelMessage: async (sessionId, messageId) => { cancelled.push([sessionId, messageId]); }, + reconcileMessage: async (sessionId, messageId) => { reconciled.push([sessionId, messageId]); }, + sessions: { readSnapshot: async () => { throw new Error('unexpected snapshot read'); }, promoteQueueEntry: async () => undefined, updateQueueEntry: async () => undefined, retractQueueEntry: async () => undefined, reorderQueueEntries: async () => undefined }, skills: { listInvocable: async () => [] }, workspace: { searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, newTasks: { subscribeChanges: () => () => {}, listInvocableSkills: async () => [], searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, @@ -53,20 +66,46 @@ test('local delivery recovery cannot republish accepted Host queue rows', async publish: (_id, message) => { transient.set(message.id, message); }, retire: (_id, messageId) => { transient.delete(messageId); }, reportError: (message) => { throw new Error(message); }, + restoreDraft: (sessionId, draft) => { restored.push([sessionId, draft.text]); }, }) }), }))); - assert.equal(transient.get('steering')?.deliveryActions?.length, 1, 'unconfirmed sends retain their receipt check'); - messages = messages.map((message) => ({ ...message, state: 'accepted', ...(message.messageId === 'root' ? { turnId: 'started-turn' } : {}) })); + const steering = transient.get('steering'); + assert.equal(steering?.deliveryStatus, 'Delivery unconfirmed. Do not send again.'); + assert.deepEqual(steering?.deliveryActions?.map((action) => action.label), ['Check delivery'], + 'an unconfirmed send offers only its receipt check, never cancellation'); + const followup = transient.get('followup'); + assert.equal(followup?.deliveryStatus, 'Waiting to send'); + assert.deepEqual(followup?.deliveryActions?.map((action) => action.label), ['Edit', 'Cancel sending']); + await act(async () => { await steering?.deliveryActions?.[0]?.onClick(); }); + assert.deepEqual(reconciled, [['session-1', 'steering']]); + assert.equal(transient.has('steering'), true, 'checking delivery does not retire the row'); + await act(async () => { await followup?.deliveryActions?.[0]?.onClick(); }); + assert.deepEqual(cancelled, [['session-1', 'followup']]); + assert.deepEqual(restored, [['session-1', 'followup']], + 'editing a never-dispatched message returns its text to the composer'); + assert.equal(transient.has('followup'), false, 'edit retires the local row'); + messages = [ + { ...messages[0]!, state: 'failed', canCancel: true }, + { sessionId: 'session-1', messageId: 'settled', createdAt: 4, state: 'accepted', canCancel: false, + text: 'settled', attachments: [], inlineReferences: [], placement: 'next_turn' }, + { ...messages[2]!, state: 'accepted', turnId: 'started-turn' }, + { sessionId: 'session-1', messageId: 'later', createdAt: 5, state: 'saved', canCancel: true, + text: 'later', attachments: [], inlineReferences: [], placement: 'next_turn' }, + ]; await act(async () => changed('session-1')); - assert.deepEqual([...transient.keys()], ['root']); + const failed = transient.get('steering'); + assert.equal(failed?.deliveryStatus, 'Could not send · message kept'); + assert.deepEqual(failed?.deliveryActions?.map((action) => action.label), ['Edit', 'Delete unsent message']); + assert.deepEqual([...transient.keys()], ['steering', 'root', 'later']); assert.equal(transient.get('root')?.transientPlacement, 'current_turn'); await act(async () => changed('session-1')); - assert.deepEqual([...transient.keys()], ['root'], 'a retained local copy cannot resurrect a withdrawn queue entry'); + assert.deepEqual([...transient.keys()], ['steering', 'root', 'later'], + 'a retained local copy cannot resurrect a withdrawn queue entry'); }); -test('queue_update events drive the independent desktop queue projection', () => { +test('queue_update stores the snapshot and retires every listed local placeholder', async () => { const controller = createAppShellSessionUiStateController(); - const transientMessages = new Set(['message-steer', 'message-next']); + const transientMessages = new Map(); const handlers = createAppShellSessionEventHandlers({ uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, @@ -76,8 +115,9 @@ test('queue_update events drive the independent desktop queue projection', () => setLiveTurnBySession: controller.setLiveTurnBySession, setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, - removeTransientMessage: (_sessionId, messageId) => - transientMessages.delete(messageId), + removeTransientMessage: (_sessionId, messageId) => { + transientMessages.delete(messageId); + }, showModelSetupToast() {}, toastApi: { error() {} }, }); @@ -88,90 +128,42 @@ test('queue_update events drive the independent desktop queue projection', () => placement: 'current_turn' as const, state: 'queued' as const, }; - const inFlightEntry = { - ...steeringEntry, - state: 'in_flight' as const, + const followupEntry = { + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn' as const, + state: 'queued' as const, }; - - handlers.handleEvent('session-1', { - type: 'queue_update', + const queueUpdate = (steering: import('@maka/core/events').MessageQueueEntryProjection[]) => ({ + type: 'queue_update' as const, id: 'queue-1', turnId: 'turn-1', ts: 1, queueRevision: 3, steering: ['adjust this run'], followup: ['do this next'], - steeringEntries: [steeringEntry], - followupEntries: [{ - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }], + steeringEntries: steering, + followupEntries: [followupEntry], }); - - assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { - queueRevision: 3, - entries: [ - steeringEntry, - { - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }, - ], + transientMessages.set('message-next', { + id: 'message-next', text: 'do this next', ts: 1, transientPlacement: 'next_turn', }); - assert.equal(transientMessages.size, 0, 'Host evidence retires local placeholders'); - - handlers.handleEvent('session-1', { - type: 'steering_message', - id: 'steering-message-steer', - turnId: 'turn-1', - messageId: 'message-steer', - ts: 2, - content: { text: 'adjust this run' }, - }); - assert.equal(transientMessages.size, 0); - assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { - queueRevision: 3, - entries: [{ - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }], + transientMessages.set('message-steer', { + id: 'message-steer', text: 'adjust this run', ts: 1, transientPlacement: 'current_turn', + pendingSteering: true, }); - handlers.handleEvent('session-1', { - type: 'queue_update', - id: 'queue-2', + handlers.handleEvent('session-1', queueUpdate([steeringEntry])); + + assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { turnId: 'turn-1', - ts: 3, - queueRevision: 4, - steering: ['adjust this run'], - followup: ['do this next'], - steeringEntries: [inFlightEntry], - followupEntries: [{ - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }], + ts: 1, + queueRevision: 3, + entries: [steeringEntry, followupEntry], }); - assert.deepEqual(controller.getState().messageQueueBySession['session-1']?.entries, [{ - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }]); - assert.equal(transientMessages.size, 0); - assert.equal(transientMessages.size, 0, 'in-flight queue projection must not re-add a local row'); + assert.equal(transientMessages.size, 0, + 'the store keeps no copy of entries the Host snapshot now owns — queued steering derives from it at render'); handlers.handleEvent('session-1', { type: 'message_admission', @@ -265,3 +257,77 @@ test('complete events deliver the durable context compaction outcome to Desktop' }, ]); }); + +test('editing a queued steering restores content under the owning Session even after navigation', async () => { + const { root } = installReactRenderer(); + const entry = { + entryId: 'entry-1', messageId: 'message-1', + placement: 'current_turn' as const, state: 'queued' as const, + content: { + text: 'steer it', + attachments: [{ + kind: 'other' as const, name: 'a.png', mimeType: 'image/png', bytes: 1, + ref: { kind: 'external_file' as const, absolutePath: '/tmp/a.png' }, + }], + quotes: [{ text: 'quoted' }], + }, + }; + const retracted: string[][] = []; + const restoredDrafts: [string, string][] = []; + const restoredContext: [string, RestoredDraftContent][] = []; + // The user navigated to another Session before the retract resolves. + const activeSessionId = { current: 'session-b' as string | undefined }; + let surface!: ReturnType; + function Probe() { + surface = useSessionMessageQueue({ + sessionId: 'session-a', + queue: { entries: [entry], turnId: 'turn-1', ts: 1, queueRevision: 2 }, + transientMessages: [], + activeSessionId, + }); + return null; + } + await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: + createElement(ConversationServicesProvider, { services: { + listMessages: async () => [], + subscribeChanges: () => () => {}, + cancelMessage: async () => {}, + reconcileMessage: async () => {}, + sessions: { + readSnapshot: async () => { throw new Error('unexpected snapshot read'); }, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + retractQueueEntry: async (sessionId: string, entryId: string) => { + retracted.push([sessionId, entryId]); + }, + reorderQueueEntries: async () => undefined, + }, + skills: { listInvocable: async () => [] }, + workspace: { searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, + newTasks: { subscribeChanges: () => () => {}, listInvocableSkills: async () => [], searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, + mcp: { subscribeChanges: () => () => {} }, + }, children: createElement(Probe) }) })}))); + surface.composer.current = { + setText() {}, appendText() {}, getText: () => '', clearDraft() {}, + setDraft: (key, text) => { restoredDrafts.push([key, text]); }, + getDraft: () => '', + appendDraft: (key, text) => { restoredDrafts.push([key, text]); }, + focus() {}, openModelPicker() {}, + } as ComposerHandle; + surface.draftContextRestorer.current = (sessionId, draft) => { restoredContext.push([sessionId, draft]); }; + const bubble = surface.transientMessages.find((message) => message.pendingSteering); + assert.ok(bubble, 'a queued steering entry derives a transcript bubble'); + const edit = bubble.deliveryActions?.find((action) => action.label === 'Edit'); + assert.ok(edit, 'the bubble offers edit'); + await act(async () => { await edit.onClick(); }); + assert.deepEqual(retracted, [['session-a', 'entry-1']]); + assert.equal(restoredContext.length, 1); + assert.equal(restoredContext[0]![0], 'session-a'); + assert.equal(restoredContext[0]![1].attachments, entry.content.attachments, + 'attachments ride back into the draft'); + assert.equal(restoredContext[0]![1].quotes, entry.content.quotes, + 'quotes ride back into the draft'); + assert.deepEqual(restoredDrafts, [['session-a', 'steer it']], + 'the draft lands under the owning Session even while another is active'); +}); diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index 2c8d23d90a..3cad851bb0 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -52,6 +52,13 @@ const sessionLocalServices: Pick< subscribeChanges: () => () => undefined, }; +const queueStubs = { + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + retractQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, +}; + afterEach(async () => { if (root) await act(() => root?.unmount()); root = undefined; @@ -102,6 +109,7 @@ test('Session reference picker keeps same-Host sessions and send waits for the s ...sessionLocalServices, sessions: { readSnapshot: async () => snapshot, + ...queueStubs, }, skills: { listInvocable: async () => [] }, workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, @@ -241,6 +249,7 @@ test('send resolves the selected Session snapshot at the send boundary', async ( const services: ConversationServices = { ...sessionLocalServices, sessions: { + ...queueStubs, readSnapshot: async () => new Promise((resolve) => { reads += 1; queueMicrotask(() => resolve({ @@ -345,6 +354,7 @@ test('ignores a snapshot that resolves after the Composer owner changes', async const services: ConversationServices = { ...sessionLocalServices, sessions: { + ...queueStubs, readSnapshot: async () => new Promise((resolve) => { release = resolve; }), diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index f4d3977038..2ea5e71e34 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -20,7 +20,7 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { @@ -28,6 +28,10 @@ import { SessionCatalogContext, } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; +import { + ConversationServicesProvider, + type ConversationServices, +} from '../../renderer/features/conversation/index.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; @@ -54,6 +58,32 @@ function actionKeys(workspace: Workspace): string[] { ); } +function stubConversationServices(): ConversationServices { + return { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => undefined, + skills: { listInvocable: async () => [] }, + sessions: { + readSnapshot: async () => { + throw new Error('Session snapshot is not used in this test'); + }, + promoteQueueEntry: async () => undefined, + updateQueueEntry: async () => undefined, + retractQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, + }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, + }; +} + describe('session workspace action identity', () => { afterEach(cleanupFakeDom); @@ -65,6 +95,7 @@ describe('session workspace action identity', () => { const catalog = createSessionCatalogController(); let workspace!: Workspace; const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = []; + const services = stubConversationServices(); function Probe(): null { workspace = useAppShellSessionWorkspace({ error: () => {} }); displays.push({ id: workspace.activeId, messages: workspace.messages }); @@ -72,8 +103,13 @@ describe('session workspace action identity', () => { } act(() => root.render(createElement(LocaleProvider, { locale: 'en', - children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services, + children: createElement(SessionCatalogContext.Provider, { + value: catalog, children: createElement(Probe), + }), + }), }), }))); act(() => workspace.seedSessions([sessionA, sessionB, sessionC].map((id) => ({ @@ -186,8 +222,13 @@ describe('session workspace action identity', () => { root.render( createElement(LocaleProvider, { locale: 'en', - children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services: stubConversationServices(), + children: createElement(SessionCatalogContext.Provider, { + value: catalog, children: createElement(Probe), + }), + }), }), }), ); diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index d64678990a..4d8ce4cd10 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -24,8 +24,8 @@ import type { TransientUserMessageProjection } from '@maka/ui'; import { deriveMessageQueueProjection } from '../../renderer/application/contracts/message-queue-projection.js'; import { mergeTransientMessageProjection, - projectQueuedTransientMessages, reconcileTransientMessages, + withQueuedSteeringTransients, } from '../../renderer/application/contracts/transient-message-projection.js'; /** The durable Message that replaces the transient row above. */ @@ -103,35 +103,13 @@ test('keeps transient messages ordered independently from a sparse durable tail' assert.deepEqual(projected.map((message) => message.id), ['message-1']); }); -test('uses the Host queue snapshot order for already-present transient messages', () => { - const localSecond = { - ...transient, - id: 'message-2', - turnId: 'message-2', - text: 'second', - }; - const remoteFirst = { - ...transient, - id: 'message-1', - turnId: 'message-1', - text: 'first', - }; - const pending = new Map([[localSecond.id, localSecond]]); - - projectQueuedTransientMessages(pending, [remoteFirst, localSecond]); - - assert.deepEqual( - reconcileTransientMessages(pending, []).map((message) => message.id), - ['message-1', 'message-2'], - ); -}); - test('derives one queue projection for main and Side Conversation consumers', () => { const projection = deriveMessageQueueProjection({ type: 'queue_update', id: 'queue-1', turnId: 'turn-1', ts: 7, + queueRevision: 3, steering: ['in flight', 'steer'], followup: ['next'], steeringEntries: [ @@ -145,7 +123,7 @@ test('derives one queue projection for main and Side Conversation consumers', () { entryId: 'steer', messageId: 'message-steer', - content: { text: 'raw', displayText: 'steer', quotes: [{ text: 'context' }] }, + content: { text: 'raw', displayText: 'steer' }, placement: 'current_turn', state: 'queued', }, @@ -161,24 +139,79 @@ test('derives one queue projection for main and Side Conversation consumers', () ], }); - assert.deepEqual(projection.entries.map((entry) => entry.entryId), ['steer', 'next']); - assert.deepEqual(projection.transientMessages, [ - { - id: 'message-steer', - pendingSteering: true, - transientPlacement: 'current_turn', - hostTurnId: 'turn-1', - ts: 7, - text: 'steer', - quotes: [{ text: 'context' }], - }, - { - id: 'message-next', - transientPlacement: 'next_turn', - ts: 7, - text: 'next', + assert.deepEqual(projection, { + turnId: 'turn-1', + ts: 7, + queueRevision: 3, + entries: [ + { + entryId: 'steer', + messageId: 'message-steer', + content: { text: 'raw', displayText: 'steer' }, + placement: 'current_turn', + state: 'queued', + }, + { + entryId: 'next', + messageId: 'message-next', + content: { text: 'next' }, + placement: 'next_turn', + state: 'queued', + }, + ], + }); +}); + +test('queued steering derives a transcript bubble that lives and dies with the snapshot', async () => { + const queueEntry = { + entryId: 'steer', + messageId: 'message-steer', + content: { text: 'raw', displayText: 'steer', quotes: [{ text: 'context' }] }, + placement: 'current_turn' as const, + state: 'queued' as const, + }; + const followupEntry = { + entryId: 'next', + messageId: 'message-next', + content: { text: 'follow up' }, + placement: 'next_turn' as const, + state: 'queued' as const, + }; + const queue = { turnId: 'turn-1', ts: 7, entries: [queueEntry, followupEntry] }; + const retracted: (string | undefined)[][] = []; + const actions = { + locale: 'en' as const, + retract: async (_entry: unknown, draftText?: string) => { + retracted.push([draftText]); + return true; }, - ]); + }; + const localCopy = { ...transient, id: 'message-steer', text: 'local copy' }; + + const derived = withQueuedSteeringTransients([transient, localCopy], queue, actions); + + assert.deepEqual(derived.map((message) => message.id), ['message-1', 'message-steer'], + 'the queue-owned bubble replaces its stored local copy, and a queued follow-up stays out of the transcript'); + const bubble = derived.at(-1); + assert.equal(bubble?.pendingSteering, true); + assert.equal(bubble?.hostTurnId, 'turn-1'); + assert.equal(bubble?.text, 'steer'); + assert.deepEqual(bubble?.quotes, [{ text: 'context' }]); + assert.deepEqual(bubble?.deliveryActions?.map((action) => action.label), ['Edit', 'Delete']); + await bubble?.deliveryActions?.[0]?.onClick(); + assert.deepEqual(retracted, [['steer']], 'edit hands the text back to the composer'); + await bubble?.deliveryActions?.[1]?.onClick(); + assert.deepEqual(retracted.at(-1), [undefined], 'delete retracts without a draft'); + + assert.equal( + withQueuedSteeringTransients([transient], { ...queue, entries: [] }, actions).length, + 1, + 'an entry gone from the snapshot leaves no bubble behind', + ); + assert.equal( + withQueuedSteeringTransients([transient], undefined, actions).length, + 1, + ); }); test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { 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 be8eba88b5..cfd9b5e0f7 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -608,7 +608,7 @@ test('steering observed before its admission response renders once and outranks }); -test('WorkHub Host queue owns restored, consumed and retracted rows without transient mirrors', async () => { +test('WorkHub queued steering keeps a transcript bubble until consumed or retracted', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); const entry = { entryId: 'queued', messageId: 'queued', placement: 'current_turn' as const, state: 'queued' as const, content: { text: 'change direction' } }; @@ -618,12 +618,16 @@ test('WorkHub Host queue owns restored, consumed and retracted rows without tran }); await act(() => project('queued')); assert.deepEqual(h.controller.messageQueue.entries, [entry]); - assert.deepEqual(h.controller.transientMessages, []); + assert.deepEqual(h.controller.transientMessages.map((message) => message.id), ['queued'], + 'queued steering waits in the transcript, not the follow-up plate'); + assert.equal(h.controller.transientMessages[0]?.pendingSteering, true); + assert.equal(h.controller.transientMessages[0]?.hostTurnId, 'active-turn'); await act(() => h.emit({ type: 'steering_message', id: 'consumed', turnId: 'active-turn', ts: 3, messageId: entry.messageId, content: entry.content })); await act(() => project('in_flight')); assert.deepEqual(h.controller.messageQueue.entries, []); assert.deepEqual(h.controller.transientMessages, [], 'an in-flight snapshot cannot resurrect consumed steering'); await act(() => project('queued')); + assert.equal(h.controller.transientMessages.length, 1); await act(async () => { await h.controller.deleteQueuedEntry(entry.entryId); }); await act(() => h.emit({ type: 'queue_update', id: 'removed', turnId: 'active-turn', ts: 4, steering: [], followup: [], steeringEntries: [] })); assert.deepEqual(h.controller.messageQueue.entries, []); @@ -650,7 +654,8 @@ test('WorkHub sends queue edits, withdrawal and both queue orders to the Host an await act(() => h.emit({ type: 'queue_update', id: 'updated', turnId: 'active-turn', ts: 3, queueRevision: 10, steering: ['edited second'], followup: [], steeringEntries: [{ ...entries[1]!, content: { text: 'edited second' } }] })); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.content.text), ['edited second']); - assert.deepEqual(h.controller.transientMessages, []); + assert.deepEqual(h.controller.transientMessages.map((message) => message.text), ['edited second'], + 'the queue snapshot retires the edited-out steering bubble and republishes the edited one'); }); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index efd4cc0d9b..68474c2a88 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -118,7 +118,6 @@ export function createAppShellSessionEventHandlers(options: { } = options; const scheduleFrame = options.scheduleFrame ?? createConversationDisplayFrameScheduler(); const displayBatch = options.displayBatch ?? createAppShellSessionDisplayBatch(); - function applyProjectionEvents( projection: LiveTurnBuffer | undefined, events: readonly SessionEvent[], @@ -302,6 +301,9 @@ export function createAppShellSessionEventHandlers(options: { switch (event.type) { case 'queue_update': { const queue = deriveMessageQueueProjection(event); + // Queued steering renders from this snapshot as transcript bubbles — + // the store only holds local intents, so every entry the Host lists + // retires its local copy here. for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) { removeTransientMessage?.(sessionId, entry.messageId); } @@ -314,10 +316,7 @@ export function createAppShellSessionEventHandlers(options: { } return { ...current, - [sessionId]: { - queueRevision: event.queueRevision, - entries: queue.entries, - }, + [sessionId]: queue, }; }); break; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index eaafc7b180..fc57215127 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -331,6 +331,7 @@ function AppShellContent({ sharedSessionActive, ownerActiveId, switchingSession, + queueSurface, } = useAppShellSessionWorkspace(toastApi); // The shell's own reading of the catalog rides the membership set the list // hook already publishes — background row churn belongs to the rail, which @@ -384,6 +385,7 @@ function AppShellContent({ pickAttachments, attachFilePaths, restoreAttachments, + restoreDirectories, removeAttachment, clearSubmittedContext, imageNoticeLifecycle, @@ -599,7 +601,6 @@ function AppShellContent({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); - const composerRef = useRef(null); const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); }, []); @@ -671,6 +672,26 @@ function AppShellContent({ } : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; + // Queued steering is a thin projection of the Host queue snapshot — the + // bubble appears and disappears with it, no bookkeeping of our own. + const { + composer: composerRef, + transientMessages: transcriptTransientMessages, + restoreDraft: restoreLocalMessageDraft, + draftContextRestorer, + promoteQueuedEntry, + updateQueuedEntry, + deleteQueuedEntry, + reorderQueuedEntries, + } = queueSurface; + // A retracted send hands its staged context back through the same keyed + // stores a picked file or quote would land in — keyed by Session, so the + // restore survives the owning Session navigating away mid-request. + draftContextRestorer.current = (targetSessionId, draft) => { + if (draft.attachments?.length) restoreAttachments(targetSessionId, draft.attachments); + if (draft.directoryReferences?.length) restoreDirectories(targetSessionId, draft.directoryReferences); + if (draft.quotes?.length) restoreQuotes(targetSessionId, draft.quotes); + }; const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the @@ -1688,58 +1709,7 @@ function AppShellContent({ return ok; } - async function updateQueuedEntry( - entryId: string, - expectedQueueRevision: number, - text: string, - ): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text) - ); - } - async function deleteQueuedEntry(entryId: string): Promise { - const messageId = activeMessageQueue?.entries.find((entry) => entry.entryId === entryId)?.messageId; - const sessionId = await runQueueEntryAction((sessionId) => - window.maka.sessions.retractQueueEntry(sessionId, entryId).then(() => undefined) - ); - if (sessionId && messageId) removeTransientMessage(sessionId, messageId); - } - - // Surfaces the failure, then rethrows so the pending plate can settle its - // in-flight action state without guessing with a timer. - async function runQueueEntryAction( - action: (sessionId: string) => Promise, - ): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) return; - try { - await action(sessionId); - return sessionId; - } catch (error) { - if (activeIdRef.current === sessionId) { - const copy = getDesktopConversationCopy(uiLocale).actions; - showSessionError( - sessionId, - copy.operationFailedTitle, - localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), - ); - } - throw error; - } - } - - async function promoteQueuedEntry(entryId: string): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.promoteQueueEntry(sessionId, entryId).then(() => undefined) - ); - } - - async function reorderQueuedEntries(entryIds: readonly string[]): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.reorderQueueEntries(sessionId, entryIds).then(() => undefined) - ); - } const stop = createAppShellStopAction({ uiLocale, @@ -2117,7 +2087,13 @@ function AppShellContent({ canOpenDialog={activeBoundarySurface.localInteractionAvailable} reportError={showSessionError} > - + transcriptReadingCommands.current?.loadEarlier(turn.sequence)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} - transientMessages={transientMessages} + transientMessages={transcriptTransientMessages} messageLoading={activeMessageLoading} onStreamingSettled={ activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined diff --git a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts index b3f484380d..35b80fb723 100644 --- a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts @@ -21,11 +21,13 @@ import type { MessageQueueEntryProjection, QueueUpdateEvent, } from '@maka/core/events'; -import type { TransientUserMessageProjection } from '@maka/ui'; +/** The last Host queue snapshot a surface renders from. */ export interface MessageQueueProjection { + readonly turnId: string; + readonly ts: number; + readonly queueRevision?: number; readonly entries: readonly MessageQueueEntryProjection[]; - readonly transientMessages: readonly TransientUserMessageProjection[]; } /** One presentation contract for Host queue snapshots in every chat surface. */ @@ -39,24 +41,9 @@ export function deriveMessageQueueProjection( .filter((entry) => entry.state === 'queued') .map((entry) => structuredClone(entry)); return { + turnId: event.turnId, + ts: event.ts, + queueRevision: event.queueRevision, entries, - transientMessages: entries.map((entry) => ({ - id: entry.messageId, - transientPlacement: entry.placement, - ...(entry.placement === 'current_turn' && { - hostTurnId: event.turnId, - pendingSteering: true, - }), - ts: event.ts, - text: entry.content.displayText ?? entry.content.text, - ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), - ...(entry.content.directoryReferences && { - directoryReferences: entry.content.directoryReferences, - }), - ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), - ...(entry.content.inlineReferences && { - inlineReferences: [...entry.content.inlineReferences], - }), - })), }; } diff --git a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts index 4b89d962ed..5a52a94db4 100644 --- a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts @@ -17,26 +17,103 @@ * under the License. */ +import { createElement } from 'react'; +import type { + AttachmentRef, + DirectoryReference, + MessageQueueEntryProjection, + QuoteRef, +} from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; -import type { TransientUserMessageProjection } from '@maka/ui'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { getConversationCopy, type TransientUserMessageProjection } from '@maka/ui'; +import { ICON_SIZE, Pencil, Trash2 } from '@maka/ui/icons'; type TransientUserMessage = TransientUserMessageProjection; /** - * Replace the queue-backed subset in the exact order supplied by the Host. - * Other local intents keep their relative position because queue absence is - * not cancellation or delivery proof. + * What a retracted send hands back to the composer: the editable text plus + * the staged context (attachments, directory references, quotes) that rode + * with it. `text` is the editable serialization — `displayText` when the + * content carries a separate model-facing `text`. */ -export function projectQueuedTransientMessages( - transient: Map, - queued: readonly TransientUserMessage[], -): void { - if (queued.length === 0) return; - const queuedIds = new Set(queued.map((message) => message.id)); - const retained = [...transient.entries()].filter(([id]) => !queuedIds.has(id)); - transient.clear(); - for (const [id, message] of retained) transient.set(id, message); - for (const message of queued) transient.set(message.id, message); +export interface RestoredDraftContent { + text: string; + attachments?: readonly AttachmentRef[]; + directoryReferences?: readonly DirectoryReference[]; + quotes?: readonly QuoteRef[]; +} + +/** + * Edit/delete controls for steering the Host queued but has not consumed. + * Both retract the queue entry; edit also hands the text back to the caller's + * draft restore. `retract` resolves false when the Host call failed. + */ +export function queuedSteeringDeliveryActions(input: { + locale: UiLocale; + draftText: string; + retract: (draftText?: string) => Promise; +}): NonNullable { + const copy = getConversationCopy(input.locale).composer; + const icon = (glyph: typeof Pencil) => createElement(glyph, { size: ICON_SIZE.control, 'aria-hidden': true }); + return [ + { label: copy.editQueuedEntry, icon: icon(Pencil), onClick: async () => { await input.retract(input.draftText); } }, + { label: copy.deleteQueuedEntry, icon: icon(Trash2), onClick: async () => { await input.retract(); } }, + ]; +} + +/** + * Queued steering is a thin projection of the Host queue snapshot, not a stored + * transient: the bubble appears, updates and disappears with `queue` alone. + * Appends one transcript bubble per queued current_turn entry — with the + * retract-backed edit/delete actions — and drops any stored transient the + * queue now owns, so a message never renders twice. + */ +export function withQueuedSteeringTransients( + transientMessages: readonly TransientUserMessage[], + queue: + | { + readonly entries: readonly MessageQueueEntryProjection[]; + readonly turnId?: string; + readonly ts?: number; + } + | undefined, + actions: { + locale: UiLocale; + /** Retract the queue entry; resolves false when the Host call failed. */ + retract(entry: MessageQueueEntryProjection, draftText?: string): Promise; + }, +): TransientUserMessage[] { + const steering = (queue?.entries ?? []).filter( + (entry) => entry.placement === 'current_turn' && entry.state === 'queued', + ); + if (steering.length === 0) return [...transientMessages]; + const bubbles = steering.map((entry): TransientUserMessage => ({ + id: entry.messageId, + transientPlacement: 'current_turn', + pendingSteering: true, + hostTurnId: queue?.turnId, + ts: queue?.ts ?? 0, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), + ...(entry.content.directoryReferences && { + directoryReferences: entry.content.directoryReferences, + }), + ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), + ...(entry.content.inlineReferences && { + inlineReferences: [...entry.content.inlineReferences], + }), + deliveryActions: queuedSteeringDeliveryActions({ + locale: actions.locale, + draftText: entry.content.displayText ?? entry.content.text, + retract: (draftText) => actions.retract(entry, draftText), + }), + })); + const ids = new Set(bubbles.map((message) => message.id)); + return [ + ...transientMessages.filter((message) => !ids.has(message.id)), + ...bubbles, + ]; } /** diff --git a/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx b/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx index 0e08e4679b..65f1f253f5 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx @@ -19,7 +19,9 @@ import { useEffect } from 'react'; import { useUiLocale, type TransientUserMessageProjection } from '@maka/ui'; +import { ICON_SIZE, Pencil, Search, Trash2 } from '@maka/ui/icons'; import { getSessionLocalCopy } from '../../../locales/session-local-copy.js'; +import type { RestoredDraftContent } from '../../../application/contracts/transient-message-projection.js'; import { useConversationServices } from '../services.js'; export function SessionLocalMessages(props: { @@ -27,10 +29,12 @@ export function SessionLocalMessages(props: { readonly publish: (sessionId: string, message: TransientUserMessageProjection) => void; readonly retire: (sessionId: string, messageId: string) => void; readonly reportError: (message: string) => void; + /** Puts a never-dispatched message's content back into the composer for editing. */ + readonly restoreDraft?: (sessionId: string, draft: RestoredDraftContent) => void; }): null { const services = useConversationServices(); const locale = useUiLocale(); - const { sessionId, publish, retire, reportError } = props; + const { sessionId, publish, retire, reportError, restoreDraft } = props; useEffect(() => { if (!sessionId) return; let disposed = false; @@ -49,9 +53,8 @@ export function SessionLocalMessages(props: { retire(sessionId, message.messageId); continue; } - const action = (operation: () => Promise) => () => { - void operation().catch(() => reportError(copy.updateError)); - }; + const action = (operation: () => Promise) => () => + operation().catch(() => reportError(copy.updateError)); publish(sessionId, { id: message.messageId, text: message.text, @@ -64,23 +67,43 @@ export function SessionLocalMessages(props: { hostTurnId: message.turnId, deliveryStatus: copy[message.state], deliveryDetail: message.error, - deliveryActions: message.canCancel + deliveryActions: message.state === 'unknown' ? [ { - label: copy.remove, - onClick: action(async () => { - await services.cancelMessage(sessionId, message.messageId); - retire(sessionId, message.messageId); - }), + label: copy.check, + icon: