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