Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@
"./locales/shell-copy.js": 1
},
"importSpecifiers": 2,
"nonTriviaTokens": 302
"nonTriviaTokens": 301
},
"src/renderer/app-shell-turn-actions.ts": {
"importDeclarations": 4,
Expand Down Expand Up @@ -895,8 +895,8 @@
"@maka/ui/icons": 1,
"react": 1
},
"importSpecifiers": 147,
"nonTriviaTokens": 15588
"importSpecifiers": 146,
"nonTriviaTokens": 15587
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down
36 changes: 34 additions & 2 deletions apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ test('removes exactly the transient messages the Host retracts while stopping',
toastApi: { error() {} },
});

await stop();

assert.equal(await stop(), true);
assert.deepEqual(removed, [
{ sessionId: 'session-1', messageId: 'message-1' },
{ sessionId: 'session-1', messageId: 'message-2' },
Expand All @@ -54,3 +53,36 @@ test('removes exactly the transient messages the Host retracts while stopping',
target.window = previousWindow;
}
});

test('returns undefined when stop fails so plain-Enter send can abort', async () => {
const target = globalThis as unknown as { window?: unknown };
const previousWindow = target.window;
const errors: string[] = [];
target.window = {
maka: {
sessions: {
stop: async () => {
throw new Error('stop failed');
},
},
},
};
try {
const stop = createAppShellStopAction({
uiLocale: 'en',
activeIdRef: { current: 'session-1' },
stopPending: { claim: () => true, release: () => undefined },
removeTransientMessage: () => undefined,
toastApi: {
error(title) {
errors.push(title);
},
},
});

assert.equal(await stop(), undefined);
assert.equal(errors.length, 1);
} finally {
target.window = previousWindow;
}
});
49 changes: 0 additions & 49 deletions apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { describe, it } from 'node:test';
import {
hasActiveTurnAtSubmit,
mergeWorkspaceReferences,
resolveFollowUpModeAtSubmit,
} from '../../renderer/follow-up-submit-routing.js';

