Skip to content
Merged
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
132 changes: 132 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import { act, createElement } from 'react';
import { LiveTurnReconciler } from '../../renderer/features/conversation/index.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { normalizeSessionSummaryForDisplay } from '../../renderer/session-status-presentation.js';
import {
createSessionCatalogController,
selectSessionById,
} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';
import { useExternalStoreSelector } from '../../renderer/application/contracts/session-catalog/use-external-store-selector.js';
import {
clearAppShellSessionUiStateForSession,
createAppShellSessionUiStateController,
Expand All @@ -37,7 +42,9 @@ import {
import {
createTranscriptRestoreLifecycle,
restoreSessionTranscriptRange,
shellSessionRowEqual,
} from '../../renderer/features/conversation/testing.js';
import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js';

function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent {
return {
Expand Down Expand Up @@ -149,6 +156,33 @@ describe('app shell session UI state controller', () => {
assert.equal(next.liveTurnBySession, state.liveTurnBySession);
});

it('does not republish a fresh-but-equal execution projection', () => {
const controller = createAppShellSessionUiStateController();
const projection = {
type: 'host_execution' as const,
available: true,
rootTurn: { sessionId: 'session', turnId: 'turn', runId: 'run', status: 'running' as const },
};
controller.setExecution('session', projection);
const state = controller.getState();
let notifications = 0;
controller.subscribe(() => {
notifications += 1;
});

// The observation channel resends an equivalent projection on unrelated
// metadata events — a fresh identity carrying the same content.
controller.setExecution('session', { ...projection, rootTurn: { ...projection.rootTurn } });
assert.equal(controller.getState(), state);
assert.equal(notifications, 0);

controller.setExecution('session', {
...projection,
rootTurn: { ...projection.rootTurn, status: 'completed' as const, terminalEventId: 'evt-1' },
});
assert.equal(notifications, 1);
});

it('records event-stream health without notifying render subscribers', () => {
let notifications = 0;
const controller = createAppShellSessionUiStateController();
Expand Down Expand Up @@ -282,3 +316,101 @@ describe('app shell session UI state controller', () => {
assert.equal(controller.liveTurnBySessionRef.current.session, projection);
});
});

describe('shellSessionRowEqual', () => {
const row: DesktopSessionSummary = {
id: 'session-1',
revision: 7,
activityAt: 100,
name: 'session one',
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'active',
backend: 'ai-sdk',
llmConnectionSlug: 'default',
connectionLocked: false,
model: 'model',
permissionMode: 'ask',
runtimeHostId: 'host',
profileId: 'profile',
profileName: 'Local',
profileKind: 'local',
};

it('holds identity across rail-only bookkeeping', () => {
const patched: DesktopSessionSummary = {
...row,
revision: 8,
activityAt: 200,
isFlagged: true,
hasUnread: true,
lastMessagePreview: 'newest line',
statusUpdatedAt: 150,
};
assert.equal(shellSessionRowEqual(row, patched), true);
assert.equal(shellSessionRowEqual(row, row), true);
});

it('republishes when a rendered field moves', () => {
assert.equal(shellSessionRowEqual(row, { ...row, status: 'running' }), false);
assert.equal(shellSessionRowEqual(row, { ...row, name: 'renamed' }), false);
assert.equal(shellSessionRowEqual(row, { ...row, permissionMode: 'bypass' }), false);
assert.equal(
shellSessionRowEqual(row, { ...row, lastMessageAt: 200 }),
false,
);
assert.equal(shellSessionRowEqual(row, undefined), false);
});

it('republishes for a field the rail-only list does not know about', () => {
// A row field added later is not in NON_RENDERED_ROW_KEYS, so it must
// fail closed: compare, differ, republish — never silently keep identity.
const future = { ...row, fieldAddedNextMonth: 'a' } as DesktopSessionSummary;
const later = { ...row, fieldAddedNextMonth: 'b' } as DesktopSessionSummary;
assert.equal(shellSessionRowEqual(future, later), false);
assert.equal(shellSessionRowEqual(future, row), false);
});

it('keeps a catalog row subscriber mounted through rail-only patches', async () => {
const { root } = installReactRenderer();
try {
const catalog = createSessionCatalogController();
catalog.commitSessions([row]);
let renders = 0;
function Probe() {
useExternalStoreSelector(catalog, selectSessionById, row.id, shellSessionRowEqual);
renders += 1;
return null;
}
await act(async () => { root.render(createElement(Probe)); });
assert.equal(renders, 1);

await act(async () => {
catalog.commitPatch(row.id, {
...row,
revision: 8,
isFlagged: true,
hasUnread: true,
lastMessagePreview: 'newest line',
activityAt: 200,
});
});
assert.equal(renders, 1, 'rail-only bookkeeping must not republish a row subscriber');

await act(async () => {
catalog.commitPatch(row.id, {
...row,
revision: 9,
isFlagged: true,
hasUnread: true,
lastMessagePreview: 'newest line',
activityAt: 200,
name: 'renamed',
});
});
assert.equal(renders, 2, 'a rendered-field change still republishes');
} finally { cleanupFakeDom(); }
});
});
20 changes: 13 additions & 7 deletions apps/desktop/src/main/__tests__/composer-mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import {
ConversationServicesProvider,
type ConversationServices,
} from '../../renderer/features/conversation/index.js';
import {
createSessionCatalogController,
SessionCatalogContext,
} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';

interface CatalogObservation {
sessionId: string;
Expand Down Expand Up @@ -87,11 +91,9 @@ function installCatalogRenderer(t: TestContext) {
}),
},
sessions: {
list: () => new Promise(() => undefined),
readSnapshot: async () => {
throw new Error('Session snapshot is not used in catalog tests');
},
subscribeChanges: () => () => undefined,
},
workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) },
newTasks: {
Expand All @@ -102,6 +104,7 @@ function installCatalogRenderer(t: TestContext) {
mcp: { subscribeChanges: () => () => undefined },
};

const sessionCatalog = createSessionCatalogController();
const observations: CatalogObservation[] = [];
function Consumer({ sessionId }: { sessionId: string }) {
const mentions = useComposerMentionsContext();
Expand Down Expand Up @@ -145,11 +148,14 @@ function installCatalogRenderer(t: TestContext) {
locale: 'en',
children: createElement(ConversationServicesProvider, {
services,
children: createElement(ComposerMentionsProvider, {
sessionId,
projectPath,
skillCatalogRevision,
children: createElement(Consumer, { sessionId }),
children: createElement(SessionCatalogContext.Provider, {
value: sessionCatalog,
children: createElement(ComposerMentionsProvider, {
sessionId,
projectPath,
skillCatalogRevision,
children: createElement(Consumer, { sessionId }),
}),
}),
}),
})));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,5 @@ test('Desktop conversation adapter keeps snapshot reads and catalog access on th
const services = createDesktopConversationServices(bridge);

await services.sessions.readSnapshot('source');
assert.deepEqual(await services.sessions.list(), []);
assert.deepEqual(calls, ['snapshot:source']);
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('local delivery recovery cannot republish accepted Host queue rows', async
listMessages: async () => messages,
subscribeChanges: (handler) => { changed = handler; return () => {}; },
cancelMessage: async () => {}, reconcileMessage: async () => {},
sessions: { list: async () => [], subscribeChanges: () => () => {}, readSnapshot: async () => { throw new Error('unexpected snapshot read'); } },
sessions: { readSnapshot: async () => { throw new Error('unexpected snapshot read'); } },
skills: { listInvocable: async () => [] },
workspace: { searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) },
newTasks: { subscribeChanges: () => () => {}, listInvocableSkills: async () => [], searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { afterEach, test } from 'node:test';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import type { SessionChangedEvent } from '@maka/core/session';
import type { SessionSnapshot } from '@maka/core/session-reference';
import {
ConversationServicesProvider,
Expand Down Expand Up @@ -102,8 +101,6 @@ test('Session reference picker keeps same-Host sessions and send waits for the s
const services: ConversationServices = {
...sessionLocalServices,
sessions: {
list: async () => sessions,
subscribeChanges: (_handler: (event: SessionChangedEvent) => void) => () => undefined,
readSnapshot: async () => snapshot,
},
skills: { listInvocable: async () => [] },
Expand Down Expand Up @@ -244,8 +241,6 @@ test('send resolves the selected Session snapshot at the send boundary', async (
const services: ConversationServices = {
...sessionLocalServices,
sessions: {
list: async () => [source],
subscribeChanges: () => () => undefined,
readSnapshot: async () => new Promise<SessionSnapshot>((resolve) => {
reads += 1;
queueMicrotask(() => resolve({
Expand Down Expand Up @@ -350,8 +345,6 @@ test('ignores a snapshot that resolves after the Composer owner changes', async
const services: ConversationServices = {
...sessionLocalServices,
sessions: {
list: async () => [session('current'), session('next'), session('source')],
subscribeChanges: () => () => undefined,
readSnapshot: async () => new Promise<SessionSnapshot>((resolve) => {
release = resolve;
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ 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 {
createSessionCatalogController,
SessionCatalogContext,
} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';
import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js';
import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js';
import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js';
Expand Down Expand Up @@ -58,6 +62,7 @@ describe('session workspace action identity', () => {
const sessionB = JSON.stringify(['local', 'b']);
const sessionC = JSON.stringify(['local', 'c']);
const { root } = installReactRenderer();
const catalog = createSessionCatalogController();
let workspace!: Workspace;
const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = [];
function Probe(): null {
Expand All @@ -66,7 +71,10 @@ describe('session workspace action identity', () => {
return null;
}
act(() => root.render(createElement(LocaleProvider, {
locale: 'en', children: createElement(Probe),
locale: 'en',
children: createElement(SessionCatalogContext.Provider, {
value: catalog, children: createElement(Probe),
}),
})));
act(() => workspace.seedSessions([sessionA, sessionB, sessionC].map((id) => ({
id, name: id, isFlagged: false, isArchived: false, labels: [],
Expand Down Expand Up @@ -166,6 +174,7 @@ describe('session workspace action identity', () => {

it('keeps every action identity fixed across re-renders', () => {
const { root } = installReactRenderer();
const catalog = createSessionCatalogController();
const reads: Workspace[] = [];

function Probe(): null {
Expand All @@ -175,7 +184,12 @@ describe('session workspace action identity', () => {

act(() => {
root.render(
createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) }),
createElement(LocaleProvider, {
locale: 'en',
children: createElement(SessionCatalogContext.Provider, {
value: catalog, children: createElement(Probe),
}),
}),
);
});
assert.equal(reads.length, 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { useRef } from 'react';
import { createContext, useContext, useRef } from 'react';
import { valuesEqual } from '@maka/ui';
import {
compareDesktopSessionCatalogSummaries,
Expand Down Expand Up @@ -199,11 +199,15 @@ export const selectAuthoritativeSessionIds = (
state.revision > 0 ? new Set(state.sessions.map(({ id }) => id)) : undefined;

/**
* Owns the controller for the component's lifetime. Deliberately does NOT
* subscribe: readers select what they need through `useExternalStoreSelector`.
* The shell's catalog instance, mounted once above the feature services.
* Providers that need the catalog read it here instead of receiving it as a
* prop drilled through the shell. Deliberately does NOT subscribe: readers
* select what they need through `useExternalStoreSelector`.
*/
export const SessionCatalogContext = createContext<SessionCatalogController | null>(null);

export function useSessionCatalogController(): SessionCatalogController {
const controllerRef = useRef<SessionCatalogController | null>(null);
if (!controllerRef.current) controllerRef.current = createSessionCatalogController();
return controllerRef.current;
const catalog = useContext(SessionCatalogContext);
if (catalog === null) throw new Error('SessionCatalogContext.Provider is missing');
return catalog;
}
11 changes: 11 additions & 0 deletions apps/desktop/src/renderer/composition/desktop-feature-services.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-
import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services';
import { createDesktopOverlaysServices } from '../platform/desktop/create-overlays-services';
import { observeReactPerformanceMeasures } from '../platform/desktop/react-performance-measures';
import {
createSessionCatalogController,
SessionCatalogContext,
} from '../application/contracts/session-catalog/session-catalog-state.js';

if (import.meta.env.DEV) {
const stopObserving = observeReactPerformanceMeasures();
Expand All @@ -62,6 +66,11 @@ if (import.meta.env.DEV) {

export function createDesktopFeatureServices() {
return {
// The session catalog is renderer-owned shared state, not a bridge
// service — it is created once with the other app singletons and read
// through `useSessionCatalogController` so providers below do not need it
// drilled through the shell.
sessionCatalog: createSessionCatalogController(),
appUpdate: createDesktopAppUpdateServices(),
clientPlugins: createDesktopClientPluginServices(),
workHub: createDesktopWorkHubServices(),
Expand All @@ -86,6 +95,7 @@ export function DesktopFeatureServicesProvider(props: {
readonly children?: ReactNode;
}) {
return (
<SessionCatalogContext.Provider value={props.services.sessionCatalog}>
<ClientPluginServicesProvider services={props.services.clientPlugins}>
<ClientPluginRoot>
<AppUpdateServicesProvider services={props.services.appUpdate}>
Expand Down Expand Up @@ -121,5 +131,6 @@ export function DesktopFeatureServicesProvider(props: {
</AppUpdateServicesProvider>
</ClientPluginRoot>
</ClientPluginServicesProvider>
</SessionCatalogContext.Provider>
);
}
Loading