Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -496,7 +496,7 @@
"react": 1
},
"importSpecifiers": 19,
"nonTriviaTokens": 3736
"nonTriviaTokens": 3762
Comment thread
Astro-Han marked this conversation as resolved.
Outdated
},
"src/renderer/app-shell-overlays.tsx": {
"importDeclarations": 7,
Expand Down Expand Up @@ -866,7 +866,7 @@
"react": 1
},
"importSpecifiers": 104,
"nonTriviaTokens": 13531
"nonTriviaTokens": 13602
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down Expand Up @@ -956,7 +956,7 @@
"react": 1
},
"importSpecifiers": 10,
"nonTriviaTokens": 466
"nonTriviaTokens": 559
}
},
"closure": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import type { StoredMessage } from '@maka/core/session';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js';

Expand Down Expand Up @@ -50,6 +51,55 @@ function actionKeys(workspace: Workspace): string[] {
describe('session workspace action identity', () => {
afterEach(cleanupFakeDom);

it('hands over identity and rows together and rejects superseded reads', () => {
const { root } = installReactRenderer();
let workspace!: Workspace;
const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = [];
function Probe(): null {
workspace = useAppShellSessionWorkspace({ error: () => {} });
displays.push({ id: workspace.activeId, messages: workspace.messages });
return null;
}
act(() => root.render(createElement(LocaleProvider, {
locale: 'en', children: createElement(Probe),
})));
act(() => workspace.seedSessions(['a', 'b', 'c'].map((id) => ({
id, name: id, isFlagged: false, isArchived: false, labels: [],
hasUnread: false, status: 'active' as const, backend: 'ai-sdk' as const,
revision: 1, runtimeHostId: 'local', profileId: 'local', profileName: 'Local',
llmConnectionSlug: 'test', connectionLocked: false, model: 'test',
permissionMode: 'ask' as const, profileKind: 'local' as const,
}))));
const row = (id: string): StoredMessage => ({ id, type: 'user', text: id, turnId: id, ts: 1 });
const a = [row('a-message')];
const c = [row('c-message')];
act(() => { workspace.setActiveId('a'); workspace.commitTranscript('a', a); });
displays.length = 0;
act(() => workspace.setActiveId('b'));
assert.equal(workspace.requestedSessionId, 'b');
assert.equal(workspace.activeId, 'a');
assert.equal(workspace.messages, a);
act(() => workspace.setActiveId('c'));
act(() => workspace.commitTranscript('b', [row('b-message')]));
assert.equal(workspace.activeId, 'a');
act(() => workspace.commitTranscript('c', c));
assert.equal(workspace.activeId, 'c');
assert.equal(workspace.messageLoadPending, false);
assert.ok(displays.every((display) =>
(display.id === 'a' && display.messages === a) ||
(display.id === 'c' && display.messages === c)));

act(() => workspace.setActiveId('b'));
act(() => workspace.startNewSession());
act(() => workspace.commitTranscript('b', [row('b-message')]));
assert.equal(workspace.activeId, undefined);
assert.deepEqual(workspace.messages, []);
// A first-send task has no readable Host history yet; it must activate
// immediately rather than waiting for its own first message to be sent.
act(() => workspace.setActiveId('new-local-task'));
assert.equal(workspace.activeId, 'new-local-task');
});

it('keeps every action identity fixed across re-renders', () => {
const { root } = installReactRenderer();
const reads: Workspace[] = [];
Expand Down
39 changes: 28 additions & 11 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { LocaleProvider } from '@maka/ui';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
createFakeWorkbarServices,
projectWorkbarPanelsForSession,
useWorkbarController,
WorkbarServicesProvider,
type UseWorkbarControllerInput,
Expand Down Expand Up @@ -71,14 +72,18 @@ type ControllerProbeInput = UseWorkbarControllerInput & { openOnActivation?: boo
function ControllerProbe(props: ControllerProbeInput) {
const workbar = useWorkbarController(props);
latestController = workbar;
const visiblePanels = projectWorkbarPanelsForSession(
workbar.host.panelsState, workbar.host.activeId,
new Set(workbar.host.quotes?.map((quote) => `side-chat:${quote.id}`)),
);
useLayoutEffect(() => {
if (props.openOnActivation) workbar.host.onOpenLauncher('right');
}, [props.activeSession?.id, props.openOnActivation]);
controllerRenderSnapshots.push({
activeId: latestController.host.activeId,
terminalOwnerIds: [
...latestController.host.panelsState.right.tabs,
...latestController.host.panelsState.bottom.tabs,
...visiblePanels.right.tabs,
...visiblePanels.bottom.tabs,
]
.filter((tab) => tab.kind === 'terminal')
.map((tab) => tab.ownerSessionId),
Expand Down Expand Up @@ -194,7 +199,7 @@ describe('useWorkbarController', () => {
assert.equal(controller().host.rightCollapsed, false);
});

it("preserves the active Session's visibility while removing the previous Session's Terminal", async () => {
it("preserves visibility while hiding, rather than removing, another Session's Terminal", async () => {
const { root } = installReactRenderer();
const defaults = createFakeWorkbarServices();
const services = createFakeWorkbarServices({
Expand All @@ -217,7 +222,10 @@ describe('useWorkbarController', () => {
assert.equal(controller().host.panelsState.right.tabs.length, 1);
await act(async () => show('b'));

assert.equal(controller().host.panelsState.right.tabs.length, 0);
assert.equal(controller().host.panelsState.right.tabs.length, 1);
assert.equal(projectWorkbarPanelsForSession(
controller().host.panelsState, 'b', new Set(),
).right.tabs.length, 0);
assert.equal(controller().host.rightCollapsed, false);
});

Expand Down Expand Up @@ -340,7 +348,7 @@ describe('useWorkbarController', () => {
);
});

it('stops a Terminal whose start resolves after the owner Session changes', async () => {
it('retains a Terminal whose start resolves after navigation without revealing it in the new Session', async () => {
const { root } = installReactRenderer();
const start = deferred<ShellRunUpdate>();
const starts: string[] = [];
Expand All @@ -367,16 +375,21 @@ describe('useWorkbarController', () => {
await act(async () => renderController(root, services, input(session('b'))));
await act(async () => start.resolve(shellUpdate('a', 'terminal-a')));

assert.deepEqual(stops, [{ sessionId: 'a', ref: 'terminal-a' }]);
assert.deepEqual(stops, []);
assert.equal(
controller().host.panelsState.right.tabs.some(
(tab) => tab.kind === 'terminal',
),
false,
true,
);
assert.equal(controller().host.rightCollapsed, true);
await act(async () => renderController(root, services, input(session('a'))));
const owned = controller().host.panelsState.right.tabs.find((tab) => tab.kind === 'terminal');
assert.equal(owned?.ownerSessionId, 'a');
assert.equal(owned?.resourceRef, 'terminal-a');
});

it('stops an opened Terminal exactly once on close and on Session switch', async () => {
it('stops only explicitly closed Terminals and retains the same resource across navigation', async () => {
const { root } = installReactRenderer();
const stops: Array<{ sessionId: string; ref: string }> = [];
const defaults = createFakeWorkbarServices();
Expand Down Expand Up @@ -407,7 +420,6 @@ describe('useWorkbarController', () => {
await act(async () => renderController(root, services, input(session('b'))));
assert.deepEqual(stops, [
{ sessionId: 'a', ref: 'terminal-1' },
{ sessionId: 'a', ref: 'terminal-2' },
]);
assert.equal(
controllerRenderSnapshots
Expand All @@ -419,9 +431,13 @@ describe('useWorkbarController', () => {
),
false,
);
await act(async () => renderController(root, services, input(session('a'))));
const retained = controller().host.panelsState.right.tabs.find((tab) => tab.kind === 'terminal');
assert.equal(retained?.resourceRef, 'terminal-2');
assert.deepEqual(stops, [{ sessionId: 'a', ref: 'terminal-1' }]);
});

it('retries a failed Terminal stop during later Session cleanup', async () => {
it('retries a failed explicit Terminal stop at workspace teardown, not navigation', async () => {
const { root } = installReactRenderer();
const firstStop = deferred<ShellRunUpdate | null>();
const stops: Array<{ sessionId: string; ref: string }> = [];
Expand Down Expand Up @@ -451,12 +467,13 @@ describe('useWorkbarController', () => {
await Promise.resolve();
});
await act(async () => renderController(root, services, input(session('b'))));
assert.equal(stops.length, 1);
Comment thread
Astro-Han marked this conversation as resolved.
await act(async () => root.unmount());
assert.deepEqual(stops, [
{ sessionId: 'a', ref: 'terminal-retry' },
{ sessionId: 'a', ref: 'terminal-retry' },
]);

await act(async () => renderController(root, services, input(session('c'))));
assert.equal(stops.length, 2);
});

Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/renderer/app-shell-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,14 @@ export function useActiveSessionEvents(options: {
activeId: string | undefined;
observationAuthorityRevision: number;
activeIdRef: RefBox<string | undefined>;
isRequestedSession(sessionId: string): boolean;
handleEvent: (sessionId: string, event: SessionEvent) => void;
setExecution: import('./features/conversation/index.js').AppShellSessionUiStateController['setExecution'];
beginObservationSeed: (sessionId: string) => void;
completeObservationSeed: (sessionId: string) => void;
setMessageLoadErrorBySession: (updater: (current: Record<string, string>) => Record<string, string>) => void;
setMessageLoadPending: (pending: boolean) => void;
setMessages: (messages: StoredMessage[]) => void;
commitTranscript: (sessionId: string, messages: StoredMessage[]) => void;
transcriptRangeRef: RefBox<desktopTranscript.DesktopTranscriptRangeController | undefined>;
setSessionEventHealthBySession: SessionEventHealthUpdater;
toastApi: Pick<ToastApi, 'error'>;
Expand All @@ -339,17 +340,17 @@ export function useActiveSessionEvents(options: {
sessionId: string,
store: desktopTranscript.DesktopTranscriptRangeStore,
) => {
if (options.activeIdRef.current === sessionId) {
if (options.isRequestedSession(sessionId)) {
const snapshot = store.snapshot();
options.setMessages([...snapshot.messages]);
if (snapshot.ready) {
options.commitTranscript(sessionId, [...snapshot.messages]);
clearMessageLoadError(sessionId);
options.setMessageLoadPending(false);
}
}
});
const applyReadError = useEffectEvent((sessionId: string, error: unknown) => {
if (options.activeIdRef.current === sessionId) {
if (options.isRequestedSession(sessionId)) {
if (options.activeIdRef.current !== sessionId) options.commitTranscript(sessionId, []);
const message = messageReadErrorMessage(error, options.uiLocale);
options.setMessageLoadErrorBySession((current) => ({
...current,
Expand Down
19 changes: 14 additions & 5 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,15 @@ function AppShellContent({
seedSessions,
activeId,
activeIdRef,
requestedSessionId,
bootstrapSelectionLease,
setActiveId,
startNewSession,
clearOwnedSessionState,
messages,
transientMessages,
setMessages,
commitTranscript,
addTransientMessage,
updateTransientMessage,
retireCancelledTransientMessages,
Expand All @@ -328,11 +330,15 @@ function AppShellContent({
messageLoadPending,
setMessageLoadPending,
sessionUiController,
sessionCatalogController,
} = useAppShellSessionWorkspace(toastApi);
// A locally created task can become active before its catalog row arrives,
// and remains pending until Host creation finishes. Neither state admits
// Host reads; cached rows already have a Host identity and may reconnect.
const activeCatalogSession = sessions.find((session) => session.id === activeId);
const requestedCatalogSession = sessions.find((session) => session.id === requestedSessionId);
const requestedHostSession = requestedCatalogSession?.localState !== 'pending'
? requestedCatalogSession : undefined;
const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined;
const sharedSessionActive = activeCatalogSession?.shared === true;
const ownerActiveId = sharedSessionActive ? undefined : activeHostSession?.id;
Expand Down Expand Up @@ -2006,21 +2012,22 @@ function AppShellContent({
const observationAuthorityRef = useRef(liveContent.EMPTY_SESSION_OBSERVATION_AUTHORITY);
observationAuthorityRef.current = liveContent.advanceSessionObservationAuthority(
observationAuthorityRef.current,
activeId,
activeSession?.profileId,
requestedSessionId,
requestedCatalogSession?.profileId,
);
useActiveSessionEvents({
uiLocale,
activeId: activeHostSession?.id,
activeId: requestedHostSession?.id,
observationAuthorityRevision: observationAuthorityRef.current.revision,
activeIdRef,
isRequestedSession: (id) => sessionCatalogController.getState().activeSessionId === id,
handleEvent,
beginObservationSeed,
setExecution: sessionUiController.setExecution,
completeObservationSeed,
setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession,
setMessageLoadPending,
setMessages,
commitTranscript,
transcriptRangeRef,
setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession,
toastApi,
Expand Down Expand Up @@ -2454,7 +2461,9 @@ function AppShellContent({
navigation entry point. */}
<MakaUriContext.Provider value={dispatchMakaUri}>
<div className="maka-detail-with-artifacts">
<div className="mainColumn" data-home-surface={homeSurfaceActive ? 'true' : undefined}>
<div className="mainColumn" data-home-surface={homeSurfaceActive ? 'true' : undefined}
inert={activeId !== requestedSessionId || undefined}
aria-busy={activeId !== requestedSessionId || undefined}>
<ModuleHub.ModuleHubHost />
<WorkHubMainNavigation onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
<WorkHubDock enabled={workHubEnabled} visible={workHubActive && sessionsSelected && !shellObscured} />
Expand Down
Loading
Loading