You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ha...@apache.org on 2026/08/24 08:04:42 UTC

(maka) branch main updated: fix(desktop): make queued messages editable (#3635)

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

hqhq1025 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 2d07fde67 fix(desktop): make queued messages editable (#3635)
2d07fde67 is described below

commit 2d07fde67f878bf1a1b87b14218439ee220673b8
Author: Haoqing Wang <78...@users.noreply.github.com>
AuthorDate: Mon Aug 24 16:04:37 2026 +0800

    fix(desktop): make queued messages editable (#3635)
    
    Generated-by: Codex (GPT-5.6)
---
 .../app-shell-queue-entry-actions.test.ts          | 143 --------------
 .../main/__tests__/message-queue-ui-state.test.ts  |  29 ++-
 .../runtime-host-client-operations.test.ts         |  21 +-
 ...runtime-host-session-execution-ipc-main.test.ts |  28 ++-
 apps/desktop/src/main/runtime-host-client.ts       |  10 +
 .../runtime-host-session-execution-ipc-main.ts     |  31 +++
 apps/desktop/src/preload/bridge-contract.d.ts      |   6 +
 apps/desktop/src/preload/preload.ts                |  14 ++
 .../src/renderer/app-shell-queue-entry-actions.ts  |  70 -------
 .../src/renderer/app-shell-session-events.ts       |   8 +-
 .../src/renderer/app-shell-session-ui-state.ts     |   9 +-
 apps/desktop/src/renderer/app-shell.tsx            |  32 +--
 apps/desktop/src/renderer/styles/composer.css      |  24 +++
 .../stories/composer-message-queue.stories.tsx     |  31 ++-
 docs/astryx-surface-file-inventory.md              |   4 +-
 .../src/__tests__/message-coordinator.test.ts      | 207 +++++++++++++++++++
 .../runtime-host/src/__tests__/protocol.test.ts    |  26 +++
 packages/runtime-host/src/protocol/index.ts        |   3 +-
 packages/runtime-host/src/protocol/message.ts      |  39 ++++
 packages/runtime-host/src/protocol/operations.ts   |   1 +
 .../runtime-host/src/server/message-coordinator.ts | 144 +++++++++++++-
 .../src/server/operation-dispatcher.ts             |   1 +
 .../runtime/src/__tests__/session-manager.test.ts  |   5 +
 packages/runtime/src/runtime-kernel.ts             |  47 +++--
 packages/storage/src/message-receipt-store.ts      |   2 +
 .../ui/src/__tests__/composer-send-toggle.test.tsx |  91 +++++++--
 packages/ui/src/composer-message-queue.tsx         | 220 +++++++++++++++------
 packages/ui/src/composer.tsx                       |  21 +-
 packages/ui/src/conversation-copy.ts               |   9 +-
 29 files changed, 923 insertions(+), 353 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/app-shell-queue-entry-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-queue-entry-actions.test.ts
deleted file mode 100644
index 9a4931ed3..000000000
--- a/apps/desktop/src/main/__tests__/app-shell-queue-entry-actions.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * 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 type { MessageQueueEntryProjection } from '@maka/core/events';
-import { retractQueueEntryToDraft } from '../../renderer/app-shell-queue-entry-actions.js';
-
-test('restores a retracted entry to its owning session after the active session changes', async () => {
-  const response = deferred<void>();
-  const restoreWindow = installWindow(() => response.promise);
-  const activeIdRef = { current: 'session-a' as string | undefined };
-  const drafts = new Map([
-    ['session-a', 'existing A'],
-    ['session-b', 'existing B'],
-  ]);
-  const attachments: Array<{ draftKey: string; names: string[] }> = [];
-  const quotes: Array<{ draftKey: string; texts: string[] }> = [];
-  const references: Array<{ draftKey: string; starts: number[] }> = [];
-  let focusCount = 0;
-  const deps = {
-    activeIdRef,
-    composerRef: {
-      current: {
-        appendDraft(draftKey, text) {
-          const before = drafts.get(draftKey) ?? '';
-          drafts.set(draftKey, before ? `${before}\n\n${text}` : text);
-        },
-        getDraft: (draftKey) => drafts.get(draftKey) ?? '',
-        focus: () => {
-          focusCount += 1;
-        },
-      },
-    },
-    restoreAttachments: (draftKey, restored) => {
-      attachments.push({ draftKey, names: restored.map((attachment) => attachment.name) });
-    },
-    restoreQuotes: (draftKey, restored) => {
-      quotes.push({ draftKey, texts: restored.map((quote) => quote.text) });
-    },
-    setRestoredWorkspaceReferences: (draftKey, restored) => {
-      references.push({ draftKey, starts: restored.map((reference) => reference.start) });
-    },
-    requestFocus: (callback) => callback(),
-  } satisfies Parameters<typeof retractQueueEntryToDraft>[0];
-  const entry = queuedEntry();
-
-  try {
-    const retracting = retractQueueEntryToDraft(deps, 'session-a', entry);
-    activeIdRef.current = 'session-b';
-    response.resolve();
-    await retracting;
-
-    assert.equal(drafts.get('session-a'), 'existing A\n\nrestore @src/app.ts');
-    assert.equal(drafts.get('session-b'), 'existing B');
-    assert.deepEqual(attachments, [{ draftKey: 'session-a', names: ['notes.md'] }]);
-    assert.deepEqual(quotes, [{ draftKey: 'session-a', texts: ['quoted'] }]);
-    assert.deepEqual(references, [{ draftKey: 'session-a', starts: [20] }]);
-    assert.equal(focusCount, 0);
-  } finally {
-    restoreWindow();
-  }
-});
-
-function queuedEntry(): MessageQueueEntryProjection {
-  return {
-    entryId: 'entry-a',
-    messageId: 'message-a',
-    placement: 'next_turn',
-    state: 'queued',
-    content: {
-      text: 'model text',
-      displayText: 'restore @src/app.ts',
-      attachments: [{
-        kind: 'doc',
-        name: 'notes.md',
-        mimeType: 'text/markdown',
-        bytes: 5,
-        ref: {
-          kind: 'session_file',
-          sessionId: 'session-a',
-          relativePath: 'attachments/notes.md',
-        },
-      }],
-      quotes: [{ text: 'quoted' }],
-      inlineReferences: [{
-        kind: 'workspace_file',
-        value: '@src/app.ts',
-        label: 'src/app.ts',
-        start: 8,
-      }],
-    },
-  };
-}
-
-function deferred<T>(): {
-  promise: Promise<T>;
-  resolve(value: T): void;
-} {
-  let resolve!: (value: T) => void;
-  const promise = new Promise<T>((settle) => {
-    resolve = settle;
-  });
-  return { promise, resolve };
-}
-
-function installWindow(retractQueueEntry: () => Promise<void>): () => void {
-  const target = globalThis as unknown as { window?: unknown };
-  const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window');
-  const previousWindow = target.window;
-  Object.defineProperty(target, 'window', {
-    configurable: true,
-    value: { maka: { sessions: { retractQueueEntry } } },
-    writable: true,
-  });
-  return () => {
-    if (hadWindow) {
-      Object.defineProperty(target, 'window', {
-        configurable: true,
-        value: previousWindow,
-        writable: true,
-      });
-    } else {
-      delete target.window;
-    }
-  };
-}
diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts
index 426c8cdc3..1dd2451cf 100644
--- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts
+++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts
@@ -43,6 +43,13 @@ test('queue_update events drive the independent desktop queue projection', () =>
     placement: 'current_turn' as const,
     state: 'queued' as const,
   };
+  const inFlightEntry = {
+    ...steeringEntry,
+    entryId: 'entry-delivering',
+    messageId: 'message-delivering',
+    content: { text: 'already delivering' },
+    state: 'in_flight' as const,
+  };
 
   handlers.handleEvent('session-1', {
     type: 'queue_update',
@@ -52,7 +59,7 @@ test('queue_update events drive the independent desktop queue projection', () =>
     queueRevision: 3,
     steering: ['adjust this run'],
     followup: ['do this next'],
-    steeringEntries: [steeringEntry],
+    steeringEntries: [steeringEntry, inFlightEntry],
     followupEntries: [{
       entryId: 'entry-next',
       messageId: 'message-next',
@@ -62,13 +69,19 @@ test('queue_update events drive the independent desktop queue projection', () =>
     }],
   });
 
-  assert.deepEqual(controller.getState().messageQueueBySession['session-1'], [{
-    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,
+    entries: [
+      steeringEntry,
+      {
+        entryId: 'entry-next',
+        messageId: 'message-next',
+        content: { text: 'do this next' },
+        placement: 'next_turn',
+        state: 'queued',
+      },
+    ],
+  });
 
   handlers.handleEvent('session-1', {
     type: 'queue_update',
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
index 9ef2232e3..56ca8b3e9 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
@@ -394,8 +394,9 @@ test('binds message controls to the current Host Epoch', async () => {
   const { client, requests } = clientWithResponses([
     { disposition: 'steering', queueRevision: 2 },
     { queueRevision: 3 },
+    { queueRevision: 4 },
     {
-      queueRevision: 4,
+      queueRevision: 5,
       retracted: [],
       turn: {
         sessionId: 'session-1',
@@ -419,6 +420,13 @@ test('binds message controls to the current Host Epoch', async () => {
     entryId: 'entry-1',
     retractId: 'retract-1',
   });
+  await client.updateQueueEntry({
+    sessionId: 'session-1',
+    entryId: 'entry-1',
+    updateId: 'update-1',
+    expectedQueueRevision: 3,
+    text: 'Updated steer',
+  });
   await client.interruptTurn({
     sessionId: 'session-1',
     interruptId: 'interrupt-1',
@@ -446,6 +454,17 @@ test('binds message controls to the current Host Epoch', async () => {
         originHostEpoch: 'host-current',
       },
     },
+    {
+      operation: 'queue.entry.update',
+      input: {
+        sessionId: 'session-1',
+        entryId: 'entry-1',
+        updateId: 'update-1',
+        expectedQueueRevision: 3,
+        text: 'Updated steer',
+        originHostEpoch: 'host-current',
+      },
+    },
     {
       operation: 'turn.interrupt',
       input: {
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 c455ba26f..b03be46a2 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
@@ -781,9 +781,13 @@ test("routes per-entry queue mutations to the Runtime Host", async () => {
           calls.push({ operation: "promote", ...input });
           return { queueRevision: 4 };
         },
+        updateQueueEntry: async (input) => {
+          calls.push({ operation: "update", ...input });
+          return { queueRevision: 5 };
+        },
         reorderQueueEntries: async (input) => {
           calls.push({ operation: "reorder", ...input });
-          return { queueRevision: 5 };
+          return { queueRevision: 6 };
         },
       }),
       observer: unusedObserver(),
@@ -799,6 +803,13 @@ test("routes per-entry queue mutations to the Runtime Host", async () => {
 
   assert.equal(await ipc.invoke("sessions:retractQueueEntry", "session-1", "entry-1"), undefined);
   await ipc.invoke("sessions:promoteQueueEntry", "session-1", "entry-2");
+  await ipc.invoke(
+    "sessions:updateQueueEntry",
+    "session-1",
+    "entry-2",
+    4,
+    " revised ",
+  );
   await ipc.invoke("sessions:reorderQueueEntries", "session-1", ["entry-3", "entry-2"]);
 
   assert.deepEqual(calls, [
@@ -814,14 +825,26 @@ test("routes per-entry queue mutations to the Runtime Host", async () => {
       entryId: "entry-2",
       promoteId: "id-2",
     },
+    {
+      operation: "update",
+      sessionId: "session-1",
+      entryId: "entry-2",
+      updateId: "id-3",
+      expectedQueueRevision: 4,
+      text: "revised",
+    },
     {
       operation: "reorder",
       sessionId: "session-1",
-      reorderId: "id-3",
+      reorderId: "id-4",
       entryIds: ["entry-3", "entry-2"],
     },
   ]);
 
+  await assert.rejects(
+    () => ipc.invoke("sessions:updateQueueEntry", "session-1", "entry-1", 4, " "),
+    /Invalid Queued message text/,
+  );
   await assert.rejects(
     () => ipc.invoke("sessions:promoteQueueEntry", "session-1", 42),
     /Invalid queue entry identity/,
@@ -933,6 +956,7 @@ function executionClient(overrides: Partial<ExecutionClient>): ExecutionClient {
     regenerateTurn: unavailable,
     retractQueueEntry: unavailable,
     promoteQueueEntry: unavailable,
+    updateQueueEntry: unavailable,
     reorderQueueEntries: unavailable,
     setSessionReadMarker: unavailable,
     startTurn: unavailable,
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index bec3cb65c..7fd5c647d 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -94,6 +94,7 @@ import {
   type QueueEntriesReorderInput,
   type QueueEntryPromoteInput,
   type QueueEntryRetractInput,
+  type QueueEntryUpdateInput,
   type QueueMutationResult,
   SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
   type SessionCatalogChangedFrame,
@@ -1082,6 +1083,15 @@ export class DesktopRuntimeHostClient {
     });
   }
 
+  updateQueueEntry(
+    input: Omit<QueueEntryUpdateInput, "originHostEpoch">,
+  ): Promise<QueueMutationResult> {
+    return this.request("queue.entry.update", {
+      ...input,
+      originHostEpoch: this.connection.hostEpoch,
+    });
+  }
+
   reorderQueueEntries(
     input: Omit<QueueEntriesReorderInput, "originHostEpoch">,
   ): Promise<QueueMutationResult> {
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 a1d6d45b6..a6653fedb 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
@@ -75,6 +75,7 @@ type RuntimeHostSessionExecutionClient = Pick<
   | "regenerateTurn"
   | "retractQueueEntry"
   | "promoteQueueEntry"
+  | "updateQueueEntry"
   | "reorderQueueEntries"
   | "setSessionReadMarker"
   | "startTurn"
@@ -467,6 +468,25 @@ export function registerRuntimeHostSessionExecutionIpc(
       });
     },
   );
+  ipcMain.handle(
+    "sessions:updateQueueEntry",
+    async (
+      _event,
+      sessionId: unknown,
+      entryId: unknown,
+      expectedQueueRevision: unknown,
+      text: unknown,
+    ) => {
+      const normalizedText = requiredText(text, "Queued message").trim();
+      await deps.client.updateQueueEntry({
+        sessionId: requiredId(sessionId, "Session"),
+        entryId: requiredId(entryId, "Queue entry"),
+        updateId: newId(),
+        expectedQueueRevision: requiredSequence(expectedQueueRevision, "Queue"),
+        text: normalizedText,
+      });
+    },
+  );
   ipcMain.handle(
     "sessions:reorderQueueEntries",
     async (_event, sessionId: string, entryIds: unknown) => {
@@ -749,6 +769,17 @@ function requiredId(value: unknown, label: string): string {
   return value;
 }
 
+function requiredText(value: unknown, label: string): string {
+  if (
+    typeof value !== "string" ||
+    value.trim().length === 0 ||
+    Buffer.byteLength(value, "utf8") > 48 * 1024
+  ) {
+    throw new Error(`Invalid ${label} text`);
+  }
+  return value;
+}
+
 function requiredSequence(value: unknown, label: string): number {
   if (!Number.isSafeInteger(value) || (value as number) < 0) {
     throw new Error(`Invalid ${label} sequence`);
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index a8cb3c31c..f05bdac90 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -749,6 +749,12 @@ export interface MakaBridge {
     }>;
     retractQueueEntry(sessionId: string, entryId: string): Promise<void>;
     promoteQueueEntry(sessionId: string, entryId: string): Promise<void>;
+    updateQueueEntry(
+      sessionId: string,
+      entryId: string,
+      expectedQueueRevision: number,
+      text: string,
+    ): Promise<void>;
     reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise<void>;
     readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel>;
     listActiveInteractions(sessionId: string): Promise<ActiveInteractionRequestEvent[]>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 883adb1f9..67b2c15bb 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1566,6 +1566,20 @@ const makaBridge = {
     promoteQueueEntry(sessionId: string, entryId: string): Promise<void> {
       return invokeSessionRuntimeHost('sessions:promoteQueueEntry', sessionId, entryId);
     },
+    updateQueueEntry(
+      sessionId: string,
+      entryId: string,
+      expectedQueueRevision: number,
+      text: string,
+    ): Promise<void> {
+      return invokeSessionRuntimeHost(
+        'sessions:updateQueueEntry',
+        sessionId,
+        entryId,
+        expectedQueueRevision,
+        text,
+      );
+    },
     reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise<void> {
       return invokeSessionRuntimeHost('sessions:reorderQueueEntries', sessionId, [...entryIds]);
     },
diff --git a/apps/desktop/src/renderer/app-shell-queue-entry-actions.ts b/apps/desktop/src/renderer/app-shell-queue-entry-actions.ts
deleted file mode 100644
index 1c8eeb3e5..000000000
--- a/apps/desktop/src/renderer/app-shell-queue-entry-actions.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * 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 {
-  AttachmentRef,
-  InlineReference,
-  MessageContent,
-  MessageQueueEntryProjection,
-  QuoteRef,
-} from '@maka/core/events';
-
-type RefBox<T> = { current: T };
-
-type ComposerDraftPort = {
-  appendDraft?(draftKey: string, text: string): void;
-  getDraft(draftKey: string): string;
-  focus(): void;
-};
-
-export async function retractQueueEntryToDraft(deps: {
-  activeIdRef: RefBox<string | undefined>;
-  composerRef: RefBox<ComposerDraftPort | null>;
-  restoreAttachments(draftKey: string, attachments: readonly AttachmentRef[]): void;
-  restoreQuotes(draftKey: string, quotes: readonly QuoteRef[]): void;
-  setRestoredWorkspaceReferences(draftKey: string, references: readonly InlineReference[]): void;
-  requestFocus(callback: () => void): void;
-}, sessionId: string, entry: MessageQueueEntryProjection): Promise<void> {
-  // Queue content has one wire path: the Host-authored projection captured at
-  // click time. The mutation only confirms removal; its response does not send
-  // the same content again or make restoration depend on the active surface.
-  await window.maka.sessions.retractQueueEntry(sessionId, entry.entryId);
-  restoreContent(sessionId, entry.content);
-  deps.requestFocus(() => {
-    if (deps.activeIdRef.current === sessionId) deps.composerRef.current?.focus();
-  });
-
-  function restoreContent(draftKey: string, content: MessageContent): void {
-    const displayText = content.displayText ?? content.text;
-    const before = deps.composerRef.current?.getDraft(draftKey) ?? '';
-    deps.composerRef.current?.appendDraft?.(draftKey, displayText);
-    const after = deps.composerRef.current?.getDraft(draftKey) ?? displayText;
-    const insertionStart = Math.max(0, after.lastIndexOf(displayText, before.length + 2));
-    deps.setRestoredWorkspaceReferences(
-      draftKey,
-      (content.inlineReferences ?? []).flatMap((reference) =>
-        reference.kind === 'workspace_file'
-          ? [{ ...reference, start: insertionStart + reference.start }]
-          : [],
-      ),
-    );
-    deps.restoreAttachments(draftKey, content.attachments ?? []);
-    deps.restoreQuotes(draftKey, content.quotes ?? []);
-  }
-}
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index b27d0b91e..dfdf850ec 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -292,7 +292,13 @@ export function createAppShellSessionEventHandlers(options: {
           }
           return {
             ...current,
-            [sessionId]: event.followupEntries?.map((entry) => structuredClone(entry)) ?? [],
+            [sessionId]: {
+              queueRevision: event.queueRevision,
+              entries: [
+                ...(event.steeringEntries ?? []).filter((entry) => entry.state === 'queued'),
+                ...(event.followupEntries ?? []),
+              ].map((entry) => structuredClone(entry)),
+            },
           };
         });
         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 12b163ac2..bd3ba2394 100644
--- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts
+++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts
@@ -37,9 +37,12 @@ export interface AppShellSessionUiState {
   pendingSessionModelBySession: Record<string, boolean>;
 }
 
-// The pending plate mirrors the Host's follow-up queue only: steering entries
-// are already handed to the active Turn, and queueRevision is not rendered.
-export type MessageQueueUiState = MessageQueueEntryProjection[];
+// The pending plate keeps the Host revision beside its entries so edits can
+// reject stale multi-client projections instead of silently overwriting them.
+export interface MessageQueueUiState {
+  readonly queueRevision?: number;
+  readonly entries: readonly MessageQueueEntryProjection[];
+}
 
 type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState;
 
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 20e2ae82a..29a97fb25 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -33,7 +33,6 @@ import type { ProjectRecord } from '@maka/core/project';
 import type {
   FollowUpMode,
   InlineReference,
-  MessageQueueEntryProjection,
   QuoteRef,
 } from '@maka/core/events';
 import type { SessionSummary } from '@maka/core/session';
@@ -109,7 +108,6 @@ import {
   mergeWorkspaceReferences,
   resolveFollowUpModeAtSubmit,
 } from './follow-up-submit-routing';
-import { retractQueueEntryToDraft } from './app-shell-queue-entry-actions';
 import {
   PlanExecutionPanel,
   PlanProposalCard,
@@ -724,16 +722,6 @@ function AppShellContent({
   const [viewMode, setViewMode] = useState<SessionViewMode>(() => readSessionListViewMode());
   const composerRef = useRef<ComposerHandle>(null);
   const retractedWorkspaceReferencesRef = useRef<Record<string, InlineReference[]>>({});
-  const queueEntryDraftDeps = {
-    activeIdRef,
-    composerRef,
-    restoreAttachments,
-    restoreQuotes,
-    setRestoredWorkspaceReferences: (sessionId, references) => {
-      retractedWorkspaceReferencesRef.current[sessionId] = [...references];
-    },
-    requestFocus: (callback) => window.requestAnimationFrame(callback),
-  } satisfies Parameters<typeof retractQueueEntryToDraft>[0];
   // 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.
@@ -2184,9 +2172,19 @@ function AppShellContent({
     return ok;
   }
 
-  async function retractQueuedEntry(entry: MessageQueueEntryProjection): Promise<void> {
+  async function updateQueuedEntry(
+    entryId: string,
+    expectedQueueRevision: number,
+    text: string,
+  ): Promise<void> {
+    await runQueueEntryAction((sessionId) =>
+      window.maka.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text)
+    );
+  }
+
+  async function deleteQueuedEntry(entryId: string): Promise<void> {
     await runQueueEntryAction((sessionId) =>
-      retractQueueEntryToDraft(queueEntryDraftDeps, sessionId, entry)
+      window.maka.sessions.retractQueueEntry(sessionId, entryId).then(() => undefined)
     );
   }
 
@@ -3016,9 +3014,11 @@ function AppShellContent({
                   continuing={showContinuingIndicator && !activeStreamingLive}
                   onSend={sendOwningItsTarget}
                   onStop={stop}
-                  queuedMessages={activeMessageQueue}
+                  queuedMessages={activeMessageQueue?.entries}
+                  queuedMessageRevision={activeMessageQueue?.queueRevision}
                   onPromoteQueuedEntry={activeId ? promoteQueuedEntry : undefined}
-                  onRetractQueuedEntry={activeId ? retractQueuedEntry : undefined}
+                  onUpdateQueuedEntry={activeId ? updateQueuedEntry : undefined}
+                  onDeleteQueuedEntry={activeId ? deleteQueuedEntry : undefined}
                   onReorderQueuedEntries={activeId ? reorderQueuedEntries : undefined}
                   revisionNotice={
                     revisionDraft && activeId === revisionDraft.draftSessionId
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 27f6787a5..43c4f7625 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -61,6 +61,26 @@
   overflow-y: auto;
 }
 
+.maka-composer-queue-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-0-5);
+  flex: 0 0 auto;
+}
+
+.maka-composer-queue-edit {
+  width: 100%;
+  min-width: 0;
+  border: 0;
+  border-bottom: var(--border-width-hairline) solid var(--border-strong);
+  outline: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  line-height: inherit;
+  resize: vertical;
+}
+
 .maka-composer-queue-grip {
   display: grid;
   place-items: center;
@@ -84,6 +104,10 @@
   .maka-composer-queue {
     width: min(var(--maka-chat-measure), calc(100% - (2 * var(--space-3))));
   }
+
+  .maka-composer-queue-actions {
+    gap: 0;
+  }
 }
 
 /* Row-flow, not the old stacked column: image thumbnails and file/quote
diff --git a/apps/desktop/stories/composer-message-queue.stories.tsx b/apps/desktop/stories/composer-message-queue.stories.tsx
index ca074c1e4..8dd5958c3 100644
--- a/apps/desktop/stories/composer-message-queue.stories.tsx
+++ b/apps/desktop/stories/composer-message-queue.stories.tsx
@@ -84,12 +84,20 @@ function followUpEntry(entryId: string, text: string): MessageQueueEntryProjecti
 
 /**
  * Local stand-in for the Runtime Host queue projection: promote hands an
- * entry to the active Turn (it leaves the plate), retract drops it, reorder
- * applies the drag order. The component contract (projection in, mutations
+ * entry to the active Turn (it leaves the plate), update edits it in place,
+ * retract drops it, and reorder applies the drag order. The component contract
+ * (projection in, mutations
  * out) is the real one; only the authority is simulated.
  */
 function QueuedComposer() {
   const [followup, setFollowup] = useState<MessageQueueEntryProjection[]>([
+    {
+      entryId: 'entry-steer',
+      messageId: 'message-steer',
+      content: { text: '先把刚才的判断改成只检查当前工作树。' },
+      placement: 'current_turn',
+      state: 'queued',
+    },
     followUpEntry('entry-1', '先不要改协议。'),
     followUpEntry('entry-2', '查一下 PR #3526 相关的 session 及其 compaction 诊断记录。'),
     followUpEntry('entry-3', '把 runtime.sqlite 里的 compaction 日志也带上。'),
@@ -114,11 +122,21 @@ function QueuedComposer() {
     <Composer
       {...base}
       queuedMessages={followup}
+      queuedMessageRevision={1}
       onPromoteQueuedEntry={(entryId) => {
         setFollowup((current) => current.filter((candidate) => candidate.entryId !== entryId));
       }}
-      onRetractQueuedEntry={(entry) => {
-        setFollowup((current) => current.filter((candidate) => candidate.entryId !== entry.entryId));
+      onUpdateQueuedEntry={(entryId, _expectedQueueRevision, text) => {
+        setFollowup((current) =>
+          current.map((candidate) =>
+            candidate.entryId === entryId
+              ? { ...candidate, content: { ...candidate.content, text, displayText: text } }
+              : candidate,
+          ),
+        );
+      }}
+      onDeleteQueuedEntry={(entryId) => {
+        setFollowup((current) => current.filter((candidate) => candidate.entryId !== entryId));
       }}
       onReorderQueuedEntries={(entryIds) => {
         setFollowup((current) =>
@@ -131,9 +149,8 @@ function QueuedComposer() {
   );
 }
 
-// Real path: mid-turn sends queue above the composer card (Enter queues;
-// Shift+Enter steers and leaves the plate). Drag the hover grip to reorder,
-// 立即发送 hands an entry to the active Turn, 收回草稿 restores the draft.
+// Real path: mid-turn sends stay visible above the composer until consumed.
+// Drag follow-ups to reorder; 调整方向 promotes one, 编辑 updates it in place.
 export const PendingPlate: Story = {
   render: () => (
     <div style={{ padding: '24px 24px 48px', maxWidth: 840 }}>
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 6e2181f77..55dc8f7c7 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:** 207 files — blocker 0, polish 0, aligned 207.
+**Totals:** 207 files — blocker 0, polish 1, aligned 206.
 
 ## Exclusions (explicit)
 
@@ -187,7 +187,7 @@ 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-message-queue.tsx` | shell-chrome-or-panel | IconButton, List, ListItem | aligned — uses Astryx (IconButton, List, ListItem) | aligned |
+| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw `<textarea` (API Use-the-System) | polish |
 | `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/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 |
diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts
index 41ba3a10a..934227bd5 100644
--- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts
@@ -519,6 +519,213 @@ test('entry retract of an in-flight steering lease conflicts', async () => {
   assert.equal(fixture.liveResidencies(), 0);
 });
 
+test('entry update preserves queue identity, order, and placement and replays its receipt', async () => {
+  const fixture = createFixture();
+  fixture.coordinator.reserveRootTurn(ROOT);
+
+  await submit(fixture, 'steer-1', 'steer me', 'current_turn');
+  await submitContent(
+    fixture,
+    'follow-1',
+    {
+      text: 'first @src/a.ts',
+      inlineReferences: [
+        {
+          kind: 'workspace_file',
+          value: '@src/a.ts',
+          label: 'src/a.ts',
+          start: 6,
+        },
+      ],
+    },
+    'next_turn',
+  );
+  await submit(fixture, 'follow-2', 'second', 'next_turn');
+  let preparedUpdateContent: MessageContent | undefined;
+  fixture.setMessagePreparation(async (input) => {
+    preparedUpdateContent = input.content;
+    return { kind: 'ready', content: input.content };
+  });
+
+  const updated = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-2',
+      updateId: 'update-entry-1',
+      expectedQueueRevision: 3,
+      text: 'please first @src/a.ts',
+    },
+    operationContext(),
+  );
+  assert.equal(updated.ok, true);
+  assert.deepEqual(preparedUpdateContent, {
+    text: 'please first @src/a.ts',
+    inlineReferences: [
+      {
+        kind: 'workspace_file',
+        value: '@src/a.ts',
+        label: 'src/a.ts',
+        start: 13,
+      },
+    ],
+  });
+  const projection = fixture.coordinator.projection(ROOT.sessionId);
+  assert.deepEqual(
+    projection.followup.map((entry) => [entry.entryId, entry.content.text, entry.placement]),
+    [
+      ['id-2', 'please first @src/a.ts', 'next_turn'],
+      ['id-3', 'second', 'next_turn'],
+    ],
+  );
+  assert.deepEqual(
+    projection.steering.map((entry) => [entry.entryId, entry.content.text, entry.placement]),
+    [['id-1', 'steer me', 'current_turn']],
+  );
+
+  const stale = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-2',
+      updateId: 'update-entry-stale',
+      expectedQueueRevision: 3,
+      text: 'stale overwrite',
+    },
+    operationContext(),
+  );
+  assert.equal(stale.ok, false);
+  if (!stale.ok) assert.equal(stale.error.code, 'operation_conflict');
+
+  const retry = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-2',
+      updateId: 'update-entry-1',
+      expectedQueueRevision: 3,
+      text: 'please first @src/a.ts',
+    },
+    operationContext(),
+  );
+  assert.deepEqual(retry, updated);
+
+  const conflict = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-3',
+      updateId: 'update-entry-1',
+      expectedQueueRevision: 3,
+      text: 'conflicting retry',
+    },
+    operationContext(),
+  );
+  assert.equal(conflict.ok, false);
+  if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict');
+
+  await fixture.coordinator.handlers['queue.retract'](
+    { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-update' },
+    operationContext(),
+  );
+  fixture.coordinator.abandonRootReservation(ROOT);
+  await fixture.coordinator.close();
+});
+
+test('entry update of an in-flight steering lease conflicts', async () => {
+  const fixture = createFixture();
+  fixture.coordinator.reserveRootTurn(ROOT);
+  const owner = fixture.coordinator.bindRun(ROOT);
+
+  await submit(fixture, 'steer-1', 'steer me', 'current_turn');
+  const [lease] = owner.pull();
+  assert.ok(lease);
+
+  const outcome = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-1',
+      updateId: 'update-in-flight',
+      expectedQueueRevision: 2,
+      text: 'too late',
+    },
+    operationContext(),
+  );
+  assert.equal(outcome.ok, false);
+  if (!outcome.ok) assert.equal(outcome.error.code, 'operation_conflict');
+
+  owner.ack([lease.id]);
+  owner.release();
+  const batch = fixture.coordinator.beginTerminalTransition(ROOT);
+  fixture.coordinator.completeIdle(batch);
+  assert.equal(fixture.liveResidencies(), 0);
+});
+
+test('entry update keeps relocated inline references ordered and non-overlapping', async () => {
+  const fixture = createFixture();
+  fixture.coordinator.reserveRootTurn(ROOT);
+  await submitContent(
+    fixture,
+    'reordered-refs',
+    {
+      text: '@src/a @src/b',
+      inlineReferences: [
+        { kind: 'workspace_file', value: '@src/a', label: 'src/a', start: 0 },
+        { kind: 'workspace_file', value: '@src/b', label: 'src/b', start: 7 },
+      ],
+    },
+    'next_turn',
+  );
+  await submitContent(
+    fixture,
+    'overlapping-refs',
+    {
+      text: '@src/a @src/a.ts',
+      inlineReferences: [
+        { kind: 'workspace_file', value: '@src/a', label: 'src/a', start: 0 },
+        { kind: 'workspace_file', value: '@src/a.ts', label: 'src/a.ts', start: 7 },
+      ],
+    },
+    'next_turn',
+  );
+
+  const reordered = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-1',
+      updateId: 'update-reordered-refs',
+      expectedQueueRevision: 2,
+      text: '@src/b @src/a',
+    },
+    operationContext(),
+  );
+  assert.equal(reordered.ok, true);
+
+  const overlapping = await fixture.coordinator.handlers['queue.entry.update'](
+    {
+      originHostEpoch: 'epoch-1',
+      sessionId: ROOT.sessionId,
+      entryId: 'id-2',
+      updateId: 'update-overlapping-refs',
+      expectedQueueRevision: 3,
+      text: '@src/a.ts',
+    },
+    operationContext(),
+  );
+  assert.equal(overlapping.ok, true);
+
+  const [first, second] = fixture.coordinator.projection(ROOT.sessionId).followup;
+  assert.deepEqual(first?.content.inlineReferences, [
+    { kind: 'workspace_file', value: '@src/b', label: 'src/b', start: 0 },
+    { kind: 'workspace_file', value: '@src/a', label: 'src/a', start: 7 },
+  ]);
+  assert.deepEqual(second?.content.inlineReferences, [
+    { kind: 'workspace_file', value: '@src/a.ts', label: 'src/a.ts', start: 0 },
+  ]);
+});
+
 test('entry promote moves a follow-up into the steering queue', async () => {
   const fixture = createFixture();
   fixture.coordinator.reserveRootTurn(ROOT);
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index 53ba601a3..7bcbdbc7a 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -172,6 +172,10 @@ describe('Runtime Host bootstrap protocol', () => {
     assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 44);
   });
 
+  test('publishes a new compatibility epoch for queued message editing', () => {
+    assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45);
+  });
+
   test('adds credential rotation without changing existing credential inputs', () => {
     const issueInput = {
       principalKind: 'remote_owner',
@@ -1002,6 +1006,18 @@ describe('Runtime Host bootstrap protocol', () => {
         promoteId: 'promote-1',
       },
     };
+    const entryUpdate = {
+      requestId: 'entry-update-request-1',
+      operation: 'queue.entry.update' as const,
+      input: {
+        originHostEpoch: 'epoch-1',
+        sessionId: 'session-1',
+        entryId: 'entry-1',
+        updateId: 'update-1',
+        expectedQueueRevision: 7,
+        text: 'updated message',
+      },
+    };
     const entriesReorder = {
       requestId: 'entries-reorder-request-1',
       operation: 'queue.entries.reorder' as const,
@@ -1014,7 +1030,16 @@ describe('Runtime Host bootstrap protocol', () => {
     };
     assert.deepEqual(decodeClientFrame(entryRetract), entryRetract);
     assert.deepEqual(decodeClientFrame(entryPromote), entryPromote);
+    assert.deepEqual(decodeClientFrame(entryUpdate), entryUpdate);
     assert.deepEqual(decodeClientFrame(entriesReorder), entriesReorder);
+    assert.throws(
+      () =>
+        decodeClientFrame({
+          ...entryUpdate,
+          input: { ...entryUpdate.input, text: '   ' },
+        }),
+      isInvalidFrame,
+    );
     assert.throws(
       () =>
         decodeClientFrame({
@@ -1359,6 +1384,7 @@ describe('Runtime Host bootstrap protocol', () => {
     for (const [operation, requestId] of [
       ['queue.entry.retract', 'entry-retract-response'],
       ['queue.entry.promote', 'entry-promote-response'],
+      ['queue.entry.update', 'entry-update-response'],
       ['queue.entries.reorder', 'entries-reorder-response'],
     ] as const) {
       assert.doesNotThrow(() =>
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 1a1b22474..0edebdf8d 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -91,7 +91,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 45 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 46 as const;
+// 46: Queued message content can be edited in place (queue.entry.update).
 // 45: Connection onboarding inputs require `baseUrl` and `connectionId`, and
 // results can carry the `base_url_not_configured` / `connection_not_found`
 // rejections. Older peers reject all of these shapes.
diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts
index 8c74aba13..e72c171b0 100644
--- a/packages/runtime-host/src/protocol/message.ts
+++ b/packages/runtime-host/src/protocol/message.ts
@@ -25,12 +25,14 @@ import {
   requireExactRecord,
   requireId,
   requireRecord,
+  requireUtf8String,
 } from './codec.js';
 import { defineOperation } from './operation-spec.js';
 import {
   decodeMessageContent,
   decodeTurnSnapshot,
   type MessageContent,
+  TURN_MESSAGE_TEXT_MAX_BYTES,
   type TurnSnapshot,
 } from './turn.js';
 
@@ -118,6 +120,15 @@ export interface QueueEntryPromoteInput {
   readonly promoteId: string;
 }
 
+export interface QueueEntryUpdateInput {
+  readonly originHostEpoch: string;
+  readonly sessionId: string;
+  readonly entryId: string;
+  readonly updateId: string;
+  readonly expectedQueueRevision: number;
+  readonly text: string;
+}
+
 export interface QueueEntriesReorderInput {
   readonly originHostEpoch: string;
   readonly sessionId: string;
@@ -180,6 +191,13 @@ export const MESSAGE_OPERATION_SPECS = {
     decodeInput: decodeQueueEntryPromoteInput,
     decodeOutput: decodeQueueMutationResult,
   }),
+  'queue.entry.update': defineOperation({
+    mode: 'command',
+    availability: 'ready',
+    errors: MESSAGE_OPERATION_ERRORS,
+    decodeInput: decodeQueueEntryUpdateInput,
+    decodeOutput: decodeQueueMutationResult,
+  }),
   'queue.entries.reorder': defineOperation({
     mode: 'command',
     availability: 'ready',
@@ -314,6 +332,27 @@ function decodeQueueEntryPromoteInput(value: unknown): QueueEntryPromoteInput {
   };
 }
 
+function decodeQueueEntryUpdateInput(value: unknown): QueueEntryUpdateInput {
+  const record = requireExactRecord(value, 'queue.entry.update input', [
+    'originHostEpoch',
+    'sessionId',
+    'entryId',
+    'updateId',
+    'expectedQueueRevision',
+    'text',
+  ]);
+  const text = requireUtf8String(record.text, 'Message text', TURN_MESSAGE_TEXT_MAX_BYTES);
+  if (text.trim().length === 0) throw invalidProtocolFrame('Invalid Message text');
+  return {
+    originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'),
+    sessionId: requireEntityId(record.sessionId, 'sessionId'),
+    entryId: requireEntityId(record.entryId, 'entryId'),
+    updateId: requireEntityId(record.updateId, 'updateId'),
+    expectedQueueRevision: requireCount(record.expectedQueueRevision, 'expectedQueueRevision'),
+    text,
+  };
+}
+
 function decodeQueueEntriesReorderInput(value: unknown): QueueEntriesReorderInput {
   const record = requireExactRecord(value, 'queue.entries.reorder input', [
     'originHostEpoch',
diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index 8460bfaea..fc83a8eba 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -277,6 +277,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([
   'queue.entries.reorder',
   'queue.entry.promote',
   'queue.entry.retract',
+  'queue.entry.update',
   'queue.retract',
   'runtime.policy.mutate',
   'runtime.policy.query',
diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts
index 89eea91d4..8d8ac47e9 100644
--- a/packages/runtime-host/src/server/message-coordinator.ts
+++ b/packages/runtime-host/src/server/message-coordinator.ts
@@ -51,6 +51,7 @@ import {
   type QueueEntriesReorderInput,
   type QueueEntryPromoteInput,
   type QueueEntryRetractInput,
+  type QueueEntryUpdateInput,
   type QueueMutationResult,
   type QueueRetractInput,
   type QueueRetractResult,
@@ -185,8 +186,8 @@ export type CandidateSnapshotPreflight = (
 interface LiveEntry {
   readonly entryId: string;
   readonly messageId: string;
-  readonly content: MessageContent;
-  readonly modelContent: MessageContent;
+  content: MessageContent;
+  modelContent: MessageContent;
   readonly initiatingConnectionId: string;
   readonly placement: MessagePlacement;
   readonly disposition: 'steering' | 'followup';
@@ -211,7 +212,12 @@ interface PendingSubmit {
   readonly result: Promise<MessageOutcome<TurnMessageSubmitResult>>;
 }
 
-type QueuedMutationReceiptKind = 'retract' | 'retract_entry' | 'promote' | 'reorder';
+type QueuedMutationReceiptKind =
+  | 'retract'
+  | 'retract_entry'
+  | 'promote'
+  | 'update_entry'
+  | 'reorder';
 
 interface PendingQueuedMutation {
   readonly payload: object;
@@ -295,6 +301,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
     'queue.retract': (input) => this.retract(input),
     'queue.entry.retract': (input) => this.retractQueuedEntry(input),
     'queue.entry.promote': (input) => this.promoteQueuedEntry(input),
+    'queue.entry.update': (input) => this.updateQueuedEntry(input),
     'queue.entries.reorder': (input) => this.reorderQueuedEntries(input),
     'turn.interrupt': (input) => this.interrupt(input),
   };
@@ -830,6 +837,19 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
     });
   }
 
+  private updateQueuedEntry(
+    input: QueueEntryUpdateInput,
+  ): Promise<MessageOutcome<QueueMutationResult>> {
+    return this.#runQueuedMutation({
+      spec: MESSAGE_OPERATION_SPECS['queue.entry.update'],
+      receiptKind: 'update_entry',
+      operationId: input.updateId,
+      verb: 'Update',
+      input,
+      execute: () => this.#updateQueuedEntryAdmitted(input),
+    });
+  }
+
   private reorderQueuedEntries(
     input: QueueEntriesReorderInput,
   ): Promise<MessageOutcome<QueueMutationResult>> {
@@ -1016,6 +1036,96 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
     return success(result);
   }
 
+  async #updateQueuedEntryAdmitted(
+    input: QueueEntryUpdateInput,
+  ): Promise<MessageOutcome<QueueMutationResult>> {
+    const header = await this.#root.readSessionHeader(input.sessionId);
+    if (this.#failStopped) {
+      return failure('host_draining', 'Runtime Host message authority has failed');
+    }
+    if (!header) return failure('not_found', 'Session does not exist');
+    if (header.isArchived) return failure('session_archived', 'Session is archived');
+    const state = this.#state(input.sessionId);
+    if (state.transition) {
+      return failure('operation_conflict', 'Message queue is draining into the next Turn');
+    }
+    const queued = findQueuedEntry(state, input.entryId);
+    if (!queued) {
+      if ([...state.inFlight.values()].some((entry) => entry.entryId === input.entryId)) {
+        return failure('operation_conflict', 'Message entry is already being delivered');
+      }
+      return failure('not_found', 'Message queue entry does not exist');
+    }
+    if (state.revision !== input.expectedQueueRevision) {
+      return failure('operation_conflict', 'Message queue changed since editing began');
+    }
+    if (!state.reservedRoot) {
+      throw new RuntimeMessageAuthorityInvariantError('Queued entry has no root Turn reservation');
+    }
+    const currentRevision = state.revision;
+    const content = normalizeMessageContent({
+      ...queued.entry.content,
+      text: input.text,
+      displayText: input.text,
+      inlineReferences: relocateInlineReferences(queued.entry.content.inlineReferences, input.text),
+    });
+    const prepared = await this.#root.prepareMessage({
+      sessionId: input.sessionId,
+      turnId: state.reservedRoot.turnId,
+      content,
+      placement: queued.entry.placement,
+      initiatingConnectionId: queued.entry.initiatingConnectionId,
+    });
+    if (prepared.kind === 'rejected') return failure('operation_conflict', prepared.error);
+    const modelContent = prepared.content;
+    const candidate = this.#project(state);
+    const updateSnapshot = <T extends SteeringMessageSnapshot | QueuedMessageSnapshot>(
+      entry: T,
+    ): T =>
+      entry.entryId === input.entryId && entry.state === 'queued' ? { ...entry, content } : entry;
+    const updatedProjection = {
+      ...candidate,
+      queueRevision: candidate.queueRevision + 1,
+      steering: candidate.steering.map(updateSnapshot),
+      followup: candidate.followup.map(updateSnapshot),
+    };
+    if (!projectionFitsEveryEntryState(updatedProjection)) {
+      return failure('session_busy', 'Message queue projection capacity is full');
+    }
+    const sources = allLiveEntries(state).map((entry) =>
+      entry === queued.entry
+        ? {
+            ...sourceFromEntry(entry),
+            content: modelContent,
+            submittedContentDigest: messageContentDigest(content),
+          }
+        : sourceFromEntry(entry),
+    ) satisfies RootTurnSourceMessage[];
+    if (!rootAdmissionPayloadFits(sources)) {
+      return failure('session_busy', 'Message queue mutation exceeds root admission capacity');
+    }
+    if (!(await this.#preflightSessionSnapshot(input.sessionId, { queue: updatedProjection }))) {
+      return failure('session_busy', 'Session projection capacity is full');
+    }
+    if (
+      state.revision !== currentRevision ||
+      findQueuedEntry(state, input.entryId)?.entry !== queued.entry
+    ) {
+      return failure('session_busy', 'Message queue changed during update');
+    }
+    queued.entry.content = content;
+    queued.entry.modelContent = modelContent;
+    this.#mutated(state);
+    const result = { queueRevision: state.revision };
+    try {
+      await this.#commitReceipt('update_entry', input.sessionId, input.updateId, input, result);
+    } catch (error) {
+      this.#failStop();
+      throw error;
+    }
+    return success(result);
+  }
+
   async #reorderQueuedEntriesAdmitted(
     input: QueueEntriesReorderInput,
   ): Promise<MessageOutcome<QueueMutationResult>> {
@@ -1618,6 +1728,34 @@ function findQueuedEntry(
   return undefined;
 }
 
+function relocateInlineReferences(
+  references: MessageContent['inlineReferences'],
+  text: string,
+): MessageContent['inlineReferences'] {
+  if (!references) return undefined;
+  const relocated = references
+    .flatMap((reference) => {
+      if (
+        text.slice(reference.start, reference.start + reference.value.length) === reference.value
+      ) {
+        return [reference];
+      }
+      const first = text.indexOf(reference.value);
+      if (first === -1 || text.indexOf(reference.value, first + reference.value.length) !== -1) {
+        return [];
+      }
+      return [{ ...reference, start: first }];
+    })
+    .sort((left, right) => left.start - right.start || right.value.length - left.value.length);
+  const nonOverlapping: NonNullable<MessageContent['inlineReferences']> = [];
+  for (const reference of relocated) {
+    const previous = nonOverlapping.at(-1);
+    if (previous && reference.start < previous.start + previous.value.length) continue;
+    nonOverlapping.push(reference);
+  }
+  return nonOverlapping;
+}
+
 function decodeInterruptReceiptOutcome(value: unknown): MessageOutcome<TurnInterruptResult> {
   if (!value || typeof value !== 'object' || Array.isArray(value)) {
     throw new Error('Interrupt receipt outcome is not an object');
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 46a033e29..70a705555 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -89,6 +89,7 @@ export type MessageOperationKey = Extract<
   | 'queue.retract'
   | 'queue.entry.retract'
   | 'queue.entry.promote'
+  | 'queue.entry.update'
   | 'queue.entries.reorder'
   | 'turn.interrupt'
 >;
diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts
index f6c138c6c..5545a5e44 100644
--- a/packages/runtime/src/__tests__/session-manager.test.ts
+++ b/packages/runtime/src/__tests__/session-manager.test.ts
@@ -15665,6 +15665,11 @@ describe('SessionManager steering and followup queues', () => {
     );
     expect(updates.at(-1)?.steering).toEqual([]);
     expect(updates.at(-1)?.followup).toEqual(['late']);
+    expect(updates.at(-1)?.steeringEntries).toEqual([]);
+    expect(updates.at(-1)?.followupEntries).toHaveLength(1);
+    expect(updates.at(-1)?.followupEntries?.[0]?.content).toEqual({ text: 'late' });
+    expect(updates.at(-1)?.followupEntries?.[0]?.placement).toBe('next_turn');
+    expect(updates.at(-1)?.followupEntries?.[0]?.state).toBe('queued');
     // And the followup queue is the authoritative owner of the text.
     expect(manager.drainFollowup(session.id)).toBe('late');
   });
diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts
index fa6043b72..968b54db9 100644
--- a/packages/runtime/src/runtime-kernel.ts
+++ b/packages/runtime/src/runtime-kernel.ts
@@ -286,7 +286,7 @@ interface SessionSteeringState {
    */
   inFlight: LeasedSteeringMessage[];
   /** Messages waiting to open the next turn. */
-  followup: string[];
+  followup: PendingSteeringMessage[];
   /** Pushes a `queue_update` into the active turn's stream; unset when idle. */
   sink?: (event: QueueUpdateEvent) => void;
   activeTurnId?: string;
@@ -1617,7 +1617,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
           // queue is its only safe home — the same direction a release-time
           // fold takes.
           current.followup = [
-            ...returned.map((message) => message.content.text),
+            ...returned.map(({ id, messageId, content }) => ({ id, messageId, content })),
             ...current.followup,
           ];
         }
@@ -2421,7 +2421,8 @@ export class RuntimeKernel implements RuntimeKernelLike {
     this.assertEmbeddedMessageQueue('queueMessage');
     const state = this.liveSteeringState(sessionId);
     if (!state) return { kind: 'fallback' };
-    state.followup.push(text);
+    const messageId = this.deps.newId();
+    state.followup.push({ id: messageId, messageId, content: { text } });
     this.emitQueueUpdate(sessionId, state);
     return { kind: 'queued' };
   }
@@ -2432,7 +2433,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
     if (!state || state.followup.length === 0) return null;
     const drained = state.followup.splice(0);
     this.emitQueueUpdate(sessionId, state);
-    return drained.join('\n\n');
+    return drained.map((message) => message.content.text).join('\n\n');
   }
 
   retractQueue(sessionId: string): string {
@@ -2445,7 +2446,10 @@ export class RuntimeKernel implements RuntimeKernelLike {
     // handing its text back to the user here would refill AND execute the
     // same directive. An in-flight lease settles only by the persistence
     // fact (ack when the ledger owns it, nack back to a queue otherwise).
-    const all = [...state.steering.map((message) => message.content.text), ...state.followup];
+    const all = [
+      ...state.steering.map((message) => message.content.text),
+      ...state.followup.map((message) => message.content.text),
+    ];
     state.steering = [];
     state.followup = [];
     this.emitQueueUpdate(sessionId, state);
@@ -2489,7 +2493,30 @@ export class RuntimeKernel implements RuntimeKernelLike {
         ...state.inFlight.map((message) => message.content.text),
         ...state.steering.map((message) => message.content.text),
       ],
-      followup: [...state.followup],
+      followup: state.followup.map((message) => message.content.text),
+      steeringEntries: [
+        ...state.inFlight.map((message) => ({
+          entryId: message.id,
+          messageId: message.messageId,
+          content: message.content,
+          placement: 'current_turn' as const,
+          state: 'in_flight' as const,
+        })),
+        ...state.steering.map((message) => ({
+          entryId: message.id,
+          messageId: message.messageId,
+          content: message.content,
+          placement: 'current_turn' as const,
+          state: 'queued' as const,
+        })),
+      ],
+      followupEntries: state.followup.map((message) => ({
+        entryId: message.id,
+        messageId: message.messageId,
+        content: message.content,
+        placement: 'next_turn' as const,
+        state: 'queued' as const,
+      })),
     });
   }
 
@@ -2518,7 +2545,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
       // backstop that keeps a never-settled lease from stranding invisibly.
       if (own.length === 0) return;
       state.inFlight = state.inFlight.filter((message) => message.issuingTurnId !== turnId);
-      state.followup = [...own.map((message) => message.content.text), ...state.followup];
+      state.followup = [...own, ...state.followup];
       this.emitQueueUpdate(sessionId, state);
       return;
     }
@@ -2528,11 +2555,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
     // migration is a queue change, so emit the final snapshot BEFORE the sink
     // is cleared; otherwise observers stay on the stale pre-fold snapshot.
     if (state.steering.length > 0 || own.length > 0) {
-      state.followup = [
-        ...own.map((message) => message.content.text),
-        ...state.steering.map((message) => message.content.text),
-        ...state.followup,
-      ];
+      state.followup = [...own, ...state.steering, ...state.followup];
       state.inFlight = state.inFlight.filter((message) => message.issuingTurnId !== turnId);
       state.steering = [];
       this.emitQueueUpdate(sessionId, state);
diff --git a/packages/storage/src/message-receipt-store.ts b/packages/storage/src/message-receipt-store.ts
index 62dda5b85..82bcd2727 100644
--- a/packages/storage/src/message-receipt-store.ts
+++ b/packages/storage/src/message-receipt-store.ts
@@ -34,6 +34,7 @@ export type MessageReceiptOperation =
   | 'retract'
   | 'retract_entry'
   | 'promote'
+  | 'update_entry'
   | 'reorder'
   | 'interrupt';
 
@@ -248,6 +249,7 @@ function validateIdentity(
     operation !== 'retract' &&
     operation !== 'retract_entry' &&
     operation !== 'promote' &&
+    operation !== 'update_entry' &&
     operation !== 'reorder' &&
     operation !== 'interrupt'
   ) {
diff --git a/packages/ui/src/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
index 2a5b70ab0..c4a097c1a 100644
--- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx
+++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx
@@ -83,21 +83,42 @@ test('keeps Host order visible until the reordered projection arrives', async ()
   assert.ok(container);
   const root = createRoot(container);
   let requestedOrder: readonly string[] | undefined;
+  const updatedEntries: Array<{
+    entryId: string;
+    expectedQueueRevision: number;
+    text: string;
+  }> = [];
+  const deletedEntryIds: string[] = [];
 
   try {
     await act(() => root.render(
       <LocaleProvider locale="en">
         <Composer
           streaming
-          queuedMessages={['first', 'second'].map((entryId) => ({
-            entryId,
-            messageId: `message-${entryId}`,
-            content: { text: entryId },
-            placement: 'next_turn',
-            state: 'queued',
-          }))}
+          queuedMessages={[
+            {
+              entryId: 'steering',
+              messageId: 'message-steering',
+              content: { text: 'steering' },
+              placement: 'current_turn',
+              state: 'queued',
+            },
+            ...['first', 'second'].map((entryId) => ({
+              entryId,
+              messageId: `message-${entryId}`,
+              content: { text: entryId },
+              placement: 'next_turn' as const,
+              state: 'queued' as const,
+            })),
+          ]}
+          queuedMessageRevision={7}
           onPromoteQueuedEntry={() => undefined}
-          onRetractQueuedEntry={() => undefined}
+          onUpdateQueuedEntry={(entryId, expectedQueueRevision, text) => {
+            updatedEntries.push({ entryId, expectedQueueRevision, text });
+          }}
+          onDeleteQueuedEntry={(entryId) => {
+            deletedEntryIds.push(entryId);
+          }}
           onReorderQueuedEntries={(entryIds) => {
             requestedOrder = entryIds;
             return new Promise<void>(() => undefined);
@@ -107,8 +128,44 @@ test('keeps Host order visible until the reordered projection arrives', async ()
         />
       </LocaleProvider>,
     ));
-    assert.equal(container.querySelectorAll('[aria-label="Send now"]').length, 2);
-    assert.equal(container.querySelectorAll('[aria-label="Restore to draft"]').length, 2);
+    const buttons = [...container.querySelectorAll<HTMLButtonElement>('button')];
+    const editButtons = buttons.filter((button) => button.textContent === 'Edit');
+    const deleteButtons = [
+      ...container.querySelectorAll<HTMLButtonElement>('[aria-label="Delete"]'),
+    ];
+    assert.equal(buttons.filter((button) => button.textContent === 'Steer').length, 2);
+    assert.equal(editButtons.length, 3);
+    assert.equal(deleteButtons.length, 3);
+    await act(async () => {
+      editButtons[0]?.dispatchEvent(new window.Event('click', { bubbles: true }));
+      await Promise.resolve();
+    });
+    const editInput = container.querySelector<HTMLTextAreaElement>(
+      'textarea[aria-label="Edit"]',
+    );
+    assert.ok(editInput);
+    await act(() => {
+      editInput.value = 'updated steering\nsecond line';
+      editInput.dispatchEvent(new window.Event('input', { bubbles: true }));
+    });
+    await act(async () => {
+      container
+        .querySelector<HTMLButtonElement>('[aria-label="Save"]')
+        ?.dispatchEvent(new window.Event('click', { bubbles: true }));
+      await Promise.resolve();
+    });
+    await act(async () => {
+      container
+        .querySelector<HTMLButtonElement>('[aria-label="Delete"]')
+        ?.dispatchEvent(new window.Event('click', { bubbles: true }));
+      await Promise.resolve();
+    });
+    assert.deepEqual(updatedEntries, [{
+      entryId: 'steering',
+      expectedQueueRevision: 7,
+      text: 'updated steering\nsecond line',
+    }]);
+    assert.deepEqual(deletedEntryIds, ['steering']);
     const grips = [...container.querySelectorAll<HTMLElement>('.maka-composer-queue-grip')];
     assert.equal(grips.length, 2);
     const dragStart = new window.Event('dragstart', { bubbles: true });
@@ -116,16 +173,22 @@ test('keeps Host order visible until the reordered projection arrives', async ()
       value: { effectAllowed: '', setData() {} },
     });
     await act(() => grips[1]?.dispatchEvent(dragStart));
+    const rows = [...container.querySelectorAll('li')];
+    const steeringRow = rows[0]?.parentElement;
+    assert.ok(steeringRow);
+    await act(() => steeringRow.dispatchEvent(new window.Event('drop', { bubbles: true })));
+    assert.equal(requestedOrder, undefined);
     const firstRow = grips[0]?.closest('li')?.parentElement;
     assert.ok(firstRow);
     await act(() => firstRow.dispatchEvent(new window.Event('drop', { bubbles: true })));
 
     assert.deepEqual(requestedOrder, ['second', 'first']);
     assert.deepEqual(
-      [...container.querySelectorAll('li')].map((row) =>
-        row.textContent?.includes('first') ? 'first' : 'second'
-      ),
-      ['first', 'second'],
+      rows.map((row) => {
+        if (row.textContent?.includes('steering')) return 'steering';
+        return row.textContent?.includes('first') ? 'first' : 'second';
+      }),
+      ['steering', 'first', 'second'],
     );
   } finally {
     await act(() => root.unmount());
diff --git a/packages/ui/src/composer-message-queue.tsx b/packages/ui/src/composer-message-queue.tsx
index bf5a35c12..c717a6685 100644
--- a/packages/ui/src/composer-message-queue.tsx
+++ b/packages/ui/src/composer-message-queue.tsx
@@ -19,25 +19,24 @@
 
 import { memo, useRef, useState } from 'react';
 import type { MessageQueueEntryProjection } from '@maka/core/events';
-import { IconButton } from '@astryxdesign/core';
+import { Button, IconButton } from '@astryxdesign/core';
 import { List, ListItem } from '@astryxdesign/core/List';
 import type { ConversationCopy } from './conversation-copy.js';
-import { CornerDownRight, GripVertical, ICON_SIZE, Undo2 } from './icons.js';
+import { Check, GripVertical, ICON_SIZE, Trash2, X } from './icons.js';
 import { useMountedRef } from './use-mounted-ref.js';
 
 /**
- * The pending plate above the composer card: the follow-up queue in send
- * order (first at the top). Steering entries never appear here — 立即发送 and
- * Shift+Enter hand a message to the active Turn, and it leaves the plate at
- * that moment (it surfaces in the transcript when the Turn consumes it).
- * Every action round-trips through the Runtime Host — the plate mirrors the
- * authoritative queue projection, never a local copy.
+ * The pending plate above the composer card. It mirrors both pending steering
+ * and follow-up entries so a submitted message never disappears while waiting
+ * for the active Turn to reach a steering boundary.
  */
 export interface ComposerMessageQueueProps {
   queuedMessages: readonly MessageQueueEntryProjection[];
+  queueRevision?: number;
   copy: ConversationCopy['composer'];
   onPromoteEntry?(entryId: string): void | Promise<void>;
-  onRetractEntry?(entry: MessageQueueEntryProjection): void | Promise<void>;
+  onUpdateEntry?(entryId: string, expectedQueueRevision: number, text: string): void | Promise<void>;
+  onDeleteEntry?(entryId: string): void | Promise<void>;
   onReorderEntries?(entryIds: readonly string[]): void | Promise<void>;
 }
 
@@ -45,24 +44,30 @@ export const ComposerMessageQueue = memo(function ComposerMessageQueue(
   props: ComposerMessageQueueProps,
 ) {
   const [pendingEntryId, setPendingEntryId] = useState<string | null>(null);
+  const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
+  const [editingQueueRevision, setEditingQueueRevision] = useState(0);
+  const [editingText, setEditingText] = useState('');
   const dragEntryId = useRef<string | null>(null);
   const mountedRef = useMountedRef();
   const copy = props.copy;
 
-  const followup = props.queuedMessages;
+  const entries = props.queuedMessages;
+  const followup = entries.filter((entry) => entry.placement === 'next_turn');
 
   async function runEntryAction(
     entryId: string,
     action: (() => void | Promise<void>) | undefined,
-  ) {
-    if (!action || pendingEntryId) return;
+  ): Promise<boolean> {
+    if (!action || pendingEntryId) return false;
     setPendingEntryId(entryId);
     try {
       // The caller (app shell) surfaces failures itself; the projection is
       // unchanged on failure, so there is nothing to settle here.
       await action();
+      return true;
     } catch {
       // surfaced by the caller
+      return false;
     } finally {
       if (mountedRef.current) setPendingEntryId(null);
     }
@@ -83,46 +88,85 @@ export const ComposerMessageQueue = memo(function ComposerMessageQueue(
     void runEntryAction(fromId, () => props.onReorderEntries?.(ids));
   }
 
-  function retractButton(entry: MessageQueueEntryProjection) {
-    return (
-      <IconButton
-        variant="ghost"
-        size="sm"
-        type="button"
-        isDisabled={pendingEntryId !== null}
-        label={copy.retractQueuedEntry}
-        tooltip={copy.retractQueuedEntry}
-        onClick={() => void runEntryAction(
-          entry.entryId,
-          props.onRetractEntry ? () => props.onRetractEntry?.(entry) : undefined,
-        )}
-        icon={<Undo2 size={ICON_SIZE.control} aria-hidden="true" />}
-      />
+  function beginEdit(entry: MessageQueueEntryProjection) {
+    if (pendingEntryId || !props.onUpdateEntry || props.queueRevision === undefined) return;
+    setEditingEntryId(entry.entryId);
+    const text = entry.content.displayText ?? entry.content.text;
+    setEditingQueueRevision(props.queueRevision);
+    setEditingText(text);
+  }
+
+  async function commitEdit(entryId: string) {
+    const text = editingText.trim();
+    if (!text) return;
+    const updated = await runEntryAction(entryId, () =>
+      props.onUpdateEntry?.(entryId, editingQueueRevision, text)
     );
+    if (updated && mountedRef.current) {
+      setEditingEntryId(null);
+      setEditingQueueRevision(0);
+      setEditingText('');
+    }
+  }
+
+  function cancelEdit() {
+    setEditingEntryId(null);
+    setEditingQueueRevision(0);
+    setEditingText('');
   }
 
   return (
     <div
       className="maka-composer-queue"
       role="region"
-      aria-label={copy.queuedMessagesAriaLabel(followup.length)}
+      aria-label={copy.queuedMessagesAriaLabel(entries.length)}
     >
       <List className="maka-composer-queue-list" density="compact">
-        {followup.map((entry) => (
-          <div
-            key={entry.entryId}
-            onDragOver={(event) => {
-              if (dragEntryId.current) event.preventDefault();
-            }}
-            onDrop={() => dropOn(entry.entryId)}
-          >
-            <ListItem
-              label={entry.content.displayText ?? entry.content.text}
+        {entries.map((entry) => {
+          const editing = editingEntryId === entry.entryId;
+          const reorderable =
+            entry.placement === 'next_turn'
+            && entry.state === 'queued'
+            && !editing
+            && Boolean(props.onReorderEntries)
+            && pendingEntryId === null;
+          return (
+            <div
+              key={entry.entryId}
+              onDragOver={(event) => {
+                if (reorderable && dragEntryId.current) event.preventDefault();
+              }}
+              onDrop={reorderable ? () => dropOn(entry.entryId) : undefined}
+            >
+              <ListItem
+              label={editing ? (
+                <textarea
+                  autoFocus
+                  className="maka-composer-queue-edit"
+                  aria-label={copy.editQueuedEntry}
+                  rows={1}
+                  value={editingText}
+                  onInput={(event) => setEditingText(event.currentTarget.value)}
+                  onKeyDown={(event) => {
+                    if (
+                      event.key === 'Enter'
+                      && !event.shiftKey
+                      && !event.nativeEvent.isComposing
+                    ) {
+                      event.preventDefault();
+                      void commitEdit(entry.entryId);
+                    } else if (event.key === 'Escape') {
+                      event.preventDefault();
+                      cancelEdit();
+                    }
+                  }}
+                />
+              ) : entry.content.displayText ?? entry.content.text}
               style={{ minHeight: 28, paddingBlock: 0 }}
-              startContent={(
+              startContent={entry.placement === 'next_turn' ? (
                 <span
                   className="maka-composer-queue-grip"
-                  draggable={Boolean(props.onReorderEntries) && pendingEntryId === null}
+                  draggable={reorderable}
                   aria-label={copy.reorderQueuedEntry}
                   onDragStart={(event) => {
                     dragEntryId.current = entry.entryId;
@@ -135,30 +179,84 @@ export const ComposerMessageQueue = memo(function ComposerMessageQueue(
                 >
                   <GripVertical size={ICON_SIZE.control} aria-hidden="true" />
                 </span>
-              )}
+              ) : undefined}
               endContent={(
-                <>
-                  <IconButton
-                    variant="ghost"
-                    size="sm"
-                    type="button"
-                    isDisabled={pendingEntryId !== null}
-                    label={copy.promoteQueuedEntry}
-                    tooltip={copy.promoteQueuedEntry}
-                    onClick={() => void runEntryAction(
-                      entry.entryId,
-                      props.onPromoteEntry
-                        ? () => props.onPromoteEntry?.(entry.entryId)
-                        : undefined,
-                    )}
-                    icon={<CornerDownRight size={ICON_SIZE.control} aria-hidden="true" />}
-                  />
-                  {retractButton(entry)}
-                </>
+                <span className="maka-composer-queue-actions">
+                  {editing ? (
+                    <>
+                      <IconButton
+                        variant="ghost"
+                        size="sm"
+                        type="button"
+                        isDisabled={pendingEntryId !== null || editingText.trim().length === 0}
+                        label={copy.saveQueuedEntry}
+                        tooltip={copy.saveQueuedEntry}
+                        onClick={() => void commitEdit(entry.entryId)}
+                        icon={<Check size={ICON_SIZE.control} aria-hidden="true" />}
+                      />
+                      <IconButton
+                        variant="ghost"
+                        size="sm"
+                        type="button"
+                        isDisabled={pendingEntryId !== null}
+                        label={copy.cancelQueuedEntryEdit}
+                        tooltip={copy.cancelQueuedEntryEdit}
+                        onClick={cancelEdit}
+                        icon={<X size={ICON_SIZE.control} aria-hidden="true" />}
+                      />
+                    </>
+                  ) : (
+                    <>
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        type="button"
+                        isDisabled={
+                          pendingEntryId !== null
+                          || entry.state !== 'queued'
+                          || props.queueRevision === undefined
+                        }
+                        label={copy.editQueuedEntry}
+                        onClick={() => beginEdit(entry)}
+                      />
+                      {entry.placement === 'next_turn' ? (
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          type="button"
+                          isDisabled={pendingEntryId !== null || entry.state !== 'queued'}
+                          label={copy.promoteQueuedEntry}
+                          onClick={() => void runEntryAction(
+                            entry.entryId,
+                            props.onPromoteEntry
+                              ? () => props.onPromoteEntry?.(entry.entryId)
+                              : undefined,
+                          )}
+                        />
+                      ) : null}
+                      <IconButton
+                        variant="ghost"
+                        size="sm"
+                        type="button"
+                        isDisabled={pendingEntryId !== null || entry.state !== 'queued'}
+                        label={copy.deleteQueuedEntry}
+                        tooltip={copy.deleteQueuedEntry}
+                        onClick={() => void runEntryAction(
+                          entry.entryId,
+                          props.onDeleteEntry
+                            ? () => props.onDeleteEntry?.(entry.entryId)
+                            : undefined,
+                        )}
+                        icon={<Trash2 size={ICON_SIZE.control} aria-hidden="true" />}
+                      />
+                    </>
+                  )}
+                </span>
               )}
             />
-          </div>
-        ))}
+            </div>
+          );
+        })}
       </List>
     </div>
   );
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index c61236553..8ea830e99 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -247,10 +247,17 @@ export const Composer = forwardRef<
     /** True while the current streaming session is processing a stop request. */
     stopPending?: boolean;
     queuedMessages?: readonly MessageQueueEntryProjection[];
-    /** Promote a queued follow-up into the active Turn (立即发送). */
+    queuedMessageRevision?: number;
+    /** Promote a queued follow-up into the active Turn (调整方向). */
     onPromoteQueuedEntry?(entryId: string): void | Promise<void>;
-    /** Retract one queued entry back into the draft (收回草稿). */
-    onRetractQueuedEntry?(entry: MessageQueueEntryProjection): void | Promise<void>;
+    /** Update one queued entry in place without changing its order or placement. */
+    onUpdateQueuedEntry?(
+      entryId: string,
+      expectedQueueRevision: number,
+      text: string,
+    ): void | Promise<void>;
+    /** Remove one queued entry without restoring it. */
+    onDeleteQueuedEntry?(entryId: string): void | Promise<void>;
     /** Reorder the follow-up queue; entryIds is the full intended order. */
     onReorderQueuedEntries?(entryIds: readonly string[]): void | Promise<void>;
     /** Runtime-only key used to keep unsent drafts isolated per session. */
@@ -1532,11 +1539,13 @@ export const Composer = forwardRef<
         </div>
       )}
       {!props.hidden && queueCount > 0 ? (
-        <ComposerMessageQueue
-          queuedMessages={props.queuedMessages!}
+          <ComposerMessageQueue
+            queuedMessages={props.queuedMessages!}
+            queueRevision={props.queuedMessageRevision}
           copy={copy}
           onPromoteEntry={props.onPromoteQueuedEntry}
-          onRetractEntry={props.onRetractQueuedEntry}
+          onUpdateEntry={props.onUpdateQueuedEntry}
+          onDeleteEntry={props.onDeleteQueuedEntry}
           onReorderEntries={props.onReorderQueuedEntries}
         />
       ) : null}
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 983089caa..0da5e13a7 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -111,7 +111,10 @@ export interface ConversationCopy {
     sendLabel: string;
     queuedMessagesAriaLabel(count: number): string;
     promoteQueuedEntry: string;
-    retractQueuedEntry: string;
+    editQueuedEntry: string;
+    saveQueuedEntry: string;
+    cancelQueuedEntryEdit: string;
+    deleteQueuedEntry: string;
     reorderQueuedEntry: string;
     stopLabel: string;
     stopping: string;
@@ -418,7 +421,7 @@ const CONVERSATION_COPY = {
       placeholder: '描述任务,@ 引用文件,/ 选择技能…', textareaAriaLabel: '消息输入框', pastedQuoteLabel: '粘贴的文本', selectedSkillsAriaLabel: '已选择的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你确认权限…',
       sending: '正在发送…', importing: '正在导入…', sendLabel: '发送',
       queuedMessagesAriaLabel: (count) => `${count} 条待发送消息`,
-      promoteQueuedEntry: '立即发送', retractQueuedEntry: '收回草稿', reorderQueuedEntry: '拖动排序',
+      promoteQueuedEntry: '调整方向', editQueuedEntry: '编辑', saveQueuedEntry: '保存', cancelQueuedEntryEdit: '取消编辑', deleteQueuedEntry: '删除', reorderQueuedEntry: '拖动排序',
       stopLabel: '停止', stopping: '停止中…',
       streaming: 'Maka 正在回答…', processing: 'Maka 正在处理…', continuing: 'Maka 继续中…',
       interruptHint: '或点停止中断', addContext: '添加上下文', stagedContext: '附加内容',
@@ -569,7 +572,7 @@ const CONVERSATION_COPY = {
       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',
       queuedMessagesAriaLabel: (count) => `${count} queued message${count === 1 ? '' : 's'}`,
-      promoteQueuedEntry: 'Send now', retractQueuedEntry: 'Restore to draft', reorderQueuedEntry: 'Drag to reorder',
+      promoteQueuedEntry: 'Steer', editQueuedEntry: 'Edit', saveQueuedEntry: 'Save', cancelQueuedEntryEdit: 'Cancel editing', deleteQueuedEntry: 'Delete', reorderQueuedEntry: 'Drag to reorder',
       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',


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