You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/26 14:58:03 UTC

(maka) 12/26: fix(desktop): keep live message projection single-owned

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch fix/canonical-message-projection
in repository https://gitbox.apache.org/repos/asf/maka.git

commit a4612c21901de41da02da1cd2ad4e93e4846ec02
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Aug 26 06:13:27 2026 +0800

    fix(desktop): keep live message projection single-owned
    
    Generated-by: Codex
---
 .../app-shell-busy-race-settlement.test.ts         | 50 ++++++++++++++++++++++
 .../main/__tests__/message-queue-ui-state.test.ts  | 45 ++++++++++---------
 .../src/main/__tests__/streaming-handoff.test.ts   | 31 ++++++++++++++
 .../desktop/src/renderer/app-shell-chat-actions.ts | 22 +++++++++-
 .../src/renderer/app-shell-session-events.ts       |  1 +
 apps/desktop/src/renderer/app-shell.tsx            |  2 +
 .../renderer/use-app-shell-session-workspace.ts    | 10 +++++
 packages/ui/src/chat-view.tsx                      | 27 ++++++++++--
 8 files changed, 163 insertions(+), 25 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
index 465c6dd79..433237be7 100644
--- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
@@ -199,6 +199,56 @@ describe('busy-raced send settlement', () => {
     }
   });
 
