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/09/20 19:08:34 UTC
(maka) branch feat/5488-ui-first-startup created (now 1c79232924)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a change to branch feat/5488-ui-first-startup
in repository https://gitbox.apache.org/repos/asf/maka.git
at 1c79232924 fix(desktop): keep revision send alive past a slow transcript open
This branch includes the following new commits:
new 1c79232924 fix(desktop): keep revision send alive past a slow transcript open
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/01: fix(desktop): keep revision send alive past a slow transcript open
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/5488-ui-first-startup
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 1c79232924e70a2b213031c81c0ed7bc15dcf553
Author: AstroHan <le...@outlook.com>
AuthorDate: Mon Sep 21 03:08:20 2026 +0800
fix(desktop): keep revision send alive past a slow transcript open
prepareRevisionSend read the revised Session's transcript with a 480ms
budget that also covers transcripts.open, which waits on Host admission
and legitimately takes far longer under load. When it lapsed, the catch
ran rollback — whose navigation bumps the selection revision — and only
then checked selectionIsCurrent, which was therefore always stale, so
the failure was discarded without a toast and the send vanished.
A transcript replica that is still opening must not gate the send: the
Session view fills from its own consumer and the write boundary is Host
admission. tolerateOpenTimeout reports such an open as unsettled instead
of failing; real open errors and caller aborts still propagate. Failure
feedback now runs before rollback so rollback's own navigation cannot
invalidate it.
Generated-by: Devin
---
apps/desktop/renderer-architecture.json | 2 +-
.../__tests__/app-shell-revision-actions.test.ts | 110 +++++++++++++++++++--
.../desktop-transcript-range-store.test.ts | 40 ++++++++
.../src/renderer/app-shell-revision-actions.ts | 36 ++++---
.../platform/desktop/session-message-settlement.ts | 22 ++++-
5 files changed, 186 insertions(+), 24 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 06419834d1..57bb5dec59 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -580,7 +580,7 @@
"@maka/core/session": 1
},
"importSpecifiers": 8,
- "nonTriviaTokens": 2155
+ "nonTriviaTokens": 2154
},
"src/renderer/app-shell-session-events.ts": {
"importDeclarations": 2,
diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
index d7f35be7f7..0bfe8a019b 100644
--- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
@@ -23,6 +23,9 @@ import { describe, it } from 'node:test';
import type { StoredMessage } from '@maka/core/session';
import { createAppShellRevisionActions } from '../../renderer/app-shell-revision-actions.js';
+const SESSION_1 = JSON.stringify(['host-1', 'session-1']);
+const SESSION_2 = JSON.stringify(['host-1', 'session-2']);
+
function userMessage(turnId: string, text: string, extra: Record<string, unknown> = {}): StoredMessage {
return {
id: `msg-${turnId}`,
@@ -36,11 +39,19 @@ function userMessage(turnId: string, text: string, extra: Record<string, unknown
function createActions(input: { messages: StoredMessage[] }) {
const drafts: unknown[] = [];
+ const errors: string[] = [];
+ const infos: string[] = [];
let composerText = '';
+ let selectionRevision = 0;
+ const activeIdRef: { current: string | undefined } = { current: SESSION_1 };
const revisionDraftRef: { current: unknown } = { current: null };
const actions = createAppShellRevisionActions({
uiLocale: 'en' as never,
- activeIdRef: { current: 'session-1' },
+ activeIdRef,
+ captureSelection: () => {
+ const revision = selectionRevision;
+ return () => selectionRevision === revision;
+ },
composerRef: {
current: {
getText: () => composerText,
@@ -56,8 +67,10 @@ function createActions(input: { messages: StoredMessage[] }) {
},
messages: input.messages,
hasPendingAttachments: () => false,
- openSessionInChat: () => {},
- refreshMessages: async () => true,
+ openSessionInChat: (sessionId: string) => {
+ selectionRevision += 1;
+ activeIdRef.current = sessionId;
+ },
refreshSessions: async () => [],
setMessages: () => {},
commitRevisionDraft: (draft: unknown) => {
@@ -66,11 +79,28 @@ function createActions(input: { messages: StoredMessage[] }) {
},
revisionDraftRef,
toastApi: {
- info: () => {},
- error: () => {},
+ info: (title: string) => infos.push(title),
+ error: (title: string) => errors.push(title),
},
} as never);
- return Object.assign(actions, { drafts, composerState: { get text(): string { return composerText; } } });
+ return Object.assign(actions, {
+ drafts,
+ errors,
+ infos,
+ activeIdRef,
+ composerState: { get text(): string { return composerText; } },
+ });
+}
+
+async function withWindowMaka(maka: unknown, run: () => Promise<void>): Promise<void> {
+ const target = globalThis as { window?: unknown };
+ const previous = target.window;
+ target.window = { maka };
+ try {
+ await run();
+ } finally {
+ target.window = previous;
+ }
}
describe('app-shell revision actions with structured context (#5109)', () => {
@@ -120,3 +150,71 @@ describe('app-shell revision actions with structured context (#5109)', () => {
assert.equal(h.drafts.at(-1), undefined, 'attachment-bearing sources stay explicitly rejected');
});
});
+
+describe('prepareRevisionSend transcript settlement', () => {
+ it('keeps the send alive while the revision transcript is still opening', async () => {
+ let abandoned = 0;
+ await withWindowMaka(
+ {
+ sessions: {
+ reviseBeforeTurn: async () => ({ id: SESSION_2 }),
+ abandonSessionCopy: async () => {
+ abandoned += 1;
+ },
+ },
+ transcripts: {
+ open: async (
+ _sessionId: string,
+ _handler: unknown,
+ registerCancellation: (cancel: () => void) => void,
+ ) =>
+ new Promise<never>((_resolve, reject) => {
+ registerCancellation(() => reject(new Error('open cancelled')));
+ }),
+ readTurn: async () => [],
+ },
+ },
+ async () => {
+ const h = createActions({ messages: [userMessage('turn-1', 'original')] });
+ h.beginEditUserMessage('turn-1');
+ assert.equal(await h.prepareRevisionSend('edited'), true);
+ assert.equal(abandoned, 0, 'a lagging replica must not roll back the revision');
+ assert.equal(h.activeIdRef.current, SESSION_2);
+ assert.deepEqual(h.errors, []);
+ assert.equal(
+ (h.drafts.at(-1) as { draftSessionId?: string }).draftSessionId,
+ SESSION_2,
+ );
+ },
+ );
+ });
+
+ it('surfaces a failed preparation instead of swallowing it behind rollback', async () => {
+ let abandoned = 0;
+ await withWindowMaka(
+ {
+ sessions: {
+ reviseBeforeTurn: async () => ({ id: SESSION_2 }),
+ abandonSessionCopy: async () => {
+ abandoned += 1;
+ },
+ },
+ transcripts: {
+ open: async () => {
+ throw new Error('Host lost the Session');
+ },
+ readTurn: async () => [],
+ },
+ },
+ async () => {
+ const h = createActions({ messages: [userMessage('turn-1', 'original')] });
+ h.beginEditUserMessage('turn-1');
+ assert.equal(await h.prepareRevisionSend('edited'), false);
+ assert.equal(h.errors.length, 1, 'the failure must reach the user before rollback navigates away');
+ assert.equal(abandoned, 1);
+ assert.equal(h.activeIdRef.current, SESSION_1);
+ assert.equal(h.composerState.text, 'edited');
+ },
+ );
+ });
+});
diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
index e4281aefac..e7729bcd79 100644
--- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
@@ -108,6 +108,46 @@ test('cancels settlement while transcript open is pending', async () => {
}
});
+test('reports an unopened transcript as unsettled when the caller tolerates it', async () => {
+ let cancelRequested = false;
+ const result = await readSettledMessagesFrom(
+ {
+ transcripts: {
+ readTurn: async () => [],
+ open: async (_sessionId, _handler, registerCancellation) =>
+ new Promise<never>((_resolve, reject) => {
+ registerCancellation?.(() => {
+ cancelRequested = true;
+ reject(new Error('open cancelled'));
+ });
+ }),
+ },
+ },
+ JSON.stringify(['host-1', 'session-1']),
+ { tolerateOpenTimeout: true },
+ );
+ assert.deepEqual(result, { messages: [], settled: false });
+ assert.equal(cancelRequested, true);
+});
+
+test('an unopened transcript still fails settlement by default', async () => {
+ await assert.rejects(
+ readSettledMessagesFrom(
+ {
+ transcripts: {
+ readTurn: async () => [],
+ open: async (_sessionId, _handler, registerCancellation) =>
+ new Promise<never>((_resolve, reject) => {
+ registerCancellation?.(() => reject(new Error('open cancelled')));
+ }),
+ },
+ },
+ JSON.stringify(['host-1', 'session-1']),
+ ),
+ /timed out while opening/,
+ );
+});
+
test('reads one Host-owned Turn outside the bounded transcript tail', async () => {
const sessionKey = JSON.stringify(['host-1', 'session-1']);
const turnB: StoredMessage[] = [
diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts
index 9325d66de7..76e0650ecc 100644
--- a/apps/desktop/src/renderer/app-shell-revision-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts
@@ -318,10 +318,13 @@ export function createAppShellRevisionActions(deps: {
commitRevisionDraft(prepared);
openSessionInChat(newSession.id);
selectionIsCurrent = captureSelection();
+ // A transcript replica that is still opening must not eat the send: the
+ // Session view already fills from its own consumer, and the write
+ // boundary is Host admission, not read readiness.
const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, {
signal: preparationAbort.signal,
+ tolerateOpenTimeout: true,
});
- if (!settled) throw new Error('Revised Session transcript did not become ready');
if (
!selectionIsCurrent() || activeIdRef.current !== newSession.id ||
revisionDraftRef.current !== prepared
@@ -329,29 +332,32 @@ export function createAppShellRevisionActions(deps: {
await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent);
return false;
}
- setMessages(preparedMessages);
+ if (settled) setMessages(preparedMessages);
composerRef.current?.focus();
toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription);
await refreshSessions();
return true;
} catch (error) {
if (preparationAbort.signal.aborted) return false;
+ // Rollback itself navigates back to the source Session, so the failure
+ // must be surfaced before it runs — checking after it is always stale.
+ if (selectionIsCurrent()) {
+ if (isSessionWorkspaceUnavailableError(error)) {
+ showSessionWorkspaceUnavailableToast(toastApi, uiLocale, {
+ sessionId: sourceSessionId,
+ });
+ } else {
+ toastApi.error(
+ copy.operationFailedTitle,
+ localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
+ undefined,
+ { sessionId: sourceSessionId },
+ );
+ }
+ }
if (preparedSessionId) {
await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent);
}
- if (!selectionIsCurrent()) return false;
- if (isSessionWorkspaceUnavailableError(error)) {
- showSessionWorkspaceUnavailableToast(toastApi, uiLocale, {
- sessionId: sourceSessionId,
- });
- } else {
- toastApi.error(
- copy.operationFailedTitle,
- localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
- undefined,
- { sessionId: sourceSessionId },
- );
- }
return false;
} finally {
if (revisionPreparationAbort === preparationAbort) revisionPreparationAbort = undefined;
diff --git a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
index 7508a2babc..856e76d178 100644
--- a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
+++ b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
@@ -27,6 +27,11 @@ export interface RefreshMessagesOptions {
requiredAssistantMessageId?: string;
requiredTurnId?: string;
signal?: AbortSignal;
+ /**
+ * A transcript open that outlives the deadline reports unsettled instead of
+ * failing, for callers whose work does not depend on the replica.
+ */
+ tolerateOpenTimeout?: boolean;
}
export type TranscriptSettlementSource = {
@@ -69,8 +74,16 @@ export async function readSettledMessagesFrom(
const abort = () => cancel(new Error('Desktop transcript settlement was cancelled'));
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
+ let openLapsed = false;
const openTimeout = globalThis.setTimeout(
- () => cancel(new Error('Desktop transcript settlement timed out while opening')),
+ () => {
+ if (options.tolerateOpenTimeout) {
+ openLapsed = true;
+ cancelOpen();
+ } else {
+ cancel(new Error('Desktop transcript settlement timed out while opening'));
+ }
+ },
Math.max(0, deadline - Date.now()),
);
const opening = source.transcripts.open(
@@ -90,7 +103,12 @@ export async function readSettledMessagesFrom(
void opening.catch(() => undefined);
let handle: Awaited<typeof opening> | undefined;
try {
- handle = await Promise.race([opening, cancellation]);
+ try {
+ handle = await Promise.race([opening, cancellation]);
+ } catch (error) {
+ if (openLapsed) return { messages: [], settled: false };
+ throw error;
+ }
globalThis.clearTimeout(openTimeout);
const requiredTurnId = options.requiredTurnId;
/*
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org