You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ja...@apache.org on 2026/08/22 17:25:33 UTC

(maka) branch main updated: fix(desktop): stabilize burst follow-up messages (#3513)

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

jackwener pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new c20f25c7f fix(desktop): stabilize burst follow-up messages (#3513)
c20f25c7f is described below

commit c20f25c7f5c96a9a30038a281685c16356f566b7
Author: Haoqing Wang <78...@users.noreply.github.com>
AuthorDate: Sun Aug 23 01:25:28 2026 +0800

    fix(desktop): stabilize burst follow-up messages (#3513)
    
    * fix(desktop): stabilize burst follow-up messages
    
    Route consecutive submissions through the Runtime Host queue authority, expose Queue and Steer controls, restore retracted message content, and deduplicate identical active error toasts.
    
    Generated-by: Codex
    
    * fix(desktop): scope queue and toast actions
---
 apps/desktop/e2e/streaming-remount.spec.ts         |  11 +-
 .../__tests__/desktop-session-projection.test.ts   |  44 ++++++
 .../__tests__/follow-up-submit-routing.test.ts     |  92 +++++++++++++
 .../main/__tests__/message-queue-ui-state.test.ts  |  87 ++++++++++++
 .../main/__tests__/new-task-staged-content.test.ts |  25 ++++
 .../permission-response-ipc-boundary.test.ts       |  27 ++++
 ...runtime-host-session-execution-ipc-main.test.ts | 117 ++++++++++++++++
 .../runtime-host-session-observer.test.ts          |  11 ++
 apps/desktop/src/main/permission-response-guard.ts |  21 ++-
 .../runtime-host-session-execution-ipc-main.ts     | 146 ++++++++++++++++++--
 apps/desktop/src/preload/bridge-contract.d.ts      |  22 +++
 apps/desktop/src/preload/preload.ts                |  46 +++++++
 .../desktop/src/renderer/app-shell-chat-actions.ts |  60 +++++++--
 .../src/renderer/app-shell-session-events.ts       |  39 ++++++
 .../src/renderer/app-shell-session-ui-state.ts     |  11 ++
 apps/desktop/src/renderer/app-shell.tsx            | 150 ++++++++++++++++++---
 apps/desktop/src/renderer/attachment-preflight.ts  |   5 +-
 .../src/renderer/follow-up-submit-routing.ts       |  65 +++++++++
 apps/desktop/src/renderer/styles/composer.css      |  57 ++++++++
 .../renderer/use-app-shell-composer-attachments.ts |  22 +++
 .../src/renderer/use-app-shell-composer-quotes.ts  |  21 ++-
 .../src/renderer/use-app-shell-session-ui-reads.ts |   9 +-
 .../renderer/use-app-shell-session-workspace.ts    |   1 +
 .../src/shared/desktop-session-projection.ts       |  20 +++
 docs/astryx-surface-file-inventory.md              |   5 +-
 docs/astryx-surface-file-inventory.paths           |   1 +
 docs/desktop-message-queue.md                      |  59 ++++++++
 packages/core/src/events.ts                        |  15 +++
 .../runtime-host/src/adapter/session-projector.ts  |  15 +++
 .../ui/src/__tests__/composer-send-toggle.test.tsx |  73 ++++++++++
 packages/ui/src/__tests__/toast.test.ts            |  67 +++++++++
 packages/ui/src/composer-message-queue.tsx         |  98 ++++++++++++++
 packages/ui/src/composer.tsx                       | 109 ++++++++++++---
 packages/ui/src/conversation-copy.ts               |  25 +++-
 packages/ui/src/icons.tsx                          |   3 +
 packages/ui/src/toast.tsx                          |  31 ++++-
 36 files changed, 1536 insertions(+), 74 deletions(-)

diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts
index f57bd2636..272464f29 100644
--- a/apps/desktop/e2e/streaming-remount.spec.ts
+++ b/apps/desktop/e2e/streaming-remount.spec.ts
@@ -22,13 +22,20 @@ import {
   FAKE_HOLD_OPEN_REWRITE_PROMPT,
   FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT,
 } from '@maka/runtime/test-only/fake-backend';
-import type { Locator } from '@playwright/test';
+import type { Locator, Page } from '@playwright/test';
 import { expect, COMPOSER_INPUT, test } from './fixtures';
 
 function sessionRow(sidebar: Locator, sessionId: string): Locator {
   return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`);
 }
 
+async function selectSteerMode(page: Page): Promise<void> {
+  const steerMode = page.getByRole('radio', { name: '插入消息' });
+  await expect(steerMode).toBeVisible();
+  await steerMode.click();
+  await expect(steerMode).toBeChecked();
+}
+
 test('remounting a live surface leaves accumulated output settled', async ({
   window: page,
 }) => {
@@ -80,6 +87,7 @@ test('remounting a live surface leaves accumulated output settled', async ({
   });
 
   const steering = 'trigger rewrite after returning to this conversation';
+  await selectSteerMode(page);
   await composer.fill(steering);
   await composer.press('Enter');
   const finalText = 'prefix <redacted> NEW streamed after the remount';
@@ -156,6 +164,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount
   });
   const steering = 'use the detailed response';
   const completedReply = 'Large response complete.';
+  await selectSteerMode(page);
   await composer.fill(steering);
   await composer.press('Enter');
   await expect(page.getByRole('log')).toContainText(completedReply);
diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
index 1e76a2afc..a2d24ccae 100644
--- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
@@ -101,6 +101,50 @@ test('projects typed linked Session ids without rewriting opaque tool data', ()
   );
 });
 
+test('projects queued Session attachments into the Desktop host namespace', () => {
+  const host = { hostId: 'remote-root' };
+  const projected = projectDesktopSessionEvent(host, {
+    type: 'queue_update',
+    id: 'event-queue',
+    turnId: 'turn-1',
+    ts: 3,
+    queueRevision: 2,
+    steering: [],
+    followup: ['inspect this'],
+    steeringEntries: [],
+    followupEntries: [{
+      entryId: 'entry-1',
+      messageId: 'message-1',
+      content: {
+        text: 'inspect this',
+        attachments: [{
+          kind: 'other',
+          name: 'notes.txt',
+          mimeType: 'text/plain',
+          bytes: 5,
+          ref: {
+            kind: 'session_file',
+            sessionId: 'session-1',
+            relativePath: 'artifact-1',
+          },
+        }],
+      },
+      placement: 'next_turn',
+      state: 'queued',
+    }],
+  });
+
+  assert.deepEqual(
+    (projected as Extract<SessionEvent, { type: 'queue_update' }>).followupEntries?.[0]
+      ?.content.attachments?.[0].ref,
+    {
+      kind: 'session_file',
+      sessionId: JSON.stringify(['remote-root', 'session-1']),
+      relativePath: 'artifact-1',
+    },
+  );
+});
+
 function summary(id: string): SessionSummary {
   return {
     id,
diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts
new file mode 100644
index 000000000..a959d725b
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import {
+  hasActiveTurnAtSubmit,
+  mergeWorkspaceReferences,
+  resolveFollowUpModeAtSubmit,
+} from '../../renderer/follow-up-submit-routing.js';
+
+describe('follow-up submit routing', () => {
+  it('uses the synchronous turn arm before React publishes streaming state', () => {
+    assert.equal(
+      hasActiveTurnAtSubmit({
+        liveTurn: { turnId: 'turn-1' },
+        runningTurnIds: [],
+      }),
+      true,
+    );
+  });
+
+  it('ignores a terminal projection whose only running id is the same turn', () => {
+    assert.equal(
+      hasActiveTurnAtSubmit({
+        liveTurn: { turnId: 'turn-1', terminal: true },
+        runningTurnIds: ['turn-1'],
+      }),
+      false,
+    );
+  });
+
+  it('routes burst input through the selected follow-up lane', () => {
+    assert.equal(
+      resolveFollowUpModeAtSubmit({
+        defaultMode: 'queue',
+        hasActiveTurn: true,
+      }),
+      'queue',
+    );
+    assert.equal(
+      resolveFollowUpModeAtSubmit({
+        requestedMode: 'steer',
+        defaultMode: 'queue',
+        hasActiveTurn: true,
+      }),
+      'steer',
+    );
+  });
+
+  it('starts a normal turn only when no active-turn witness exists', () => {
+    assert.equal(
+      resolveFollowUpModeAtSubmit({
+        defaultMode: 'queue',
+        hasActiveTurn: false,
+      }),
+      undefined,
+    );
+  });
+
+  it('restores workspace references after queued text returns to the draft', () => {
+    assert.deepEqual(
+      mergeWorkspaceReferences(
+        'preface\n\nreview @src/app.ts',
+        undefined,
+        [{
+          kind: 'workspace_file',
+          value: '@src/app.ts',
+          label: 'src/app.ts',
+          start: 7,
+        }],
+      ),
+      [{ value: '@src/app.ts', start: 16 }],
+    );
+  });
+});
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
new file mode 100644
index 000000000..5b95302c9
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js';
+import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js';
+
+test('queue_update events drive the independent desktop queue projection', () => {
+  const controller = createAppShellSessionUiStateController();
+  const handlers = createAppShellSessionEventHandlers({
+    uiLocale: 'zh',
+    activeIdRef: { current: 'session-1' },
+    liveTurnBySessionRef: controller.liveTurnBySessionRef,
+    refreshMessages: async () => true,
+    refreshSessions: async () => [],
+    setLiveTurnBySession: controller.setLiveTurnBySession,
+    setInteractionBySession: controller.setInteractionBySession,
+    setMessageQueueBySession: controller.setMessageQueueBySession,
+    showModelSetupToast() {},
+    toastApi: { error() {} },
+  });
+  const steeringEntry = {
+    entryId: 'entry-steer',
+    messageId: 'message-steer',
+    content: { text: 'adjust this run' },
+    placement: 'current_turn' as const,
+    state: 'queued' as const,
+  };
+
+  handlers.handleEvent('session-1', {
+    type: 'queue_update',
+    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',
+    }],
+  });
+
+  assert.deepEqual(controller.getState().messageQueueBySession['session-1'], {
+    queueRevision: 3,
+    steering: [steeringEntry],
+    followup: [{
+      entryId: 'entry-next',
+      messageId: 'message-next',
+      content: { text: 'do this next' },
+      placement: 'next_turn',
+      state: 'queued',
+    }],
+  });
+
+  handlers.handleEvent('session-1', {
+    type: 'queue_update',
+    id: 'queue-2',
+    turnId: 'turn-1',
+    ts: 2,
+    queueRevision: 4,
+    steering: [],
+    followup: [],
+  });
+  assert.equal(controller.getState().messageQueueBySession['session-1'], undefined);
+});
diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
index fc6e27639..28ad90698 100644
--- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
+++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
@@ -158,6 +158,31 @@ test('a completing send clears the attachments it submitted', async () => {
   assert.equal(probe.latest().pendingAttachments.length, 0);
 });
 
+test('retracted queue attachments can be restored and submitted without re-ingest', async () => {
+  const probe = await mountProbe((options) =>
+    useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
+  );
+
+  await probe.render('session-1');
+  await act(() =>
+    probe.latest().restoreAttachments([
+      {
+        kind: 'other',
+        name: 'notes.txt',
+        mimeType: 'text/plain',
+        bytes: 5,
+        ref: {
+          kind: 'session_file',
+          sessionId: 'session-1',
+          relativePath: 'attachments/notes.txt',
+        },
+      },
+    ]),
+  );
+
+  assert.equal(probe.latest().pendingAttachments[0]?.source.type, 'retained');
+});
+
 test('files chosen in the native dialog land in the composer now on screen', async () => {
   const probe = await mountProbe((options) =>
     useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
index 9cc8ab1f1..d2d1c38c8 100644
--- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
+++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
@@ -134,6 +134,19 @@ describe('permission response IPC boundary', () => {
         displayText: 'review @packages/ui/src/chat turn.tsx',
         skillIds: ['weekly-report', 'project:maka:writer'],
         attachmentItems: [{ approvalId: 'a', name: 'n' }],
+        retainedAttachments: [
+          {
+            kind: 'other',
+            name: 'notes.txt',
+            mimeType: 'text/plain',
+            bytes: 5,
+            ref: {
+              kind: 'session_file',
+              sessionId: 'session-1',
+              relativePath: 'attachments/notes.txt',
+            },
+          },
+        ],
         turnOrchestration: { mode: 'swarm', source: 'slash_command', ignored: true },
         quotes: [
           { text: 'the excerpt', label: '  Assistant  ', sourceTurnId: 'turn-9', extra: true },
@@ -155,6 +168,19 @@ describe('permission response IPC boundary', () => {
         displayText: 'review @packages/ui/src/chat turn.tsx',
         skillIds: ['weekly-report', 'project:maka:writer'],
         attachmentItems: [{ approvalId: 'a', name: 'n' }],
+        retainedAttachments: [
+          {
+            kind: 'other',
+            name: 'notes.txt',
+            mimeType: 'text/plain',
+            bytes: 5,
+            ref: {
+              kind: 'session_file',
+              sessionId: 'session-1',
+              relativePath: 'attachments/notes.txt',
+            },
+          },
+        ],
         turnOrchestration: { mode: 'swarm', source: 'slash_command' },
         quotes: [{ text: 'the excerpt', label: 'Assistant', sourceTurnId: 'turn-9' }],
         workspaceFileReferences: [
@@ -178,6 +204,7 @@ describe('permission response IPC boundary', () => {
       null,
       { type: 'send', text: '' },
       { type: 'send', text: 'x'.repeat(128_001) },
+      { type: 'send', text: 'ok', retainedAttachments: [{ name: 'broken' }] },
       { type: 'send', text: 'hello', turnId: 1 },
       { type: 'send', text: 'hello', skillIds: ['/bad'] },
       { type: 'send', text: 'hello', turnOrchestration: { mode: 'swarm', source: 'prompt' } },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index dc8087fec..c53ed656f 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -679,6 +679,122 @@ test("keeps the busy failure for a Skill send instead of degrading it to steerin
   assert.deepEqual(submits, []);
 });
 
+test("queues explicit Desktop follow-ups and retracts their full content", async () => {
+  const submits: unknown[] = [];
+  let sequence = 0;
+  const ipc = ipcHarness();
+  registerExecutionIpc(
+    {
+      client: executionClient({
+        getSession: async () => session(),
+        submitMessage: async (input) => {
+          submits.push(input);
+          return { disposition: "followup", queueRevision: 4 };
+        },
+        retractQueue: async (input) => ({
+          queueRevision: 5,
+          retracted: [
+            {
+              entryId: "entry-1",
+              messageId: "message-1",
+              content: {
+                text: "first",
+                quotes: [{ text: "context" }],
+              },
+              placement: "next_turn",
+              state: "retracted",
+            },
+            {
+              entryId: "entry-2",
+              messageId: "message-2",
+              content: { text: "second" },
+              placement: "next_turn",
+              state: "retracted",
+            },
+          ],
+        }),
+      }),
+      observer: unusedObserver(),
+      attachmentApprovals: createAttachmentApprovalRegistry(),
+      emitSessionsChanged() {},
+      stat: async () => ({ size: 0 }),
+      resizeImage: async (bytes) => bytes,
+      beforeStop() {},
+      newId: () => `id-${++sequence}`,
+    },
+    ipc,
+  );
+
+  assert.deepEqual(
+    await ipc.invoke("sessions:enqueue", "session-1", "next_turn", {
+      text: "do this next",
+      quotes: [{ text: "quoted context" }],
+      retainedAttachments: [
+        {
+          kind: "other",
+          name: "notes.txt",
+          mimeType: "text/plain",
+          bytes: 5,
+          ref: {
+            kind: "session_file",
+            sessionId: "session-1",
+            relativePath: "attachments/notes.txt",
+          },
+        },
+      ],
+    }),
+    {
+      kind: "queued",
+      attachments: [
+        {
+          kind: "other",
+          name: "notes.txt",
+          mimeType: "text/plain",
+          bytes: 5,
+          ref: {
+            kind: "session_file",
+            sessionId: "session-1",
+            relativePath: "attachments/notes.txt",
+          },
+        },
+      ],
+      inlineReferences: [],
+    },
+  );
+  assert.deepEqual(submits, [
+    {
+      sessionId: "session-1",
+      messageId: "id-2",
+      content: {
+        text: "do this next",
+        attachments: [
+          {
+            kind: "other",
+            name: "notes.txt",
+            mimeType: "text/plain",
+            bytes: 5,
+            ref: {
+              kind: "session_file",
+              sessionId: "session-1",
+              relativePath: "attachments/notes.txt",
+            },
+          },
+        ],
+        quotes: [{ text: "quoted context" }],
+        inlineReferences: [],
+      },
+      placement: "next_turn",
+    },
+  ]);
+  assert.deepEqual(
+    await ipc.invoke("sessions:retractQueue", "session-1"),
+    {
+      text: "first\n\nsecond",
+      quotes: [{ text: "context" }],
+    },
+  );
+});
+
 test("binds steer and stop to Host-owned queue and active Turn identities", async () => {
   const submits: unknown[] = [];
   const interrupts: unknown[] = [];
@@ -770,6 +886,7 @@ function executionClient(overrides: Partial<ExecutionClient>): ExecutionClient {
     queryTurnResume: unavailable,
     readExecutionBoundary: unavailable,
     regenerateTurn: unavailable,
+    retractQueue: unavailable,
     setSessionReadMarker: unavailable,
     startTurn: unavailable,
     startTurnResume: unavailable,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index 42d0ed490..aa94abec7 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -2331,6 +2331,17 @@ test("projects Host queue revisions and newly delivered steering messages", asyn
     target.events.map((event) => event.type),
     ["queue_update", "steering_message", "queue_update"],
   );
+  assert.deepEqual(target.events[0], {
+    type: "queue_update",
+    id: "host-queue:host-1:1",
+    turnId: "turn-1",
+    ts: 90,
+    queueRevision: 1,
+    steering: ["Change direction"],
+    followup: [],
+    steeringEntries: [queued],
+    followupEntries: [],
+  });
   assert.deepEqual(target.events[1], {
     type: "steering_message",
     id: "host-queue:host-1:2:entry-1",
diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts
index bbe96ed95..03597bc01 100644
--- a/apps/desktop/src/main/permission-response-guard.ts
+++ b/apps/desktop/src/main/permission-response-guard.ts
@@ -26,7 +26,8 @@ import type {
 import type { QuoteRef } from '@maka/core/events';
 import type { UserQuestionResponse } from '@maka/core/user-question';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
-import { isCanonicalStorageRef } from '@maka/core/events';
+import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
+import { isAttachmentRef, isCanonicalStorageRef, type AttachmentRef } from '@maka/core/events';
 
 import { isOrchestrationMode, isTurnOrchestrationSource } from '@maka/core/orchestration';
 
@@ -56,6 +57,7 @@ interface NormalizedSendSessionCommand {
   displayText?: string;
   skillIds?: string[];
   attachmentItems?: unknown;
+  retainedAttachments?: AttachmentRef[];
   turnOrchestration?: TurnOrchestration;
   quotes?: QuoteRef[];
   workspaceFileReferences?: WorkspaceFileReferencePosition[];
@@ -177,6 +179,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
     ...(displayText !== undefined ? { displayText } : {}),
     ...(skillIds.length > 0 ? { skillIds } : {}),
     ...(value.attachmentItems !== undefined ? { attachmentItems: value.attachmentItems } : {}),
+    ...normalizeOptionalRetainedAttachments(value.retainedAttachments),
     ...(value.turnOrchestration !== undefined
       ? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) }
       : {}),
@@ -188,6 +191,22 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
   };
 }
 
+function normalizeOptionalRetainedAttachments(
+  input: unknown,
+): { retainedAttachments?: AttachmentRef[] } {
+  if (input === undefined) return {};
+  if (
+    !Array.isArray(input) ||
+    input.length > MAX_ATTACHMENT_COUNT ||
+    !input.every(isAttachmentRef)
+  ) {
+    throw new Error('Invalid retained attachments');
+  }
+  return input.length > 0
+    ? { retainedAttachments: input.map((attachment) => structuredClone(attachment)) }
+    : {};
+}
+
 function normalizeOptionalWorkspaceFileReferences(
   input: unknown,
   displayText: string,
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index f6a6df231..09fda5765 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -19,8 +19,10 @@
 
 import { randomUUID } from "node:crypto";
 import type { IpcMainInvokeEvent } from "electron";
+import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
 import { RuntimeHostOperationError } from '@maka/runtime-host/client';
 import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token';
+import { aggregateMessageContents } from '@maka/core/events';
 import {
   type SessionChangedEvent,
   type SessionChangedReason,
@@ -71,6 +73,7 @@ type RuntimeHostSessionExecutionClient = Pick<
   | "queryTurnResume"
   | "readExecutionBoundary"
   | "regenerateTurn"
+  | "retractQueue"
   | "setSessionReadMarker"
   | "startTurn"
   | "startTurnResume"
@@ -208,7 +211,10 @@ export function registerRuntimeHostSessionExecutionIpc(
       if (!session)
         throw new Error(`Runtime Host Session not found: ${sessionId}`);
       const turnId = command.turnId ?? newId();
-      let attachments: AttachmentRef[] = [];
+      let attachments = retainedAttachmentsForSession(
+        sessionId,
+        command.retainedAttachments ?? [],
+      );
       if (command.attachmentItems !== undefined) {
         const files = await resolveIngestItems({
           senderId: event.sender.id,
@@ -216,17 +222,23 @@ export function registerRuntimeHostSessionExecutionIpc(
           approvals: deps.attachmentApprovals,
           stat: deps.stat,
         });
-        attachments = await resolveAttachmentRefs({
-          files,
-          resizeImage: deps.resizeImage,
-          snapshot: ({ name, mimeType, content }) =>
-            deps.client.ingestAttachment({
-              sessionId,
-              name,
-              mimeType,
-              content,
-            }),
-        });
+        attachments = [
+          ...attachments,
+          ...(await resolveAttachmentRefs({
+            files,
+            resizeImage: deps.resizeImage,
+            snapshot: ({ name, mimeType, content }) =>
+              deps.client.ingestAttachment({
+                sessionId,
+                name,
+                mimeType,
+                content,
+              }),
+          })),
+        ];
+      }
+      if (attachments.length > MAX_ATTACHMENT_COUNT) {
+        throw new Error("Too many attachments");
       }
       const displayText =
         command.displayText ??
@@ -342,6 +354,98 @@ export function registerRuntimeHostSessionExecutionIpc(
       return { kind: "queued" as const };
     },
   );
+  ipcMain.handle(
+    "sessions:enqueue",
+    async (event, sessionId: string, placement: unknown, value: unknown) => {
+      if (placement !== "current_turn" && placement !== "next_turn") {
+        throw new Error("Invalid message placement");
+      }
+      const command = normalizeSessionSendCommand({
+        ...(value && typeof value === "object" ? value : {}),
+        type: "send",
+        turnId: newId(),
+      });
+      if (!command) throw new Error("Invalid queued message");
+      if ((command.skillIds?.length ?? 0) > 0 || command.turnOrchestration) {
+        throw new Error("Queued control input is not available");
+      }
+      const session = await deps.client.getSession(sessionId);
+      if (!session) {
+        throw new Error(`Runtime Host Session not found: ${sessionId}`);
+      }
+      let attachments = retainedAttachmentsForSession(
+        sessionId,
+        command.retainedAttachments ?? [],
+      );
+      if (command.attachmentItems !== undefined) {
+        const files = await resolveIngestItems({
+          senderId: event.sender.id,
+          items: command.attachmentItems,
+          approvals: deps.attachmentApprovals,
+          stat: deps.stat,
+        });
+        attachments = [
+          ...attachments,
+          ...(await resolveAttachmentRefs({
+            files,
+            resizeImage: deps.resizeImage,
+            snapshot: ({ name, mimeType, content }) =>
+              deps.client.ingestAttachment({
+                sessionId,
+                name,
+                mimeType,
+                content,
+              }),
+          })),
+        ];
+      }
+      if (attachments.length > MAX_ATTACHMENT_COUNT) {
+        throw new Error("Too many attachments");
+      }
+      const displayText = command.displayText ?? command.text;
+      const inlineReferences = mergeWorkspaceFileInlineReferences({
+        displayText,
+        workspaceFileReferences: command.workspaceFileReferences,
+      });
+      const result = await deps.client.submitMessage({
+        sessionId,
+        messageId: newId(),
+        placement,
+        content: {
+          text: command.text,
+          ...(command.displayText !== undefined
+            ? { displayText: command.displayText }
+            : {}),
+          ...(attachments.length > 0 ? { attachments } : {}),
+          ...(command.quotes ? { quotes: command.quotes } : {}),
+          inlineReferences,
+        },
+      });
+      if (result.disposition === "turn_started") {
+        deps.emitSessionsChanged("status-change", sessionId, {
+          turnId: result.turnId,
+        });
+        return {
+          kind: "started" as const,
+          turnId: result.turnId,
+          attachments,
+          inlineReferences,
+        };
+      }
+      return {
+        kind: "queued" as const,
+        attachments,
+        inlineReferences,
+      };
+    },
+  );
+  ipcMain.handle("sessions:retractQueue", async (_event, sessionId: string) => {
+    const result = await deps.client.retractQueue({
+      sessionId,
+      retractId: newId(),
+    });
+    return aggregateMessageContents(result.retracted.map((entry) => entry.content));
+  });
   ipcMain.handle("sessions:stop", async (_event, sessionId: string) =>
     stopSession(sessionId),
   );
@@ -530,6 +634,24 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange
   };
 }
 
+function retainedAttachmentsForSession(
+  sessionId: string,
+  attachments: readonly AttachmentRef[],
+): AttachmentRef[] {
+  return attachments.map((attachment) => {
+    if (attachment.ref.kind === "external_file") {
+      throw new Error("External file attachments must be selected again");
+    }
+    if (
+      attachment.ref.kind === "session_file" &&
+      attachment.ref.sessionId !== sessionId
+    ) {
+      throw new Error("Retained attachment belongs to another Session");
+    }
+    return structuredClone(attachment);
+  });
+}
+
 function createRuntimeHostSessionStop(
   deps: Pick<
     RuntimeHostSessionExecutionIpcDeps,
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index a76921865..25f03434b 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -42,6 +42,7 @@ import type { HealthSnapshot } from '@maka/core/health';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type {
   ActiveInteractionRequestEvent,
+  MessageContent,
   SessionCommand,
   SessionEvent,
   ShellRunUpdate,
@@ -630,6 +631,7 @@ export interface MakaBridge {
             displayText?: string;
             skillIds?: string[];
             attachmentItems?: RendererIngestInput[];
+            retainedAttachments?: import('@maka/core/events').AttachmentRef[];
             turnOrchestration?: TurnOrchestration;
             quotes?: import('@maka/core/events').QuoteRef[];
             workspaceFileReferences?: Array<
@@ -657,6 +659,26 @@ export interface MakaBridge {
     >;
     stop(sessionId: string, input?: { source?: 'stop_button' }): Promise<void>;
     steer(sessionId: string, text: string): Promise<QueueEnqueueOutcome>;
+    enqueue(
+      sessionId: string,
+      placement: 'current_turn' | 'next_turn',
+      command: {
+        text: string;
+        displayText?: string;
+        attachmentItems?: RendererIngestInput[];
+        retainedAttachments?: import('@maka/core/events').AttachmentRef[];
+        quotes?: import('@maka/core/events').QuoteRef[];
+        workspaceFileReferences?: Array<
+          Pick<import('@maka/core/events').InlineReference, 'value' | 'start'>
+        >;
+      },
+    ): Promise<{
+      kind: 'queued' | 'started';
+      turnId?: string;
+      attachments: import('@maka/core/events').AttachmentRef[];
+      inlineReferences: import('@maka/core/events').InlineReference[];
+    }>;
+    retractQueue(sessionId: string): Promise<MessageContent>;
     readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel>;
     listActiveInteractions(sessionId: string): Promise<ActiveInteractionRequestEvent[]>;
     subscribeActiveInteractions(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 45ff7796c..b5c53dc7a 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -99,6 +99,7 @@ import type { HealthSnapshot } from '@maka/core/health';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type {
   ActiveInteractionRequestEvent,
+  MessageContent,
   SessionCommand,
   SessionEvent,
   ShellRunUpdate,
@@ -1442,6 +1443,7 @@ const makaBridge = {
             displayText?: string;
             skillIds?: string[];
             attachmentItems?: RendererIngestInput[];
+            retainedAttachments?: AttachmentRef[];
             turnOrchestration?: TurnOrchestration;
             quotes?: QuoteRef[];
             workspaceFileReferences?: Array<Pick<InlineReference, 'value' | 'start'>>;
@@ -1499,6 +1501,50 @@ const makaBridge = {
     steer(sessionId: string, text: string): Promise<QueueEnqueueOutcome> {
       return invokeSessionRuntimeHost('sessions:steer', sessionId, text);
     },
+    async enqueue(
+      sessionId: string,
+      placement: 'current_turn' | 'next_turn',
+      command: {
+        text: string;
+        displayText?: string;
+        attachmentItems?: RendererIngestInput[];
+        retainedAttachments?: AttachmentRef[];
+        quotes?: QuoteRef[];
+        workspaceFileReferences?: Array<Pick<InlineReference, 'value' | 'start'>>;
+      },
+    ): Promise<{
+      kind: 'queued' | 'started';
+      turnId?: string;
+      attachments: AttachmentRef[];
+      inlineReferences: InlineReference[];
+    }> {
+      const session = await runtimeHostSessionRef(sessionId);
+      const attachmentItems = command.attachmentItems
+        ? await encodeIngestItems(command.attachmentItems)
+        : undefined;
+      const result = await ipcRenderer.invoke(
+        'sessions:enqueue',
+        session.scope,
+        session.sessionId,
+        placement,
+        {
+          ...command,
+          ...(attachmentItems ? { attachmentItems } : {}),
+        },
+      ) as {
+        kind: 'queued' | 'started';
+        turnId?: string;
+        attachments: AttachmentRef[];
+        inlineReferences: InlineReference[];
+      };
+      return {
+        ...result,
+        attachments: projectDesktopAttachmentRefs(session.scope, result.attachments),
+      };
+    },
+    retractQueue(sessionId: string): Promise<MessageContent> {
+      return invokeSessionRuntimeHost('sessions:retractQueue', sessionId);
+    },
     readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel> {
       return invokeSessionRuntimeHost('sessions:readExecutionBoundary', sessionId);
     },
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 3b49d4af0..57af54fdd 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -20,7 +20,7 @@
 import type { ChatDefaultPermissionMode } from '@maka/core/settings';
 import type { CollaborationMode } from '@maka/core/collaboration';
 import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js';
-import type { InlineReference, QuoteRef } from '@maka/core/events';
+import type { AttachmentRef, InlineReference, QuoteRef } from '@maka/core/events';
 import type { OrchestrationMode } from '@maka/core/orchestration';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type { StoredMessage } from '@maka/core/session';
@@ -65,14 +65,21 @@ export type PendingAttachment = {
    *  so a set previewUrl always means "renderable" — anything else keeps the
    *  named file card. */
   previewUrl?: string;
-  source: { type: 'approval'; approvalId: string; name: string } | { type: 'file'; file: File };
+  source:
+    | { type: 'approval'; approvalId: string; name: string }
+    | { type: 'file'; file: File }
+    | { type: 'retained'; attachment: AttachmentRef };
 };
 
 /** Stable identity for a staged attachment across preview-URL merges. The
  *  drawer list is re-derived when a preview lands, so submitted items must
  *  be matched by their source — never by object reference. */
 export function pendingAttachmentSourceKey(attachment: PendingAttachment): unknown {
-  return attachment.source.type === 'approval' ? `approval:${attachment.source.approvalId}` : attachment.source.file;
+  if (attachment.source.type === 'approval') {
+    return `approval:${attachment.source.approvalId}`;
+  }
+  if (attachment.source.type === 'file') return attachment.source.file;
+  return `retained:${JSON.stringify(attachment.source.attachment)}`;
 }
 
 export interface WorkspaceFileReferencePosition {
@@ -141,14 +148,27 @@ export interface AppShellChatActions {
 export function toRendererIngestItems(
   pending: readonly PendingAttachment[],
 ): RendererIngestInput[] {
-  return pending.map((p) =>
-    p.source.type === 'approval'
-      ? {
-          approvalId: p.source.approvalId,
-          name: p.source.name,
-          ...(p.mimeType ? { mimeType: p.mimeType } : {}),
-        }
-      : { file: p.source.file },
+  return pending.flatMap((p) => {
+    if (p.source.type === 'retained') return [];
+    return [
+      p.source.type === 'approval'
+        ? {
+            approvalId: p.source.approvalId,
+            name: p.source.name,
+            ...(p.mimeType ? { mimeType: p.mimeType } : {}),
+          }
+        : { file: p.source.file },
+    ];
+  });
+}
+
+export function retainedAttachmentRefs(
+  pending: readonly PendingAttachment[],
+): AttachmentRef[] {
+  return pending.flatMap((item) =>
+    item.source.type === 'retained'
+      ? [structuredClone(item.source.attachment)]
+      : [],
   );
 }
 
@@ -444,13 +464,20 @@ export function createAppShellChatActions(deps: {
           pending && pending.length > 0
             ? toRendererIngestItems(pending)
             : undefined;
+        const retainedAttachments =
+          pending && pending.length > 0
+            ? retainedAttachmentRefs(pending)
+            : undefined;
         const sendResult = await window.maka.sessions.send(session.id, {
           type: 'send',
           turnId,
           text,
           ...(options.displayText ? { displayText: options.displayText } : {}),
           ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
-          ...(attachmentItems ? { attachmentItems } : {}),
+          ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}),
+          ...(retainedAttachments && retainedAttachments.length > 0
+            ? { retainedAttachments }
+            : {}),
           ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}),
           ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0
             ? { workspaceFileReferences: [...options.workspaceFileReferences] }
@@ -527,13 +554,20 @@ export function createAppShellChatActions(deps: {
         pending && pending.length > 0
           ? toRendererIngestItems(pending)
           : undefined;
+      const retainedAttachments =
+        pending && pending.length > 0
+          ? retainedAttachmentRefs(pending)
+          : undefined;
       const sendResult = await window.maka.sessions.send(sessionId, {
         type: 'send',
         turnId,
         text,
         ...(options.displayText ? { displayText: options.displayText } : {}),
         ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
-        ...(attachmentItems ? { attachmentItems } : {}),
+        ...(attachmentItems && attachmentItems.length > 0 ? { attachmentItems } : {}),
+        ...(retainedAttachments && retainedAttachments.length > 0
+          ? { retainedAttachments }
+          : {}),
         ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}),
         ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0
           ? { workspaceFileReferences: [...options.workspaceFileReferences] }
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index a98f65d54..60ff79ef2 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -32,6 +32,7 @@ import {
   type InteractionQueues,
 } from '@maka/ui';
 import type { RefreshMessagesOptions } from './app-shell-chat-actions.js';
+import type { MessageQueueUiState } from './app-shell-session-ui-state.js';
 import {
   isNoRealConnectionEvent,
   noRealConnectionReasonFromEvent,
@@ -82,6 +83,7 @@ export function createAppShellSessionEventHandlers(options: {
   refreshSessions: () => Promise<unknown>;
   setLiveTurnBySession: StateUpdater<Record<string, LiveTurnProjection>>;
   setInteractionBySession: StateUpdater<InteractionQueues>;
+  setMessageQueueBySession?: StateUpdater<Record<string, MessageQueueUiState>>;
   onInteractionChanged?: (sessionId: string) => void;
   /** A boundary decision settled: the session's execution boundary may have moved. */
   onExecutionBoundaryChanged?: (sessionId: string) => void;
@@ -103,6 +105,7 @@ export function createAppShellSessionEventHandlers(options: {
     refreshSessions,
     setLiveTurnBySession,
     setInteractionBySession,
+    setMessageQueueBySession,
     onInteractionChanged,
     onExecutionBoundaryChanged,
     showModelSetupToast,
@@ -273,6 +276,42 @@ export function createAppShellSessionEventHandlers(options: {
     updateLiveTurn(sessionId, [...pending, event]);
 
     switch (event.type) {
+      case 'queue_update':
+        setMessageQueueBySession?.((current) => {
+          if (event.steering.length === 0 && event.followup.length === 0) {
+            if (!(sessionId in current)) return current;
+            const next = { ...current };
+            delete next[sessionId];
+            return next;
+          }
+          return {
+            ...current,
+            [sessionId]: {
+              ...(event.queueRevision !== undefined
+                ? { queueRevision: event.queueRevision }
+                : {}),
+              steering:
+                event.steeringEntries?.map((entry) => structuredClone(entry)) ??
+                event.steering.map((text, index) => ({
+                  entryId: `legacy-steering-${index}`,
+                  messageId: `legacy-steering-${index}`,
+                  content: { text },
+                  placement: 'current_turn' as const,
+                  state: 'queued' as const,
+                })),
+              followup:
+                event.followupEntries?.map((entry) => structuredClone(entry)) ??
+                event.followup.map((text, index) => ({
+                  entryId: `legacy-followup-${index}`,
+                  messageId: `legacy-followup-${index}`,
+                  content: { text },
+                  placement: 'next_turn' as const,
+                  state: 'queued' as const,
+                })),
+            },
+          };
+        });
+        break;
       case 'text_complete':
         void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false);
         break;
diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts
index 1c6711f44..21777dfc3 100644
--- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts
+++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts
@@ -18,6 +18,7 @@
  */
 
 import { useRef } from 'react';
+import type { MessageQueueEntryProjection } from '@maka/core/events';
 import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health';
 import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui';
 import type { ShellRunUpdatesBySession } from './shell-run-update-state.js';
@@ -31,10 +32,17 @@ export interface AppShellSessionUiState {
   liveTurnBySession: Record<string, LiveTurnProjection>;
   shellRunUpdatesBySession: ShellRunUpdatesBySession;
   interactionBySession: InteractionQueues;
+  messageQueueBySession: Record<string, MessageQueueUiState>;
   pendingPermissionModeBySession: Record<string, boolean>;
   pendingSessionModelBySession: Record<string, boolean>;
 }
 
+export interface MessageQueueUiState {
+  queueRevision?: number;
+  steering: MessageQueueEntryProjection[];
+  followup: MessageQueueEntryProjection[];
+}
+
 type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState;
 
 const SESSION_UI_MAP_KEYS = [
@@ -44,6 +52,7 @@ const SESSION_UI_MAP_KEYS = [
   'liveTurnBySession',
   'shellRunUpdatesBySession',
   'interactionBySession',
+  'messageQueueBySession',
   'pendingPermissionModeBySession',
   'pendingSessionModelBySession',
 ] as const satisfies readonly AppShellSessionUiStateMapKey[];
@@ -170,6 +179,7 @@ export function createAppShellSessionUiStateController(
     setLiveTurnBySession: createMapSetter('liveTurnBySession'),
     setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'),
     setInteractionBySession: createMapSetter('interactionBySession'),
+    setMessageQueueBySession: createMapSetter('messageQueueBySession'),
     setSessionEventHealthBySession: ((updater) => {
       sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current);
     }) satisfies StateUpdater<Record<string, SessionEventStreamSnapshot>>,
@@ -233,6 +243,7 @@ export function useAppShellSessionUiState() {
     setLiveTurnBySession: controller.setLiveTurnBySession,
     setShellRunUpdatesBySession: controller.setShellRunUpdatesBySession,
     setInteractionBySession: controller.setInteractionBySession,
+    setMessageQueueBySession: controller.setMessageQueueBySession,
     setSessionEventHealthBySession: controller.setSessionEventHealthBySession,
     setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession,
     setPendingSessionModelBySession: controller.setPendingSessionModelBySession,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 1e98a3044..5b0a0c82f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -30,7 +30,7 @@ import {
 } from 'react';
 import type { ScheduledTask } from '@maka/core/scheduled-task';
 import type { ProjectRecord } from '@maka/core/project';
-import type { QuoteRef } from '@maka/core/events';
+import type { FollowUpMode, InlineReference, QuoteRef } from '@maka/core/events';
 import type { SessionSummary } from '@maka/core/session';
 import type { OrchestrationMode } from '@maka/core/orchestration';
 import type { ChatDefaultPermissionMode } from '@maka/core/settings';
@@ -111,6 +111,11 @@ import { useNewTaskChoice } from './use-new-task-choice';
 import { NEW_TASK_PENDING_KEY } from './app-shell-pending-attachments';
 import { sideChatTitleFromPrompt } from './side-chat-command';
 import { parseDesktopSlashCommand } from './desktop-slash-command';
+import {
+  hasActiveTurnAtSubmit,
+  mergeWorkspaceReferences,
+  resolveFollowUpModeAtSubmit,
+} from './follow-up-submit-routing';
 import {
   applyCompanionForkVisibilityEvent,
   reconcileCompanionForkVisibility,
@@ -188,6 +193,8 @@ import {
 import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture';
 import {
   createAppShellChatActions,
+  retainedAttachmentRefs,
+  toRendererIngestItems,
   type WorkspaceFileReferencePosition,
 } from './app-shell-chat-actions';
 import { createAppShellTurnActions } from './app-shell-turn-actions';
@@ -413,6 +420,7 @@ function AppShellContent({
     confirmLiveTurn,
     setShellRunUpdatesBySession,
     setInteractionBySession,
+    setMessageQueueBySession,
     setSessionEventHealthBySession,
     setPendingPermissionModeBySession,
     setPendingSessionModelBySession,
@@ -434,6 +442,7 @@ function AppShellContent({
     pendingAttachments,
     pickAttachments,
     attachFilePaths,
+    restoreAttachments,
     removeAttachment,
     clearSubmittedAttachments,
   } = useAppShellComposerAttachments({
@@ -445,9 +454,11 @@ function AppShellContent({
     addQuote,
     removeQuote,
     clearQuotes,
+    restoreQuotes,
   } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey });
   // Held for the whole of sendOwningItsTarget; see ChatComposerRegion.
   const [newTaskSendPending, setNewTaskSendPending] = useState(false);
+  const [followUpMode, setFollowUpMode] = useState<FollowUpMode>('queue');
   // What a new chat will start with, held the way the Session holds it: a
   // Plan toggle and one orchestration value, not one fused choice.
   const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false);
@@ -490,6 +501,7 @@ function AppShellContent({
     messageRetryPendingBySession,
     stopPendingBySession,
     interactionBySession,
+    messageQueueBySession,
     pendingPermissionModeBySession,
     pendingSessionModelBySession,
     streamingSessionIds,
@@ -731,6 +743,7 @@ function AppShellContent({
   const [paletteOpen, openPalette, closePalette] = useCommandPalette();
   const [viewMode, setViewMode] = useState<SessionViewMode>(() => readSessionListViewMode());
   const composerRef = useRef<ComposerHandle>(null);
+  const retractedWorkspaceReferencesRef = useRef<Record<string, InlineReference[]>>({});
   // The rail's toggle has to reach Astryx's resizable state, not just this
   // boolean — see the prop's note on SessionListPanel. The sidenav is mounted
   // for the whole shell, so the handle is always live by the time it is called.
@@ -876,6 +889,7 @@ function AppShellContent({
     activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
   const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
   const activeSession = sessions.find((session) => session.id === activeId);
+  const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeDesktopSession = activeSession;
   // The shell's reading of the active live turn: streaming/settled flags, the
   // in-flight tool signal, and the #646 turn-wait cues, all derived from the
@@ -2279,6 +2293,50 @@ function AppShellContent({
     }
   }
 
+  async function enqueueFollowUp(
+    sessionId: string,
+    text: string,
+    mode: FollowUpMode,
+    metadata?: ComposerSendMetadata,
+  ): Promise<boolean> {
+    const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined;
+    const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined;
+    const attachmentItems = pending ? toRendererIngestItems(pending) : [];
+    const retainedAttachments = pending ? retainedAttachmentRefs(pending) : [];
+    try {
+      const result = await window.maka.sessions.enqueue(
+        sessionId,
+        mode === 'steer' ? 'current_turn' : 'next_turn',
+        {
+          text,
+          ...(attachmentItems.length > 0 ? { attachmentItems } : {}),
+          ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}),
+          ...(quotes ? { quotes: [...quotes] } : {}),
+          ...(metadata?.workspaceFileReferences?.length
+            ? { workspaceFileReferences: [...metadata.workspaceFileReferences] }
+            : {}),
+        },
+      );
+      if (pending) clearSubmittedAttachments(pending);
+      if (quotes) clearQuotes();
+      if (result.kind === 'started') {
+        await refreshMessages(sessionId);
+        await refreshSessions();
+      }
+      return true;
+    } catch (error) {
+      if (activeIdRef.current === sessionId) {
+        const copy = getDesktopConversationCopy(uiLocale).actions;
+        showSessionError(
+          sessionId,
+          copy.operationFailedTitle,
+          localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
+        );
+      }
+      return false;
+    }
+  }
+
   async function sendWithAttachments(
     text: string,
     metadata?: ComposerSendMetadata,
@@ -2288,11 +2346,34 @@ function AppShellContent({
       revision && activeIdRef.current === revision.draftSessionId,
     );
     const slashCommand = parseDesktopSlashCommand(text);
-    // The composer has one submit; mid-turn it means steering, and this is
-    // where that reading lives. Slash commands are exempt because they are
-    // control instructions to the shell, not text for the running turn.
-    if (!slashCommand && (turnActive || activeStreamingLive)) {
-      return steerWithText(text);
+    // Read the synchronous live-turn store at submit time. React's rendered
+    // `streaming` prop can lag one commit behind a just-started turn, which
+    // previously sent a second root turn and surfaced duplicate session_busy
+    // errors during burst input.
+    const sessionId = activeIdRef.current;
+    const workspaceFileReferences = mergeWorkspaceReferences(
+      text,
+      metadata?.workspaceFileReferences,
+      sessionId ? retractedWorkspaceReferencesRef.current[sessionId] : undefined,
+    );
+    const liveTurn = sessionId ? liveTurnBySessionRef.current[sessionId] : undefined;
+    const runningTurnIds = sessionId
+      ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds
+      : undefined;
+    const followUpAtSubmit = !slashCommand
+      ? resolveFollowUpModeAtSubmit({
+          requestedMode: metadata?.followUpMode,
+          defaultMode: followUpMode,
+          hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }),
+        })
+      : undefined;
+    if (sessionId && followUpAtSubmit) {
+      const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, {
+        ...metadata,
+        workspaceFileReferences,
+      });
+      if (queued) delete retractedWorkspaceReferencesRef.current[sessionId];
+      return queued;
     }
     if (
       revisionSend &&
@@ -2451,12 +2532,15 @@ function AppShellContent({
     const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined;
     const ok = await send(text, pending, {
       ...(quotes ? { quotes } : {}),
-      ...(metadata?.workspaceFileReferences?.length
-        ? { workspaceFileReferences: metadata.workspaceFileReferences }
+      ...(workspaceFileReferences.length > 0
+        ? { workspaceFileReferences }
         : {}),
     });
     if (ok !== false && pending) clearSubmittedAttachments(pending);
     if (ok !== false && quotes) clearQuotes();
+    if (ok !== false && sessionId) {
+      delete retractedWorkspaceReferencesRef.current[sessionId];
+    }
     if (ok !== false && revisionSend) {
       if (expectedRevisionDraft) {
         completeTurnRevisionCopyAttempt(expectedRevisionDraft);
@@ -2470,22 +2554,41 @@ function AppShellContent({
     return ok;
   }
 
-  async function steerWithText(text: string): Promise<boolean> {
+  async function retractQueuedMessages(): Promise<void> {
     const sessionId = activeIdRef.current;
-    if (!sessionId || !text.trim()) return false;
+    if (!sessionId) return;
     try {
-      const outcome = await window.maka.sessions.steer(sessionId, text.trim());
-      return outcome.kind === 'queued';
+      const content = await window.maka.sessions.retractQueue(sessionId);
+      setMessageQueueBySession((current) => {
+        if (!(sessionId in current)) return current;
+        const next = { ...current };
+        delete next[sessionId];
+        return next;
+      });
+      if (activeIdRef.current !== sessionId) return;
+      const displayText = content.displayText ?? content.text;
+      const before = composerRef.current?.getText() ?? '';
+      composerRef.current?.appendDraft?.(sessionId, displayText);
+      const after = composerRef.current?.getText() ?? displayText;
+      const insertionStart = Math.max(0, after.lastIndexOf(displayText, before.length + 2));
+      retractedWorkspaceReferencesRef.current[sessionId] = (
+        content.inlineReferences ?? []
+      ).flatMap((reference) =>
+        reference.kind === 'workspace_file'
+          ? [{ ...reference, start: insertionStart + reference.start }]
+          : [],
+      );
+      restoreAttachments(content.attachments ?? []);
+      restoreQuotes(content.quotes ?? []);
+      window.requestAnimationFrame(() => composerRef.current?.focus());
     } catch (error) {
-      if (activeIdRef.current === sessionId) {
-        const copy = getDesktopConversationCopy(uiLocale).actions;
-        showSessionError(
-          sessionId,
-          copy.operationFailedTitle,
-          localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
-        );
-      }
-      return false;
+      if (activeIdRef.current !== sessionId) return;
+      const copy = getDesktopConversationCopy(uiLocale).actions;
+      showSessionError(
+        sessionId,
+        copy.operationFailedTitle,
+        localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
+      );
     }
   }
 
@@ -2515,6 +2618,7 @@ function AppShellContent({
     refreshSessions,
     setLiveTurnBySession,
     setInteractionBySession,
+    setMessageQueueBySession,
     displayBatch: sessionDisplayBatch,
     onInteractionChanged: markInteractionChanged,
     onExecutionBoundaryChanged: reloadActiveExecutionBoundary,
@@ -3304,6 +3408,10 @@ function AppShellContent({
                   continuing={showContinuingIndicator && !activeStreamingLive}
                   onSend={sendOwningItsTarget}
                   onStop={stop}
+                  followUpMode={followUpMode}
+                  queuedMessages={activeMessageQueue}
+                  onFollowUpModeChange={setFollowUpMode}
+                  onRetractQueued={activeId ? retractQueuedMessages : undefined}
                   revisionNotice={
                     revisionDraft && activeId === revisionDraft.draftSessionId
                       ? {
diff --git a/apps/desktop/src/renderer/attachment-preflight.ts b/apps/desktop/src/renderer/attachment-preflight.ts
index dd8b294b8..0af114c7a 100644
--- a/apps/desktop/src/renderer/attachment-preflight.ts
+++ b/apps/desktop/src/renderer/attachment-preflight.ts
@@ -23,7 +23,10 @@ import { getDesktopConversationCopy } from './locales/conversation-copy.js';
 
 type PreflightItem = {
   size: number;
-  source: { type: 'approval'; approvalId: string } | { type: 'file'; file: { size: number } };
+  source:
+    | { type: 'approval'; approvalId: string }
+    | { type: 'file'; file: { size: number } }
+    | { type: 'retained' };
 };
 
 /**
diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts
new file mode 100644
index 000000000..0c282b544
--- /dev/null
+++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+
+import type { FollowUpMode, InlineReference } from '@maka/core/events';
+
+export interface WorkspaceFileReferencePosition {
+  value: string;
+  start: number;
+}
+
+export function hasActiveTurnAtSubmit(input: {
+  liveTurn?: { turnId: string; terminal?: boolean };
+  runningTurnIds?: readonly string[];
+}): boolean {
+  if (input.liveTurn?.terminal !== true && input.liveTurn !== undefined) return true;
+  return input.runningTurnIds?.some((turnId) => turnId !== input.liveTurn?.turnId) === true;
+}
+
+export function resolveFollowUpModeAtSubmit(input: {
+  requestedMode?: FollowUpMode;
+  defaultMode: FollowUpMode;
+  hasActiveTurn: boolean;
+}): FollowUpMode | undefined {
+  if (input.requestedMode) return input.requestedMode;
+  return input.hasActiveTurn ? input.defaultMode : undefined;
+}
+
+export function mergeWorkspaceReferences(
+  text: string,
+  live: readonly WorkspaceFileReferencePosition[] | undefined,
+  restored: readonly InlineReference[] | undefined,
+): WorkspaceFileReferencePosition[] {
+  const merged = new Map<string, WorkspaceFileReferencePosition>();
+  for (const reference of live ?? []) {
+    merged.set(`${reference.start}:${reference.value}`, { ...reference });
+  }
+  let cursor = 0;
+  for (const reference of restored ?? []) {
+    if (reference.kind !== 'workspace_file') continue;
+    let start = reference.start;
+    if (text.slice(start, start + reference.value.length) !== reference.value) {
+      start = text.indexOf(reference.value, cursor);
+    }
+    if (start < 0) continue;
+    cursor = start + reference.value.length;
+    merged.set(`${start}:${reference.value}`, { value: reference.value, start });
+  }
+  return [...merged.values()].sort((left, right) => left.start - right.start);
+}
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 09717bd5b..b9b7e4ef9 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -40,6 +40,63 @@
   max-width: var(--maka-chat-measure);
 }
 
+.maka-composer-queue {
+  width: min(var(--maka-chat-measure), calc(100% - (2 * var(--space-6))));
+  max-width: var(--maka-chat-measure);
+  margin: 0 auto var(--space-1);
+  padding: var(--space-1) var(--space-1) var(--space-1) var(--space-2);
+  box-sizing: border-box;
+  display: flex;
+  align-items: flex-end;
+  gap: var(--space-1);
+  border-radius: var(--radius-control);
+  background: var(--background-elevated);
+  box-shadow: inset 0 0 0 var(--border-width-hairline) var(--border);
+}
+
+.maka-composer-queue-list {
+  min-width: 0;
+  flex: 1 1 auto;
+  max-height: 132px;
+  overflow-y: auto;
+}
+
+.maka-composer-queue-row {
+  min-width: 0;
+  min-height: var(--h-control-sm);
+  display: grid;
+  grid-template-columns: 16px auto minmax(0, 1fr);
+  align-items: center;
+  gap: var(--space-1);
+  padding: 0 var(--space-1);
+  color: var(--foreground-secondary);
+}
+
+.maka-composer-queue-kind {
+  color: var(--muted-foreground);
+  font: var(--maka-text-supporting);
+  white-space: nowrap;
+}
+
+.maka-composer-queue-text {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--foreground);
+  font: var(--maka-text-label);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.maka-composer-follow-up-mode {
+  flex: 0 0 auto;
+}
+
+@media (max-width: 640px) {
+  .maka-composer-queue {
+    width: min(var(--maka-chat-measure), calc(100% - (2 * var(--space-3))));
+  }
+}
+
 /* Row-flow, not the old stacked column: image thumbnails and file/quote
    chips share one wrapping row. `width: 100%` is what makes the wrap real:
    as a flex item of Astryx's drawer content this group would otherwise size
diff --git a/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts b/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
index e848248d4..adfb363e8 100644
--- a/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
+++ b/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
@@ -19,6 +19,7 @@
 
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { attachmentKindFromMimeType, guessMimeFromName } from '@maka/core/attachments';
+import type { AttachmentRef } from '@maka/core/events';
 import { useUiLocale } from '@maka/ui';
 import { pendingAttachmentSourceKey, type PendingAttachment } from './app-shell-chat-actions.js';
 import { getDesktopConversationCopy } from './locales/conversation-copy.js';
@@ -64,6 +65,17 @@ function fileToPending(file: File): PendingAttachment {
   };
 }
 
+function retainedToPending(attachment: AttachmentRef): PendingAttachment {
+  return {
+    stagingKey: crypto.randomUUID(),
+    displayName: attachment.name,
+    mimeType: attachment.mimeType,
+    kind: attachment.kind,
+    size: attachment.bytes,
+    source: { type: 'retained', attachment: structuredClone(attachment) },
+  };
+}
+
 /** True once the URL has decoded as an image in this renderer. Gates every
  * preview before it reaches the drawer, so a corrupt file or a spoofed
  * extension falls back to the named file card instead of Astryx Thumbnail's
@@ -161,6 +173,7 @@ export function useAppShellComposerAttachments(options: {
           else releasePreviewUrl(url);
           continue;
         }
+        if (item.source.type === 'retained') continue;
         const preview = await window.maka.attachments.previewApproval(item.source.approvalId);
         if (!preview.ok) continue;
         const url = `data:${preview.mimeType};base64,${preview.base64}`;
@@ -201,6 +214,14 @@ export function useAppShellComposerAttachments(options: {
     void loadPreviewsSequentially(staged);
   }
 
+  function restoreAttachments(attachments: readonly AttachmentRef[]): void {
+    if (attachments.length === 0) return;
+    const ownerKey = options.draftKey;
+    const staged = attachments.map(retainedToPending);
+    setPendingByKey((map) => appendPending(map, ownerKey, staged));
+    for (const item of staged) stagedKeysRef.current.add(item.stagingKey);
+  }
+
   function removeAttachment(index: number): void {
     const ownerKey = options.draftKey;
     setPendingByKey((map) => removePending(map, ownerKey, index));
@@ -221,6 +242,7 @@ export function useAppShellComposerAttachments(options: {
     pendingAttachments,
     pickAttachments,
     attachFilePaths,
+    restoreAttachments,
     removeAttachment,
     clearSubmittedAttachments,
     clearAllAttachments,
diff --git a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
index 039a3d3c5..2b85f2e04 100644
--- a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
+++ b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
@@ -68,5 +68,24 @@ export function useAppShellComposerQuotes(options: { draftKey: string }) {
     setPendingByKey({});
   }
 
-  return { pendingQuotes, addQuote, removeQuote, clearQuotes, clearAllQuotes };
+  function restoreQuotes(quotes: readonly QuoteRef[]): void {
+    if (quotes.length === 0) return;
+    const ownerKey = options.draftKey;
+    setPendingByKey((map) =>
+      appendPending(
+        map,
+        ownerKey,
+        quotes.map((quote) => ({ ...quote })),
+      ),
+    );
+  }
+
+  return {
+    pendingQuotes,
+    addQuote,
+    removeQuote,
+    clearQuotes,
+    clearAllQuotes,
+    restoreQuotes,
+  };
 }
diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
index 6d9dde8b2..aa2e24b12 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
@@ -18,7 +18,11 @@
  */
 
 import type { InteractionQueues } from '@maka/ui';
-import type { AppShellSessionUiState, AppShellSessionUiStateController } from './app-shell-session-ui-state.js';
+import type {
+  AppShellSessionUiState,
+  AppShellSessionUiStateController,
+  MessageQueueUiState,
+} from './app-shell-session-ui-state.js';
 import {
   deriveLiveTurnSnapshot,
   liveTurnSnapshotsEqual,
@@ -32,6 +36,7 @@ const selectMessageLoadError = (state: AppShellSessionUiState) => state.messageL
 const selectMessageRetryPending = (state: AppShellSessionUiState) => state.messageRetryPendingBySession;
 const selectStopPending = (state: AppShellSessionUiState) => state.stopPendingBySession;
 const selectInteraction = (state: AppShellSessionUiState) => state.interactionBySession;
+const selectMessageQueue = (state: AppShellSessionUiState) => state.messageQueueBySession;
 const selectPendingPermissionMode = (state: AppShellSessionUiState) => state.pendingPermissionModeBySession;
 const selectPendingSessionModel = (state: AppShellSessionUiState) => state.pendingSessionModelBySession;
 const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession);
@@ -71,6 +76,7 @@ export function useAppShellSessionUiReads(
   messageRetryPendingBySession: Record<string, boolean>;
   stopPendingBySession: Record<string, boolean>;
   interactionBySession: InteractionQueues;
+  messageQueueBySession: Record<string, MessageQueueUiState>;
   pendingPermissionModeBySession: Record<string, boolean>;
   pendingSessionModelBySession: Record<string, boolean>;
   streamingSessionIds: Set<string>;
@@ -81,6 +87,7 @@ export function useAppShellSessionUiReads(
     messageRetryPendingBySession: useAppShellSessionUiSelector(controller, selectMessageRetryPending),
     stopPendingBySession: useAppShellSessionUiSelector(controller, selectStopPending),
     interactionBySession: useAppShellSessionUiSelector(controller, selectInteraction),
+    messageQueueBySession: useAppShellSessionUiSelector(controller, selectMessageQueue),
     pendingPermissionModeBySession: useAppShellSessionUiSelector(controller, selectPendingPermissionMode),
     pendingSessionModelBySession: useAppShellSessionUiSelector(controller, selectPendingSessionModel),
     streamingSessionIds: useAppShellSessionUiSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual),
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 b0e8ab8b5..a43043eed 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
@@ -110,6 +110,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) {
     setLiveTurnBySession: sessionUi.setLiveTurnBySession,
     setShellRunUpdatesBySession: sessionUi.setShellRunUpdatesBySession,
     setInteractionBySession: sessionUi.setInteractionBySession,
+    setMessageQueueBySession: sessionUi.setMessageQueueBySession,
     setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession,
     setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession,
     setPendingSessionModelBySession: sessionUi.setPendingSessionModelBySession,
diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts
index 1dfa95bfc..654e1628e 100644
--- a/apps/desktop/src/shared/desktop-session-projection.ts
+++ b/apps/desktop/src/shared/desktop-session-projection.ts
@@ -142,6 +142,26 @@ export function projectDesktopSessionEvent(
       return { ...event, content: projectDesktopToolResultContent(host, event.content) };
     case 'steering_message':
       return { ...event, content: projectMessageContent(host, event.content) };
+    case 'queue_update':
+      return {
+        ...event,
+        ...(event.steeringEntries
+          ? {
+              steeringEntries: event.steeringEntries.map((entry) => ({
+                ...entry,
+                content: projectMessageContent(host, entry.content),
+              })),
+            }
+          : {}),
+        ...(event.followupEntries
+          ? {
+              followupEntries: event.followupEntries.map((entry) => ({
+                ...entry,
+                content: projectMessageContent(host, entry.content),
+              })),
+            }
+          : {}),
+      };
     default:
       return event;
   }
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 0aa81acd2..20cb5183f 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
 
 Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
 
-**Totals:** 191 files — blocker 0, polish 0, aligned 191.
+**Totals:** 192 files — blocker 0, polish 0, aligned 192.
 
 ## Exclusions (explicit)
 
@@ -172,7 +172,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, HStack, IconButton, Spinner, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, HStack, IconButton, Spinner, Token, Tooltip) | aligned |
 | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, EmptyState, Spinner | aligned — uses Astryx (Button, EmptyState, Spinner) | aligned |
 | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, IconButton, Lightbox, Token, Tooltip) | aligned |
+| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | IconButton | aligned — uses Astryx (IconButton) | aligned |
+| `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, IconButton, Lightbox, SegmentedControl, SegmentedControlItem, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, IconButton, Lightbox, SegmentedControl, SegmentedControlItem, Token, Tooltip) | aligned |
 | `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned |
 | `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `packages/ui/src/inline-reference.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index b81268911..b9d26943d 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -144,6 +144,7 @@ packages/ui/src/chat-surface-layout.tsx
 packages/ui/src/chat-turn.tsx
 packages/ui/src/chat-view.tsx
 packages/ui/src/components.tsx
+packages/ui/src/composer-message-queue.tsx
 packages/ui/src/composer.tsx
 packages/ui/src/daily-review-panel.tsx
 packages/ui/src/icons.tsx
diff --git a/docs/desktop-message-queue.md b/docs/desktop-message-queue.md
new file mode 100644
index 000000000..e87cc5e55
--- /dev/null
+++ b/docs/desktop-message-queue.md
@@ -0,0 +1,59 @@
+<!--
+  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.
+-->
+
+# Desktop Message Queue
+
+## Problem
+
+Desktop used a rendered `streaming` prop to decide whether a composer submit
+started a root turn or steered the active turn. Immediately after the first
+send completed its IPC round trip, the Runtime Host could already own an
+active turn while React had not rendered the new `streaming` value yet. A
+second submit in that interval entered the root-turn path and could fail with
+`session_busy`. Multiple burst submissions also produced duplicate error
+toasts.
+
+Runtime Host already owns the durable message semantics:
+
+- `current_turn` queues steering for the next provider boundary.
+- `next_turn` queues a successor turn.
+- queue projections are authoritative.
+- `queue.retract` returns the queued message content.
+
+## Desktop Behavior
+
+- The default follow-up mode is Queue.
+- While a turn is active, the composer exposes Queue and Steer.
+- `Shift+Enter` uses the opposite mode for one submission.
+- Queue and Steer both submit through `turn.message.submit`.
+- The composer renders Runtime Host queue projections and can retract all
+  queued messages back into the draft.
+- Identical active toasts reuse one toast instead of stacking duplicates.
+
+## Race Fix
+
+Submission routing reads the synchronous live-turn ref and the latest session
+catalog snapshot at the instant of submission. It does not rely only on the
+previous React render.
+
+## Deliberate Scope
+
+This change does not add queue mutation operations such as editing, deleting a
+single entry, reordering, promoting, or pause/resume. Those require additional
+Runtime Host protocol and durability work and should be reviewed separately.
diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts
index c43ee639f..3f323b5cf 100644
--- a/packages/core/src/events.ts
+++ b/packages/core/src/events.ts
@@ -1034,6 +1034,18 @@ export interface SteeringMessageEvent extends BaseEvent {
  */
 export type QueueEnqueueOutcome = { kind: 'queued' } | { kind: 'fallback' };
 
+export type MessageQueuePlacement = 'current_turn' | 'next_turn';
+export type MessageQueueEntryState = 'queued' | 'in_flight';
+export type FollowUpMode = 'queue' | 'steer';
+
+export interface MessageQueueEntryProjection {
+  entryId: string;
+  messageId: string;
+  content: MessageContent;
+  placement: MessageQueuePlacement;
+  state: MessageQueueEntryState;
+}
+
 /**
  * Authoritative queue snapshot pushed into the active turn's event stream
  * whenever either pending queue changes (enqueue, step-boundary consumption, or
@@ -1041,8 +1053,11 @@ export type QueueEnqueueOutcome = { kind: 'queued' } | { kind: 'fallback' };
  */
 export interface QueueUpdateEvent extends BaseEvent {
   type: 'queue_update';
+  queueRevision?: number;
   steering: string[];
   followup: string[];
+  steeringEntries?: MessageQueueEntryProjection[];
+  followupEntries?: MessageQueueEntryProjection[];
 }
 
 export type ProviderRetryReason =
diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts
index d263dde49..79b9efe44 100644
--- a/packages/runtime-host/src/adapter/session-projector.ts
+++ b/packages/runtime-host/src/adapter/session-projector.ts
@@ -616,8 +616,23 @@ function projectQueueUpdate(
     id: `host-queue:${queue.hostEpoch}:${queue.queueRevision}`,
     turnId,
     ts: now,
+    queueRevision: queue.queueRevision,
     steering: queue.steering.map((entry) => entry.content.text),
     followup: queue.followup.map((entry) => entry.content.text),
+    steeringEntries: queue.steering.map((entry) => ({
+      entryId: entry.entryId,
+      messageId: entry.messageId,
+      content: structuredClone(entry.content),
+      placement: entry.placement,
+      state: entry.state,
+    })),
+    followupEntries: queue.followup.map((entry) => ({
+      entryId: entry.entryId,
+      messageId: entry.messageId,
+      content: structuredClone(entry.content),
+      placement: entry.placement,
+      state: entry.state,
+    })),
   };
 }
 
diff --git a/packages/ui/src/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
index 2bc7ce8cb..37ea07359 100644
--- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx
+++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
@@ -52,3 +52,76 @@ test('a turn in flight turns the same single control into Stop', () => {
   const buttons = sendSlotButtons(renderComposer(true));
   assert.deepEqual(buttons, ['aria-label="Stop"']);
 });
+
+test('a running composer exposes Queue and Steer when the host supports follow-ups', () => {
+  const markup = renderToStaticMarkup(
+    <LocaleProvider locale="en">
+      <Composer
+        streaming
+        followUpMode="queue"
+        onFollowUpModeChange={() => undefined}
+        onSend={() => undefined}
+        onStop={() => undefined}
+      />
+    </LocaleProvider>,
+  );
+
+  assert.match(markup, /aria-label="Follow-up behavior"/);
+  assert.match(markup, />Queue</);
+  assert.match(markup, />Steer</);
+});
+
+test('renders authoritative queued messages with one retract action', () => {
+  const markup = renderToStaticMarkup(
+    <LocaleProvider locale="en">
+      <Composer
+        streaming
+        followUpMode="queue"
+        queuedMessages={{
+          steering: [],
+          followup: [{
+            entryId: 'entry-1',
+            messageId: 'message-1',
+            content: { text: 'do this next' },
+            placement: 'next_turn',
+            state: 'queued',
+          }],
+        }}
+        onRetractQueued={() => undefined}
+        onSend={() => undefined}
+        onStop={() => undefined}
+      />
+    </LocaleProvider>,
+  );
+
+  assert.match(markup, /1 queued message/);
+  assert.match(markup, /do this next/);
+  assert.match(markup, /aria-label="Retract all"/);
+});
+
+test('does not offer retract when only an in-flight steering message remains', () => {
+  const markup = renderToStaticMarkup(
+    <LocaleProvider locale="en">
+      <Composer
+        streaming
+        followUpMode="steer"
+        queuedMessages={{
+          steering: [{
+            entryId: 'entry-1',
+            messageId: 'message-1',
+            content: { text: 'adjust this run' },
+            placement: 'current_turn',
+            state: 'in_flight',
+          }],
+          followup: [],
+        }}
+        onRetractQueued={() => undefined}
+        onSend={() => undefined}
+        onStop={() => undefined}
+      />
+    </LocaleProvider>,
+  );
+
+  assert.match(markup, /adjust this run/);
+  assert.doesNotMatch(markup, /aria-label="Retract all"/);
+});
diff --git a/packages/ui/src/__tests__/toast.test.ts b/packages/ui/src/__tests__/toast.test.ts
new file mode 100644
index 000000000..24777f97c
--- /dev/null
+++ b/packages/ui/src/__tests__/toast.test.ts
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+import { strict as assert } from 'node:assert';
+import { it } from 'node:test';
+import { toastContentKey } from '../toast.js';
+
+it('deduplicates identical active toast content within the same session', () => {
+  const input = {
+    title: 'Send failed',
+    description: 'Try again later',
+    variant: 'error' as const,
+    diagnosticTarget: { sessionId: 'session-1' },
+  };
+  assert.equal(
+    toastContentKey(input),
+    toastContentKey({
+      ...input,
+      diagnosticTarget: { sessionId: 'session-1' },
+    }),
+  );
+});
+
+it('does not merge identical active toast content across sessions', () => {
+  const input = {
+    title: 'Send failed',
+    description: 'Try again later',
+    variant: 'error' as const,
+    diagnosticTarget: { sessionId: 'session-1' },
+  };
+  assert.notEqual(
+    toastContentKey(input),
+    toastContentKey({
+      ...input,
+      diagnosticTarget: { sessionId: 'session-2' },
+    }),
+  );
+});
+
+it('does not merge different error content within the same session', () => {
+  const input = {
+    title: 'Send failed',
+    description: 'Try again later',
+    variant: 'error' as const,
+    diagnosticTarget: { sessionId: 'session-1' },
+  };
+  assert.notEqual(
+    toastContentKey(input),
+    toastContentKey({ ...input, description: 'Authentication failed' }),
+  );
+});
diff --git a/packages/ui/src/composer-message-queue.tsx b/packages/ui/src/composer-message-queue.tsx
new file mode 100644
index 000000000..6055397f0
--- /dev/null
+++ b/packages/ui/src/composer-message-queue.tsx
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+
+import { memo, useState } from 'react';
+import type { MessageQueueEntryProjection } from '@maka/core/events';
+import { IconButton } from '@astryxdesign/core';
+import type { ConversationCopy } from './conversation-copy.js';
+import { CornerDownRight, ListEnd, Undo2 } from './icons.js';
+import { useMountedRef } from './use-mounted-ref.js';
+
+export interface ComposerMessageQueueProps {
+  queuedMessages: {
+    steering: readonly MessageQueueEntryProjection[];
+    followup: readonly MessageQueueEntryProjection[];
+  };
+  copy: ConversationCopy['composer'];
+  onRetractQueued?(): void | Promise<void>;
+}
+
+export const ComposerMessageQueue = memo(function ComposerMessageQueue(
+  props: ComposerMessageQueueProps,
+) {
+  const [retractPending, setRetractPending] = useState(false);
+  const mountedRef = useMountedRef();
+  const entries = [
+    ...props.queuedMessages.steering.map((entry) => ({
+      entry,
+      label: entry.state === 'in_flight'
+        ? props.copy.steerDeliveringLabel
+        : props.copy.steerQueuedLabel,
+    })),
+    ...props.queuedMessages.followup.map((entry) => ({
+      entry,
+      label: props.copy.followUpQueuedLabel,
+    })),
+  ];
+  const hasRetractableEntries = entries.some(({ entry }) => entry.state === 'queued');
+
+  async function retractQueued() {
+    if (!props.onRetractQueued || !hasRetractableEntries || retractPending) return;
+    setRetractPending(true);
+    try {
+      await props.onRetractQueued();
+    } finally {
+      if (mountedRef.current) setRetractPending(false);
+    }
+  }
+
+  return (
+    <div
+      className="maka-composer-queue"
+      role="region"
+      aria-label={props.copy.queuedMessagesAriaLabel(entries.length)}
+    >
+      <div className="maka-composer-queue-list">
+        {entries.map(({ entry, label }) => (
+          <div className="maka-composer-queue-row" key={entry.entryId}>
+            {entry.placement === 'current_turn'
+              ? <CornerDownRight size={14} aria-hidden="true" />
+              : <ListEnd size={14} aria-hidden="true" />}
+            <span className="maka-composer-queue-kind">{label}</span>
+            <span className="maka-composer-queue-text">
+              {entry.content.displayText ?? entry.content.text}
+            </span>
+          </div>
+        ))}
+      </div>
+      {props.onRetractQueued && hasRetractableEntries ? (
+        <IconButton
+          variant="ghost"
+          size="sm"
+          type="button"
+          isDisabled={retractPending}
+          label={props.copy.retractQueued}
+          tooltip={props.copy.retractQueued}
+          onClick={() => void retractQueued()}
+          icon={<Undo2 size={14} aria-hidden="true" />}
+        />
+      ) : null}
+    </div>
+  );
+});
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index 1bd3c93e0..5d6d852c2 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -35,7 +35,9 @@ import { useMountedRef } from './use-mounted-ref.js';
 import {
   ICON_SIZE,
   ArrowUp,
+  CornerDownRight,
   FileText,
+  ListEnd,
   ListTodo,
   Network,
   Pencil,
@@ -73,7 +75,12 @@ import {
   type ComposerTextPort,
 } from './chat-input-behavior.js';
 import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token';
-import type { AttachmentRef, QuoteRef } from '@maka/core/events';
+import type {
+  AttachmentRef,
+  FollowUpMode,
+  MessageQueueEntryProjection,
+  QuoteRef,
+} from '@maka/core/events';
 import type { PermissionMode } from '@maka/core/permission';
 import type { OrchestrationMode } from '@maka/core/orchestration';
 import type { ProviderType } from '@maka/core/llm-connections';
@@ -85,6 +92,8 @@ import {
   ChatComposerInput,
   IconButton,
   Lightbox,
+  SegmentedControl,
+  SegmentedControlItem,
   Token,
   Tooltip,
   useChatPasteAsToken,
@@ -113,6 +122,7 @@ import {
   workspaceFileReferencePositions,
   type WorkspaceFileReferencePosition,
 } from './inline-reference.js';
+import { ComposerMessageQueue } from './composer-message-queue.js';
 
 /** A Skill as the composer offers it: what the `/` menu lists and what a
  * chosen entry writes into the draft. */
@@ -205,6 +215,7 @@ export interface ComposerHandle {
 
 export interface ComposerSendMetadata {
   workspaceFileReferences?: readonly WorkspaceFileReferencePosition[];
+  followUpMode?: FollowUpMode;
 }
 
 type ComposerImportActionId = 'pick' | 'attach';
@@ -239,6 +250,13 @@ export const Composer = forwardRef<
     continuing?: boolean;
     /** True while the current streaming session is processing a stop request. */
     stopPending?: boolean;
+    followUpMode?: FollowUpMode;
+    queuedMessages?: {
+      steering: readonly MessageQueueEntryProjection[];
+      followup: readonly MessageQueueEntryProjection[];
+    };
+    onFollowUpModeChange?(mode: FollowUpMode): void;
+    onRetractQueued?(): void | Promise<void>;
     /** Runtime-only key used to keep unsent drafts isolated per session. */
     draftKey?: string;
     /** Optional host persistence for reload-safe draft scopes. */
@@ -1109,7 +1127,7 @@ export const Composer = forwardRef<
     [],
   );
 
-  async function sendCurrent() {
+  async function sendCurrent(followUpMode?: FollowUpMode) {
     if (
       props.disabled
       || props.sendBlocked
@@ -1128,10 +1146,11 @@ export const Composer = forwardRef<
     setSendPending(true);
     let sent: boolean | void;
     try {
-      sent = await props.onSend(
-        text,
-        workspaceFileReferences.length > 0 ? { workspaceFileReferences } : undefined,
-      );
+      const metadata: ComposerSendMetadata = {
+        ...(workspaceFileReferences.length > 0 ? { workspaceFileReferences } : {}),
+        ...(followUpMode ? { followUpMode } : {}),
+      };
+      sent = await props.onSend(text, Object.keys(metadata).length > 0 ? metadata : undefined);
     } finally {
       sendPendingRef.current = false;
       if (composerMountedRef.current) setSendPending(false);
@@ -1161,7 +1180,7 @@ export const Composer = forwardRef<
 
   function submit(event: FormEvent<HTMLFormElement>) {
     event.preventDefault();
-    void sendCurrent();
+    void sendCurrent(props.streaming ? props.followUpMode : undefined);
   }
 
   async function runImportAction(actionId: ComposerImportActionId, action: (() => void | Promise<void>) | undefined) {
@@ -1218,18 +1237,22 @@ export const Composer = forwardRef<
       if (handleArrowKey(event)) return;
     }
     if (event.key !== 'Enter') return;
-    // Shift+Enter / Alt+Enter insert a line break instead of sending. We have
-    // to insert it ourselves rather than fall through: ChatComposerInput's own
-    // Enter branch only exempts Shift, so a bare `return` here would hand it
-    // Alt+Enter as a submit — and that path clears the editor even when it
-    // sends nothing, silently dropping the draft.
-    if (event.shiftKey || event.altKey) {
+    // Alt+Enter always inserts a line break. During a running turn, Shift+Enter
+    // sends once using the opposite Queue/Steer mode.
+    if (event.altKey || (event.shiftKey && !props.streaming)) {
       event.preventDefault();
       document.execCommand('insertLineBreak');
       return;
     }
     event.preventDefault();
-    void sendCurrent();
+    const selectedMode = props.followUpMode ?? 'queue';
+    const submitMode =
+      props.streaming && event.shiftKey
+        ? selectedMode === 'queue' ? 'steer' : 'queue'
+        : props.streaming
+          ? selectedMode
+          : undefined;
+    void sendCurrent(submitMode);
   }
 
   function onInputChange(next: string) {
@@ -1310,6 +1333,10 @@ export const Composer = forwardRef<
   // returns to Send (the host reads that as steering). Stop is not lost in that
   // window: Esc interrupts from the input, which is where the hands already are.
   const stopShown = props.streaming === true && !text.trim();
+  const queueCount =
+    (props.queuedMessages?.steering.length ?? 0) +
+    (props.queuedMessages?.followup.length ?? 0);
+  const selectedFollowUpMode = props.followUpMode ?? 'queue';
   const modelChipLabel = props.modelLabel?.trim() || copy.selectModel;
   // Mid-turn the model and thinking menus stay mounted but locked, each
   // carrying the reason in its own words (model vs thinking level) — the
@@ -1513,6 +1540,13 @@ export const Composer = forwardRef<
           />
         </div>
       )}
+      {!props.hidden && queueCount > 0 ? (
+        <ComposerMessageQueue
+          queuedMessages={props.queuedMessages!}
+          copy={copy}
+          onRetractQueued={props.onRetractQueued}
+        />
+      ) : null}
       <form
         ref={formRef}
         className="maka-composer composer"
@@ -1974,7 +2008,28 @@ export const Composer = forwardRef<
             </div>
           )}
           sendActions={(
-            <div className="maka-composer-right-controls" />
+            <div className="maka-composer-right-controls">
+              {props.streaming && props.onFollowUpModeChange ? (
+                <SegmentedControl
+                  value={selectedFollowUpMode}
+                  onChange={(value) => props.onFollowUpModeChange?.(value as FollowUpMode)}
+                  label={copy.followUpModeLabel}
+                  size="sm"
+                  className="maka-composer-follow-up-mode"
+                >
+                  <SegmentedControlItem
+                    value="queue"
+                    label={copy.queueLabel}
+                    icon={<ListEnd size={14} aria-hidden="true" />}
+                  />
+                  <SegmentedControlItem
+                    value="steer"
+                    label={copy.steerLabel}
+                    icon={<CornerDownRight size={14} aria-hidden="true" />}
+                  />
+                </SegmentedControl>
+              ) : null}
+            </div>
           )}
           sendButton={stopShown ? (
             <IconButton
@@ -1996,11 +2051,29 @@ export const Composer = forwardRef<
               variant="primary"
               type="submit"
               isDisabled={sendDisabled}
-              label={props.streaming ? copy.steerLabel : copy.sendLabel}
+              label={
+                props.streaming
+                  ? selectedFollowUpMode === 'queue'
+                    ? copy.queueLabel
+                    : copy.steerLabel
+                  : copy.sendLabel
+              }
               aria-busy={sendPending ? 'true' : undefined}
               data-pending={sendPending ? 'true' : undefined}
-              tooltip={props.streaming ? copy.steerLabel : sendTitle}
-              icon={<ArrowUp size={ICON_SIZE.chrome} aria-hidden="true" />}
+              tooltip={
+                props.streaming
+                  ? selectedFollowUpMode === 'queue'
+                    ? copy.queueTooltip
+                    : copy.steerTooltip
+                  : sendTitle
+              }
+              icon={
+                props.streaming
+                  ? selectedFollowUpMode === 'queue'
+                    ? <ListEnd size={ICON_SIZE.chrome} aria-hidden="true" />
+                    : <CornerDownRight size={ICON_SIZE.chrome} aria-hidden="true" />
+                  : <ArrowUp size={ICON_SIZE.chrome} aria-hidden="true" />
+              }
             />
           )}
         />
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 355b31f39..bfd9dfcec 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -110,6 +110,15 @@ export interface ConversationCopy {
     importing: string;
     sendLabel: string;
     steerLabel: string;
+    queueLabel: string;
+    followUpModeLabel: string;
+    queueTooltip: string;
+    steerTooltip: string;
+    queuedMessagesAriaLabel(count: number): string;
+    steerQueuedLabel: string;
+    steerDeliveringLabel: string;
+    followUpQueuedLabel: string;
+    retractQueued: string;
     stopLabel: string;
     stopping: string;
     streaming: string;
@@ -408,7 +417,13 @@ const CONVERSATION_COPY = {
     },
     composer: {
       placeholder: '描述任务,@ 引用文件,/ 选择技能…', textareaAriaLabel: '消息输入框', pastedQuoteLabel: '粘贴的文本', selectedSkillsAriaLabel: '已选择的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你确认权限…',
-      sending: '正在发送…', importing: '正在导入…', sendLabel: '发送', steerLabel: '插入消息', stopLabel: '停止', stopping: '停止中…',
+      sending: '正在发送…', importing: '正在导入…', sendLabel: '发送', steerLabel: '插入消息',
+      queueLabel: '排队', followUpModeLabel: '连续消息处理方式',
+      queueTooltip: '当前回复完成后发送', steerTooltip: '尽快插入当前回复',
+      queuedMessagesAriaLabel: (count) => `${count} 条待处理消息`,
+      steerQueuedLabel: '引导', steerDeliveringLabel: '正在引导', followUpQueuedLabel: '排队',
+      retractQueued: '撤回全部',
+      stopLabel: '停止', stopping: '停止中…',
       streaming: 'Maka 正在回答…', processing: 'Maka 正在处理…', continuing: 'Maka 继续中…',
       interruptHint: '或点停止中断', addContext: '添加上下文', stagedContext: '附加内容',
       selectModel: '选择模型', dropToImport: '松开以导入文件内容', addingAttachment: '正在添加附件', addFileOrDirectory: '添加文件或目录',
@@ -550,7 +565,13 @@ const CONVERSATION_COPY = {
     },
     composer: {
       placeholder: 'Describe a task, @ to reference files, / for skills…', textareaAriaLabel: 'Message input', pastedQuoteLabel: 'Pasted text', selectedSkillsAriaLabel: 'Selected Skills', removeSkillAriaLabel: (name) => `Remove Skill: ${name}`, awaitingPermission: 'Waiting for your permission decision…',
-      sending: 'Sending…', importing: 'Importing…', sendLabel: 'Send', steerLabel: 'Steer', stopLabel: 'Stop', stopping: 'Stopping…',
+      sending: 'Sending…', importing: 'Importing…', sendLabel: 'Send', steerLabel: 'Steer',
+      queueLabel: 'Queue', followUpModeLabel: 'Follow-up behavior',
+      queueTooltip: 'Send after the current response', steerTooltip: 'Guide the current response',
+      queuedMessagesAriaLabel: (count) => `${count} queued message${count === 1 ? '' : 's'}`,
+      steerQueuedLabel: 'Steer', steerDeliveringLabel: 'Steering', followUpQueuedLabel: 'Queued',
+      retractQueued: 'Retract all',
+      stopLabel: 'Stop', stopping: 'Stopping…',
       streaming: 'Maka is responding…', processing: 'Maka is working…', continuing: 'Maka is continuing…',
       interruptHint: 'or click Stop to interrupt', addContext: 'Add context', stagedContext: 'staged items',
       selectModel: 'Choose model', dropToImport: 'Drop to import file contents', addingAttachment: 'Adding attachment', addFileOrDirectory: 'Add file or directory',
diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx
index 2eeb081d7..9fbc663ef 100644
--- a/packages/ui/src/icons.tsx
+++ b/packages/ui/src/icons.tsx
@@ -81,6 +81,7 @@ export {
   Clock,
   Copy,
   CornerDownLeft,
+  CornerDownRight,
   Cpu,
   Database,
   Download,
@@ -106,6 +107,7 @@ export {
   Keyboard,
   LineChart,
   ListFilter,
+  ListEnd,
   ListTodo,
   Loader2,
   Loader2Icon,
@@ -155,6 +157,7 @@ export {
   Trash2,
   Upload,
   User,
+  Undo2,
   Volume2,
   Wifi,
   Workflow,
diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx
index b3a02e126..f323f575e 100644
--- a/packages/ui/src/toast.tsx
+++ b/packages/ui/src/toast.tsx
@@ -152,11 +152,14 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
   const activeConfirmRef = useRef<PendingConfirm | null>(null);
   const confirmQueueRef = useRef<PendingConfirm[]>([]);
   const dismissByIdRef = useRef(new Map<string, ToastDismissFn>());
+  const toastIdByContentRef = useRef(new Map<string, string>());
   const idSeed = useRef(0);
 
   const push = useCallback(
     (input: ToastInput): string => {
-      const id = `t${++idSeed.current}`;
+      const contentKey = toastContentKey(input);
+      const id = toastIdByContentRef.current.get(contentKey) ?? `t${++idSeed.current}`;
+      toastIdByContentRef.current.set(contentKey, id);
       let dismissCurrent: ToastDismissFn | undefined;
       const duration = input.duration ?? DEFAULT_DURATION;
       const errorAction = props.errorAction;
@@ -195,6 +198,7 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
       ].filter((action): action is ToastAction => action !== undefined);
       dismissCurrent = showToast({
         uniqueID: id,
+        collisionBehavior: 'overwrite',
         body: <ToastBody input={input} />,
         type: input.variant === 'error' ? 'error' : 'info',
         isAutoHide: duration > 0,
@@ -228,6 +232,9 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
         ) : undefined,
         onHide: () => {
           dismissByIdRef.current.delete(id);
+          if (toastIdByContentRef.current.get(contentKey) === id) {
+            toastIdByContentRef.current.delete(contentKey);
+          }
         },
       });
       dismissByIdRef.current.set(id, dismissCurrent);
@@ -307,6 +314,7 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
       }
       confirmQueueRef.current = [];
       dismissByIdRef.current.clear();
+      toastIdByContentRef.current.clear();
     };
   }, []);
 
@@ -346,6 +354,27 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
   );
 }
 
+export function toastContentKey(input: ToastInput): string {
+  const diagnosticTarget = input.diagnosticTarget;
+  const diagnosticScope = !diagnosticTarget
+    ? null
+    : 'profileId' in diagnosticTarget
+      ? ['profile', diagnosticTarget.profileId]
+      : [
+          'session',
+          diagnosticTarget.sessionId,
+          diagnosticTarget.turnId ?? '',
+          diagnosticTarget.eventId ?? '',
+        ];
+  return JSON.stringify([
+    input.variant ?? 'info',
+    input.title,
+    input.description ?? '',
+    input.action?.label ?? '',
+    diagnosticScope,
+  ]);
+}
+
 /**
  * Read the toast API from context. Throws when called outside a provider so
  * we don't silently swallow notifications during refactors.


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