From c52b8361a3aa693047729d3c625c701aba249d5f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 15:40:23 +0800 Subject: [PATCH 01/12] fix(ui): make queued message delivery understandable Explain unconfirmed delivery and the messages waiting behind it without exposing Host terminology. Use labelled icon actions with tooltips and one body text size across queue content and status. Keep the existing Host admission, reconciliation identity and cancellation authority. Extend the existing controller, composer and recovery checks and reuse the queue story for unknown and failed delivery states. No new delivery state or scheduler is introduced. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Generated-by: Devin --- .../e2e/session-local-recovery.spec.ts | 8 +- .../__tests__/message-queue-ui-state.test.ts | 54 ++++-- .../controller/session-local-messages.tsx | 19 +- .../renderer/locales/session-local-copy.ts | 50 ++--- apps/desktop/src/renderer/styles/composer.css | 7 +- .../composer-message-queue.stories.tsx | 178 ++++++++++++++---- docs/astryx-surface-file-inventory.md | 2 +- .../__tests__/composer-send-toggle.test.tsx | 28 ++- packages/ui/src/chat-turn.tsx | 2 +- packages/ui/src/chat-view.tsx | 2 +- packages/ui/src/composer-message-queue.tsx | 21 ++- packages/ui/src/conversation-copy.ts | 12 +- 12 files changed, 282 insertions(+), 101 deletions(-) diff --git a/apps/desktop/e2e/session-local-recovery.spec.ts b/apps/desktop/e2e/session-local-recovery.spec.ts index 75b6cdf8e7..fa3787e74a 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.getByText('等待发送')).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.getByText('等待发送')).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.getByText('等待发送')).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.getByText('等待发送')).toBeVisible(); await expect(page.getByText('读取任务失败', { exact: true })).toHaveCount(0); page = await sessionLocalWindow.restart(); await ensureSidebarExpanded(page); 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..72792cccf6 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 @@ -33,17 +33,23 @@ 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[][] = []; + 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: { list: async () => [], subscribeChanges: () => () => {}, readSnapshot: async () => { throw new Error('unexpected snapshot read'); } }, 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 }) }, @@ -55,13 +61,39 @@ test('local delivery recovery cannot republish accepted Host queue rows', async reportError: (message) => { throw new Error(message); }, }) }), }))); - 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 for earlier messages to be delivered', + 'a saved row behind an unresolved predecessor explains the wait'); + assert.deepEqual(followup?.deliveryActions?.map((action) => action.label), ['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.equal(transient.has('followup'), false, 'cancel 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), ['Delete unsent message']); + assert.equal(transient.get('later')?.deliveryStatus, 'Waiting to send', + 'failed and accepted predecessors do not hold back a later saved row'); + 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', () => { 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..c968cd7388 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,6 +19,7 @@ import { useEffect } from 'react'; import { useUiLocale, type TransientUserMessageProjection } from '@maka/ui'; +import { ICON_SIZE, Search, Trash2, X } from '@maka/ui/icons'; import { getSessionLocalCopy } from '../../../locales/session-local-copy.js'; import { useConversationServices } from '../services.js'; @@ -42,6 +43,7 @@ export function SessionLocalMessages(props: { .listMessages(sessionId) .then((messages) => { if (disposed || revision !== admitted) return; + let waitingForPrevious = false; for (const message of messages) { if (message.state === 'accepted' && !message.turnId) { // The Host queue owns accepted steering and follow-ups. A local @@ -49,9 +51,12 @@ export function SessionLocalMessages(props: { retire(sessionId, message.messageId); continue; } - const action = (operation: () => Promise) => () => { - void operation().catch(() => reportError(copy.updateError)); - }; + const deliveryStatus = message.state === 'saved' && waitingForPrevious + ? copy.waitingForPrevious + : copy[message.state]; + if (message.state !== 'accepted' && message.state !== 'failed') waitingForPrevious = true; + const action = (operation: () => Promise) => () => + operation().catch(() => reportError(copy.updateError)); publish(sessionId, { id: message.messageId, text: message.text, @@ -62,12 +67,15 @@ export function SessionLocalMessages(props: { quotes: message.quotes, inlineReferences: message.inlineReferences, hostTurnId: message.turnId, - deliveryStatus: copy[message.state], + deliveryStatus, deliveryDetail: message.error, deliveryActions: message.canCancel ? [ { - label: copy.remove, + label: message.state === 'failed' ? copy.remove : copy.cancel, + icon: message.state === 'failed' + ?