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