Skip to content

Commit 5df229e

Browse files
Rangshcursoragent
andcommitted
fix(desktop): keep mid-turn interrupt off AppShell bridge debt
Route plain-Enter interrupt through createAppShellStopAction so app-shell.tsx does not grow window.maka.sessions.stop bridgePaths, and refresh the renderer architecture ledger after the token-neutral comment fold that keeps nonTriviaTokens under the ratchet. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 36bd2fb commit 5df229e

4 files changed

Lines changed: 72 additions & 72 deletions

File tree

‎apps/desktop/renderer-architecture.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@
656656
"./locales/shell-copy.js": 1
657657
},
658658
"importSpecifiers": 2,
659-
"nonTriviaTokens": 302
659+
"nonTriviaTokens": 301
660660
},
661661
"src/renderer/app-shell-turn-actions.ts": {
662662
"importDeclarations": 4,
@@ -887,8 +887,8 @@
887887
"@maka/ui/icons": 1,
888888
"react": 1
889889
},
890-
"importSpecifiers": 148,
891-
"nonTriviaTokens": 15601
890+
"importSpecifiers": 147,
891+
"nonTriviaTokens": 15600
892892
},
893893
"src/renderer/use-app-shell-composer-quotes.ts": {
894894
"importDeclarations": 2,

‎apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ test('removes exactly the transient messages the Host retracts while stopping',
4444
toastApi: { error() {} },
4545
});
4646

47-
await stop();
48-
47+
assert.equal(await stop(), true);
4948
assert.deepEqual(removed, [
5049
{ sessionId: 'session-1', messageId: 'message-1' },
5150
{ sessionId: 'session-1', messageId: 'message-2' },
@@ -54,3 +53,36 @@ test('removes exactly the transient messages the Host retracts while stopping',
5453
target.window = previousWindow;
5554
}
5655
});
56+
57+
test('returns undefined when stop fails so plain-Enter send can abort', async () => {
58+
const target = globalThis as unknown as { window?: unknown };
59+
const previousWindow = target.window;
60+
const errors: string[] = [];
61+
target.window = {
62+
maka: {
63+
sessions: {
64+
stop: async () => {
65+
throw new Error('stop failed');
66+
},
67+
},
68+
},
69+
};
70+
try {
71+
const stop = createAppShellStopAction({
72+
uiLocale: 'en',
73+
activeIdRef: { current: 'session-1' },
74+
stopPending: { claim: () => true, release: () => undefined },
75+
removeTransientMessage: () => undefined,
76+
toastApi: {
77+
error(title) {
78+
errors.push(title);
79+
},
80+
},
81+
});
82+
83+
assert.equal(await stop(), undefined);
84+
assert.equal(errors.length, 1);
85+
} finally {
86+
target.window = previousWindow;
87+
}
88+
});