describe('follow-up submit routing', () => {
Expand All @@ -46,54 +45,6 @@ describe('follow-up submit routing', () => {
);
});

it('routes burst input through the selected follow-up lane', () => {
assert.equal(
resolveFollowUpModeAtSubmit({
hasActiveTurn: true,
slashCommand: null,
}),
'queue',
);
assert.equal(
resolveFollowUpModeAtSubmit({
requestedMode: 'steer',
hasActiveTurn: true,
slashCommand: null,
}),
'steer',
);
});

it('starts a normal turn only when no active-turn witness exists', () => {
assert.equal(
resolveFollowUpModeAtSubmit({
hasActiveTurn: false,
slashCommand: null,
}),
undefined,
);
});

it('dispatches a slash command mid-turn instead of steering it into the Turn', () => {
assert.equal(
resolveFollowUpModeAtSubmit({
hasActiveTurn: true,
slashCommand: { kind: 'side' },
}),
undefined,
);
// An explicit steer request loses to the command too: Shift+Enter on
// `/side` still opens the side chat.
assert.equal(
resolveFollowUpModeAtSubmit({
requestedMode: 'steer',
hasActiveTurn: true,
slashCommand: { kind: 'side' },
}),
undefined,
);
});

it('restores workspace references after queued text returns to the draft', () => {
assert.deepEqual(
mergeWorkspaceReferences(
Expand Down
9 changes: 2 additions & 7 deletions apps/desktop/src/renderer/app-shell-stop-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function createAppShellStopAction(deps: {
stopPending: SessionPendingClaim;
removeTransientMessage: (sessionId: string, messageId: string) => void;
toastApi: ToastApi;
}): () => Promise<void> {
}): () => Promise<boolean | void> {
const {
uiLocale,
activeIdRef,
Expand All @@ -58,13 +58,8 @@ export function createAppShellStopAction(deps: {
removeTransientMessage(sessionId, messageId);
}
}
return true;
} catch (error) {
// The Composer wires this through both the Stop button onClick
// and the Escape key. Both invoke `onStop` without awaiting, so
// a rejected IPC would otherwise surface as an
// UnhandledPromiseRejection and the user would see nothing.
// Surface it as a toast so the user knows the model wasn't
// actually interrupted and can retry.
if (activeIdRef.current === sessionId) {
const copy = getDesktopConversationCopy(uiLocale).actions;
toastApi.error(
Expand Down
73 changes: 35 additions & 38 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ import {
hasActiveTurnAtSubmit,
mergeWorkspaceReferences,
rebaseWorkspaceFileReferences,
resolveFollowUpModeAtSubmit,
} from './follow-up-submit-routing';
import {
PlanExecutionPanel,
Expand Down Expand Up @@ -396,14 +395,14 @@ function AppShellContent({
reportError: reportTaskEntryError,
manageProjects: openProjectSettings,
});
// Named on its own because the rail depends on it: `taskEntry.commands` is a
// fresh object every render, so depending on the bag rather than the command
// would rebuild the rail's Project rows on every AppShell commit (#4109).
/* Named on its own because the rail depends on it: `taskEntry.commands` is a
* fresh object every render, so depending on the bag rather than the command
* would rebuild the rail's Project rows on every AppShell commit (#4109). */
const { selectLocalProject } = taskEntry.commands;
const currentNewTaskDraftKey = taskEntry.selectors.draftKey;
// Staged files and quotes do NOT take the target-scoped key: they belong to
// the composer the user is looking at, and an in-flight send needs an owner
// that cannot move under it. See NEW_TASK_PENDING_KEY.
/* Staged files and quotes do NOT take the target-scoped key: they belong to
* the composer the user is looking at, and an in-flight send needs an owner
* that cannot move under it. See NEW_TASK_PENDING_KEY. */
const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY;
const directoryHostId = activeId
? (activeCatalogSession?.profileKind === 'local'
Expand Down Expand Up @@ -441,19 +440,19 @@ function AppShellContent({
clearQuotes,
restoreQuotes,
} = useAppShellComposerQuotes({ draftKey: attachmentDraftKey });
// Held for the whole of sendOwningItsTarget; see ChatComposerRegion.
/* Held for the whole of sendOwningItsTarget; see ChatComposerRegion. */
const [newTaskSendPending, setNewTaskSendPending] = useState(false);
// 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.
/* 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);
const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState<OrchestrationMode>('default');
const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] =
useNewTaskChoice<ChatDefaultPermissionMode>(currentNewTaskDraftKey);
const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState<string>();
// The state above is what the transcript renders; this is what the guard
// reads. A scroller can ask twice in one task — two scroll events before
// React has re-rendered anything — and a state read is still the old value
// for both of them.
/* The state above is what the transcript renders; this is what the guard
* reads. A scroller can ask twice in one task — two scroll events before
* React has re-rendered anything — and a state read is still the old value
* for both of them. */
const historyLoadPendingRef = useRef(false);
const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{
sessionId: string;
Expand Down Expand Up @@ -540,8 +539,8 @@ function AppShellContent({
unsubscribe();
};
}, [setNavSelection]);
// #1985: the shell's complete read of session UI state. See the hook for why
// the two token-rate maps are absent.
/* #1985: the shell's complete read of session UI state. See the hook for why
* the two token-rate maps are absent. */
const {
messageLoadErrorBySession,
messageRetryPendingBySession,
Expand All @@ -552,8 +551,8 @@ function AppShellContent({
streamingSessionIds,
activeLiveTurnSnapshot,
} = useAppShellSessionUiReads(sessionUiController, activeId);
// The chat surface follows the active Session's Host. Settings and global
// commands remain owned by the default Host.
/* The chat surface follows the active Session's Host. Settings and global
* commands remain owned by the default Host. */
const { memoryActive, refreshMemoryActive } = useShellMemoryPill({
toastApi,
uiLocale,
Expand Down Expand Up @@ -716,10 +715,10 @@ function AppShellContent({
}, []);

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

const stop = createAppShellStopAction({
uiLocale,
activeIdRef,
stopPending: sessionUiController.stopPending,
removeTransientMessage,
toastApi,
});

/**
* The send the composer calls, wrapped so the new-task target cannot move
* out from under it (#3408). `sendCurrent` captures the draft key it
Expand Down Expand Up @@ -1912,11 +1919,8 @@ function AppShellContent({
const runningTurnIds = sessionId
? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds
: undefined;
const followUpAtSubmit = resolveFollowUpModeAtSubmit({
requestedMode: metadata?.followUpMode,
hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }),
slashCommand,
});
const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds });
const followUpAtSubmit = !slashCommand ? metadata?.followUpMode : undefined;
if (sessionId && followUpAtSubmit) {
const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, {
...metadata,
Expand All @@ -1925,6 +1929,7 @@ function AppShellContent({
if (queued) delete retractedWorkspaceReferencesRef.current[sessionId];
return queued;
}
if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false;
if (
revisionSend &&
revision &&
Expand Down Expand Up @@ -2176,14 +2181,6 @@ function AppShellContent({
);
}

const stop = createAppShellStopAction({
uiLocale,
activeIdRef,
stopPending: sessionUiController.stopPending,
removeTransientMessage,
toastApi,
});

const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch);
const {
handleEvent,
Expand Down
17 changes: 1 addition & 16 deletions apps/desktop/src/renderer/follow-up-submit-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import type { FollowUpMode, InlineReference } from '@maka/core/events';
import type { InlineReference } from '@maka/core/events';

export interface WorkspaceFileReferencePosition {
value: string;
Expand All @@ -32,21 +32,6 @@ export function hasActiveTurnAtSubmit(input: {
return input.runningTurnIds?.some((turnId) => turnId !== input.liveTurn?.turnId) === true;
}

export function resolveFollowUpModeAtSubmit(input: {
requestedMode?: FollowUpMode;
hasActiveTurn: boolean;
/** The parsed command, if the text was one. Only its presence matters here. */
slashCommand: object | null;
}): FollowUpMode | undefined {
// A slash command tells the app to do something; it is not text for the
// Turn that happens to be running. Dispatch it instead of queueing it.
if (input.slashCommand) return undefined;
if (input.requestedMode) return input.requestedMode;
// Mid-turn submits always queue; Shift+Enter carries the one-shot steer as
// the requested mode.
return input.hasActiveTurn ? 'queue' : undefined;
}

export function mergeWorkspaceReferences(
text: string,
live: readonly WorkspaceFileReferencePosition[] | undefined,
Expand Down
Loading