+  it('does not resurrect a Follow Up retracted before its IPC reply settles', async () => {
+    const transient = new Map<string, StoredMessage>();
+    let submittedMessageId: string | undefined;
+    let releaseAdmission!: () => void;
+    const admission = new Promise<void>((resolve) => {
+      releaseAdmission = resolve;
+    });
+    let observeSubmit!: () => void;
+    const submitted = new Promise<void>((resolve) => {
+      observeSubmit = resolve;
+    });
+    const restoreWindow = installWindow({
+      sessions: {
+        enqueue: async (_sessionId: string, _placement: string, command: { messageId: string }) => {
+          submittedMessageId = command.messageId;
+          observeSubmit();
+          await admission;
+          return {
+            kind: 'queued' as const,
+            messageId: command.messageId,
+            attachments: [],
+            inlineReferences: [],
+          };
+        },
+      },
+    });
+    try {
+      const actions = createAppShellChatActions({
+        ...createActionsDeps(),
+        activeIdRef: { current: 'session-a' },
+        addTransientMessage: (_sessionId, message) => transient.set(message.id, message),
+        updateTransientMessage: (_sessionId, message) => {
+          if (transient.has(message.id)) transient.set(message.id, message);
+        },
+        removeTransientMessage: (_sessionId, messageId) => transient.delete(messageId),
+      });
+      const sending = actions.enqueueMessage('session-a', 'do this next', 'next_turn');
+      await submitted;
+
+      assert.ok(submittedMessageId);
+      transient.delete(submittedMessageId);
+      releaseAdmission();
+      await sending;
+
+      assert.deepEqual([...transient.keys()], []);
+    } finally {
+      restoreWindow();
+    }
+  });
+
   it('shows one stable local message before Host admission settles', async () => {
     const activeIdRef = { current: 'session-a' as string | undefined };
     const transient = new Map<string, StoredMessage>();
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 134ecb1dc..9806a97fd 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
@@ -50,9 +50,6 @@ test('queue_update events drive the independent desktop queue projection', () =>
   };
   const inFlightEntry = {
     ...steeringEntry,
-    entryId: 'entry-delivering',
-    messageId: 'message-delivering',
-    content: { text: 'already delivering' },
     state: 'in_flight' as const,
   };
 
@@ -64,7 +61,7 @@ test('queue_update events drive the independent desktop queue projection', () =>
     queueRevision: 3,
     steering: ['adjust this run'],
     followup: ['do this next'],
-    steeringEntries: [steeringEntry, inFlightEntry],
+    steeringEntries: [steeringEntry],
     followupEntries: [{
       entryId: 'entry-next',
       messageId: 'message-next',
@@ -95,13 +92,6 @@ test('queue_update events drive the independent desktop queue projection', () =>
       ts: 1,
       text: 'adjust this run',
     },
-    {
-      type: 'user',
-      id: 'message-delivering',
-      turnId: 'message-delivering',
-      ts: 1,
-      text: 'already delivering',
-    },
     {
       type: 'user',
       id: 'message-next',
@@ -113,13 +103,13 @@ test('queue_update events drive the independent desktop queue projection', () =>
 
   handlers.handleEvent('session-1', {
     type: 'steering_message',
-    id: 'steering-message-delivering',
+    id: 'steering-message-steer',
     turnId: 'turn-1',
-    messageId: 'message-delivering',
+    messageId: 'message-steer',
     ts: 2,
-    content: { text: 'already delivering' },
+    content: { text: 'adjust this run' },
   });
-  assert.deepEqual(removedTransientMessageIds, ['message-delivering']);
+  assert.deepEqual(removedTransientMessageIds, ['message-steer']);
 
   handlers.handleEvent('session-1', {
     type: 'queue_update',
@@ -127,11 +117,26 @@ test('queue_update events drive the independent desktop queue projection', () =>
     turnId: 'turn-1',
     ts: 3,
     queueRevision: 4,
-    steering: [],
-    followup: [],
+    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',
+    }],
   });
-  assert.equal(controller.getState().messageQueueBySession['session-1'], undefined);
-  assert.deepEqual(removedTransientMessageIds, ['message-delivering']);
+  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.deepEqual(removedTransientMessageIds, ['message-steer']);
+  assert.equal(transientMessages.length, 3, 'in-flight queue projection must not re-add the row');
 
   handlers.handleEvent('session-1', {
     type: 'message_admission',
@@ -141,7 +146,7 @@ test('queue_update events drive the independent desktop queue projection', () =>
     messageId: 'message-next',
     outcome: 'retracted',
   });
-  assert.deepEqual(removedTransientMessageIds, ['message-delivering', 'message-next']);
+  assert.deepEqual(removedTransientMessageIds, ['message-steer', 'message-next']);
 });
 
 test('complete events deliver the durable context compaction outcome to Desktop', () => {
diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
index 2fd10913d..ea0e6a0dd 100644
--- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
@@ -112,6 +112,37 @@ describe('single live-turn handoff', () => {
     assert.match(markup, />send now</);
   });
 
+  it('shows a loading transient before its real live Turn answer', () => {
+    const markup = renderWithLocale(createElement(ChatView, {
+      activeSession: {
+        id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk',
+        labels: [], isFlagged: false, isArchived: false, hasUnread: false,
+        llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask',
+      },
+      messages: [],
+      transientMessages: [
+        { type: 'user', id: 'turn-1', turnId: 'turn-1', ts: 1, text: 'send now' },
+      ],
+      messageLoading: true,
+      scrollBehavior: 'smooth',
+      liveTurn: {
+        turnId: 'turn-1',
+        phase: 'streamed',
+        steps: [{
+          stepId: 'assistant-1',
+          text: { text: 'live answer', truncated: false, complete: false },
+          tools: [],
+        }],
+      },
+      onNew() {},
+    } satisfies Parameters<typeof ChatView>[0]));
+
+    assert.doesNotMatch(markup, /maka-chat-message-loading/);
+    assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"'));
+    assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1);
+    assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1);
+  });
+
   it('renders one ordered timeline: thinking before its tool and answer', () => {
     const markup = renderLiveTurn({
       turnId: 'turn-1',
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 1becd009e..9c8b4d6a8 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -158,6 +158,10 @@ export function createAppShellChatActions(deps: {
     sessionId: string,
     message: Extract<StoredMessage, { type: 'user' }>,
   ) => void;
+  updateTransientMessage?: (
+    sessionId: string,
+    message: Extract<StoredMessage, { type: 'user' }>,
+  ) => void;
   removeTransientMessage: (sessionId: string, messageId: string) => void;
   transcriptRangeRef: RefBox<DesktopTranscriptRangeController | undefined>;
   setNavSelection: (selection: NavSelection) => void;
@@ -208,6 +212,7 @@ export function createAppShellChatActions(deps: {
     setMessageRetryPendingBySession,
     setMessages,
     addTransientMessage,
+    updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
     setNavSelection,
@@ -229,6 +234,7 @@ export function createAppShellChatActions(deps: {
 
   function optimisticUserMessage(
     messageId: string,
+    turnId: string,
     text: string,
     attachments: readonly import('@maka/core/events').AttachmentRef[] = [],
     quotes: readonly QuoteRef[] = [],
@@ -240,7 +246,7 @@ export function createAppShellChatActions(deps: {
       // StoredMessage requires a grouping key, but transient messages are
       // rendered beside the Turn projection. Canonical transcript data later
       // supplies the Host-owned grouping for this same message id.
-      turnId: messageId,
+      turnId,
       ts: Date.now(),
       text,
       ...(attachments.length > 0 ? { attachments: [...attachments] } : {}),
@@ -255,18 +261,25 @@ export function createAppShellChatActions(deps: {
     text: string,
     attachments: readonly import('@maka/core/events').AttachmentRef[] = [],
     options: {
+      turnId?: string;
+      updateOnly?: boolean;
       quotes?: readonly QuoteRef[];
       inlineReferences?: readonly InlineReference[];
     } = {},
   ): void {
     const next = optimisticUserMessage(
       messageId,
+      options.turnId ?? messageId,
       text,
       attachments,
       options.quotes,
       options.inlineReferences,
     );
-    addTransientMessage(sessionId, next);
+    if (options.updateOnly) {
+      (updateTransientMessage ?? addTransientMessage)(sessionId, next);
+    } else {
+      addTransientMessage(sessionId, next);
+    }
     if (activeIdRef.current !== sessionId) return;
     setMessageLoadErrorBySession((current) => {
       if (!current[sessionId]) return current;
@@ -458,6 +471,8 @@ export function createAppShellChatActions(deps: {
               skillInvocationDisplayText(text, sendResult.skillInvocation),
             sendResult.attachments,
             {
+              turnId: sendResult.turnId ?? messageId,
+              updateOnly: true,
               ...(quotes && quotes.length > 0 ? { quotes } : {}),
               inlineReferences: sendResult.inlineReferences ?? [],
             },
@@ -558,6 +573,8 @@ export function createAppShellChatActions(deps: {
           skillInvocationDisplayText(text, sendResult.skillInvocation),
         sendResult.attachments,
         {
+          turnId: sendResult.turnId ?? messageId,
+          updateOnly: true,
           ...(quotes && quotes.length > 0 ? { quotes } : {}),
           inlineReferences: sendResult.inlineReferences ?? [],
         },
@@ -653,6 +670,7 @@ export function createAppShellChatActions(deps: {
       });
       if (result.kind === 'outcome_unknown') return;
       showOptimisticUserMessage(sessionId, messageId, text, result.attachments, {
+        updateOnly: true,
         ...(quotes.length > 0 ? { quotes } : {}),
         inlineReferences: result.inlineReferences,
       });
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index 20d4021b3..56c405148 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -291,6 +291,7 @@ export function createAppShellSessionEventHandlers(options: {
     switch (event.type) {
       case 'queue_update':
         for (const entry of [...(event.steeringEntries ?? []), ...(event.followupEntries ?? [])]) {
+          if (entry.state !== 'queued') continue;
           projectTransientMessage?.(sessionId, {
             type: 'user',
             id: entry.messageId,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 02bb97c18..2ab674ff6 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -350,6 +350,7 @@ function AppShellContent({
     transientMessages,
     setMessages,
     addTransientMessage,
+    updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
     messageLoadPending,
@@ -1780,6 +1781,7 @@ function AppShellContent({
     setMessageRetryPendingBySession,
     setMessages,
     addTransientMessage,
+    updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
     setNavSelection,
diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
index 6fc56b695..21188afc2 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
@@ -102,6 +102,15 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) {
     }
   }
 
+  function updateTransientMessage(sessionId: string, message: TransientUserMessage): void {
+    const pending = transientMessagesBySessionRef.current.get(sessionId);
+    if (!pending?.has(message.id)) return;
+    pending.set(message.id, message);
+    if (activeIdRef.current === sessionId) {
+      setTransientMessages(projectTransientMessages(sessionId, messagesRef.current));
+    }
+  }
+
   function removeTransientMessage(sessionId: string, messageId: string): void {
     const pending = transientMessagesBySessionRef.current.get(sessionId);
     if (!pending?.delete(messageId)) return;
@@ -165,6 +174,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) {
     transientMessages,
     setMessages: setMessagesForActiveSession,
     addTransientMessage,
+    updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
     messageLoadPending,
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index 128259a0d..43a3d0d9e 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -457,6 +457,17 @@ export function ChatView(props: {
     }
   }, [revealTurn]);
   const mountedTurns = turns.slice(mountStart, mountEnd);
+  const inlineTransientMessage = tailTurnId
+    ? transientMessages.find((message) => {
+        if (message.turnId !== tailTurnId) return false;
+        const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId);
+        return turn !== undefined
+          && turn.user === undefined
+          && !turn.timeline.some(
+            (item) => item.kind === 'user' && item.messageId === message.id,
+          );
+      })
+    : undefined;
   const { highlightedTurnId } = useChatScroll({
     scrollRef,
     sessionId: props.activeSession?.id,
@@ -532,8 +543,10 @@ export function ChatView(props: {
   const hasVisibleConversationItem =
     conversationItemPlacement.byTurn.size > 0 || conversationItemPlacement.orphan !== undefined;
   const showEmptyState =
-    (chat.length === 0 && transientMessages.length === 0 && !streamingActive && !hasVisibleConversationItem)
-    || Boolean(props.messageLoading && chat.length === 0 && !hasVisibleConversationItem);
+    chat.length === 0
+    && transientMessages.length === 0
+    && !streamingActive
+    && !hasVisibleConversationItem;
   const emptyContent = props.messageLoading
     ? (
         <div className="maka-chat-message-loading">
@@ -639,6 +652,12 @@ export function ChatView(props: {
                     className="maka-turn-virtual-item"
                     data-virtual-turn-id={turn.turnId}
                   >
+                    {inlineTransientMessage?.turnId === turn.turnId ? (
+                      <TransientUserMessage
+                        message={inlineTransientMessage}
+                        onReadAttachmentBytes={props.onReadAttachmentBytes}
+                      />
+                    ) : null}
                     <TurnView
                       turn={turn}
                       userLabel={props.userLabel}
@@ -693,7 +712,9 @@ export function ChatView(props: {
                   style={{ height: afterHeight, flex: '0 0 auto', transition: 'none' }}
                 />
               )}
-              {transientMessages.map((message) => (
+              {transientMessages.filter(
+                (message) => message.id !== inlineTransientMessage?.id,
+              ).map((message) => (
                 <TransientUserMessage
                   key={message.id}
                   message={message}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org