‎apps/desktop/src/renderer/app-shell-stop-action.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function createAppShellStopAction(deps: {
3939
stopPending: SessionPendingClaim;
4040
removeTransientMessage: (sessionId: string, messageId: string) => void;
4141
toastApi: ToastApi;
42-
}): () => Promise<void> {
42+
}): () => Promise<boolean | void> {
4343
const {
4444
uiLocale,
4545
activeIdRef,
@@ -58,13 +58,8 @@ export function createAppShellStopAction(deps: {
5858
removeTransientMessage(sessionId, messageId);
5959
}
6060
}
61+
return true;
6162
} catch (error) {
62-
// The Composer wires this through both the Stop button onClick
63-
// and the Escape key. Both invoke `onStop` without awaiting, so
64-
// a rejected IPC would otherwise surface as an
65-
// UnhandledPromiseRejection and the user would see nothing.
66-
// Surface it as a toast so the user knows the model wasn't
67-
// actually interrupted and can retry.
6863
if (activeIdRef.current === sessionId) {
6964
const copy = getDesktopConversationCopy(uiLocale).actions;
7065
toastApi.error(

‎apps/desktop/src/renderer/app-shell.tsx‎

Lines changed: 33 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,14 @@ function AppShellContent({
392392
reportError: reportTaskEntryError,
393393
manageProjects: openProjectSettings,
394394
});
395-
// Named on its own because the rail depends on it: `taskEntry.commands` is a
396-
// fresh object every render, so depending on the bag rather than the command
397-
// would rebuild the rail's Project rows on every AppShell commit (#4109).
395+
/* Named on its own because the rail depends on it: `taskEntry.commands` is a
396+
* fresh object every render, so depending on the bag rather than the command
397+
* would rebuild the rail's Project rows on every AppShell commit (#4109). */
398398
const { selectLocalProject } = taskEntry.commands;
399399
const currentNewTaskDraftKey = taskEntry.selectors.draftKey;
400-
// Staged files and quotes do NOT take the target-scoped key: they belong to
401-
// the composer the user is looking at, and an in-flight send needs an owner
402-
// that cannot move under it. See NEW_TASK_PENDING_KEY.
400+
/* Staged files and quotes do NOT take the target-scoped key: they belong to
401+
* the composer the user is looking at, and an in-flight send needs an owner
402+
* that cannot move under it. See NEW_TASK_PENDING_KEY. */
403403
const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY;
404404
const directoryHostId = activeId
405405
? (activeCatalogSession?.profileKind === 'local'
@@ -437,19 +437,19 @@ function AppShellContent({
437437
clearQuotes,
438438
restoreQuotes,
439439
} = useAppShellComposerQuotes({ draftKey: attachmentDraftKey });
440-
// Held for the whole of sendOwningItsTarget; see ChatComposerRegion.
440+
/* Held for the whole of sendOwningItsTarget; see ChatComposerRegion. */
441441
const [newTaskSendPending, setNewTaskSendPending] = useState(false);
442-
// What a new chat will start with, held the way the Session holds it: a
443-
// Plan toggle and one orchestration value, not one fused choice.
442+
/* What a new chat will start with, held the way the Session holds it: a
443+
* Plan toggle and one orchestration value, not one fused choice. */
444444
const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false);
445445
const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState<OrchestrationMode>('default');
446446
const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] =
447447
useNewTaskChoice<ChatDefaultPermissionMode>(currentNewTaskDraftKey);
448448
const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState<string>();
449-
// The state above is what the transcript renders; this is what the guard
450-
// reads. A scroller can ask twice in one task — two scroll events before
451-
// React has re-rendered anything — and a state read is still the old value
452-
// for both of them.
449+
/* The state above is what the transcript renders; this is what the guard
450+
* reads. A scroller can ask twice in one task — two scroll events before
451+
* React has re-rendered anything — and a state read is still the old value
452+
* for both of them. */
453453
const historyLoadPendingRef = useRef(false);
454454
const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{
455455
sessionId: string;
@@ -536,8 +536,8 @@ function AppShellContent({
536536
unsubscribe();
537537
};
538538
}, [setNavSelection]);
539-
// #1985: the shell's complete read of session UI state. See the hook for why
540-
// the two token-rate maps are absent.
539+
/* #1985: the shell's complete read of session UI state. See the hook for why
540+
* the two token-rate maps are absent. */
541541
const {
542542
messageLoadErrorBySession,
543543
messageRetryPendingBySession,
@@ -548,8 +548,8 @@ function AppShellContent({
548548
streamingSessionIds,
549549
activeLiveTurnSnapshot,
550550
} = useAppShellSessionUiReads(sessionUiController, activeId);
551-
// The chat surface follows the active Session's Host. Settings and global
552-
// commands remain owned by the default Host.
551+
/* The chat surface follows the active Session's Host. Settings and global
552+
* commands remain owned by the default Host. */
553553
const { memoryActive, refreshMemoryActive } = useShellMemoryPill({
554554
toastApi,
555555
uiLocale,
@@ -712,10 +712,10 @@ function AppShellContent({
712712
}, []);
713713

714714
const updateReminder = updateReminderFromStatus(appUpdateStatus);
715-
// Dispatches on the task, not on the raw status: the footer is this
716-
// callback's only caller and it only renders for the two states above, so
717-
// reading the status again here would be the same "who needs the user" list
718-
// maintained twice.
715+
/* Dispatches on the task, not on the raw status: the footer is this
716+
* callback's only caller and it only renders for the two states above, so
717+
* reading the status again here would be the same "who needs the user" list
718+
* maintained twice. */
719719
const openUpdateDownload = useCallback(() => {
720720
if (updateReminder?.state === 'downloaded') {
721721
if (updateInstallInFlightRef.current) return;
@@ -766,9 +766,9 @@ function AppShellContent({
766766
);
767767
});
768768
}, [updateReminder, shellCopy, toastApi, uiLocale]);
769-
// Persisted composer defaults seed the empty-state model, project path, and
770-
// recent workspace history so the home view is populated before the async
771-
// `app:info` round-trip completes on mount.
769+
/* Persisted composer defaults seed the empty-state model, project path, and
770+
* recent workspace history so the home view is populated before the async
771+
* `app:info` round-trip completes on mount. */
772772
const persistedComposerDefaults = loadComposerDefaults();
773773
const [helpOpen, closeHelp, openHelp] = useKeyboardHelp();
774774
const [paletteOpen, openPalette, closePalette] = useCommandPalette();
@@ -1829,6 +1829,14 @@ function AppShellContent({
18291829
});
18301830
}
18311831

1832+
const stop = createAppShellStopAction({
1833+
uiLocale,
1834+
activeIdRef,
1835+
stopPending: sessionUiController.stopPending,
1836+
removeTransientMessage,
1837+
toastApi,
1838+
});
1839+
18321840
/**
18331841
* The send the composer calls, wrapped so the new-task target cannot move
18341842
* out from under it (#3408). `sendCurrent` captures the draft key it
@@ -1920,8 +1928,6 @@ function AppShellContent({
19201928
? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds
19211929
: undefined;
19221930
const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds });
1923-
// Slash commands are not follow-up text for a live turn — dispatch them.
1924-
// Explicit Shift+Enter steer / queue still uses metadata.followUpMode.
19251931
const followUpAtSubmit = !slashCommand ? metadata?.followUpMode : undefined;
19261932
if (sessionId && followUpAtSubmit) {
19271933
const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, {
@@ -1931,32 +1937,7 @@ function AppShellContent({
19311937
if (queued) delete retractedWorkspaceReferencesRef.current[sessionId];
19321938
return queued;
19331939
}
1934-
// Plain Enter during a live turn: interrupt first, then fall through to a
1935-
// new root send so the typed message stops the runaway loop (#4083).
1936-
// Interrupt retracts any prior queue entries, so this must precede send.
1937-
// Host `turn.interrupt` awaits the cancelled turn's terminal fact before
1938-
// resolving, so the Session lane is free for the root send below — not
1939-
// merely "usually fast enough" relative to settlement.
1940-
if (sessionId && hasActiveTurn && !slashCommand) {
1941-
try {
1942-
const stopped = await window.maka.sessions.stop(sessionId, { source: 'stop_button' });
1943-
if (stopped?.kind === 'interrupted') {
1944-
for (const messageId of stopped.retractedMessageIds) {
1945-
removeTransientMessage(sessionId, messageId);
1946-
}
1947-
}
1948-
} catch (error) {
1949-
if (activeIdRef.current === sessionId) {
1950-
const copy = getDesktopConversationCopy(uiLocale).actions;
1951-
showSessionError(
1952-
sessionId,
1953-
copy.operationFailedTitle,
1954-
localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
1955-
);
1956-
}
1957-
return false;
1958-
}
1959-
}
1940+
if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false;
19601941
if (
19611942
revisionSend &&
19621943
revision &&
@@ -2208,14 +2189,6 @@ function AppShellContent({
22082189
);
22092190
}
22102191

2211-
const stop = createAppShellStopAction({
2212-
uiLocale,
2213-
activeIdRef,
2214-
stopPending: sessionUiController.stopPending,
2215-
removeTransientMessage,
2216-
toastApi,
2217-
});
2218-
22192192
const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch);
22202193
const {
22212194
handleEvent,

0 commit comments

Comments
 (0)