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