diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 96463e705c..deb45cd733 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -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, @@ -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 { @@ -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(); @@ -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(); } + }); +}); diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts index 4c076f2ecf..6bc4a2a504 100644 --- a/apps/desktop/src/main/__tests__/composer-mentions.test.ts +++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts @@ -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; @@ -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: { @@ -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(); @@ -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 }), + }), }), }), }))); diff --git a/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts b/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts index c9aa67fe15..262a03b553 100644 --- a/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts @@ -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']); }); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index db165cc4cb..81bf402eec 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -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 }) }, diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index d8e25c98a8..2c8d23d90a 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -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, @@ -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 () => [] }, @@ -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((resolve) => { reads += 1; queueMicrotask(() => resolve({ @@ -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((resolve) => { release = resolve; }), diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index 53c4826ae8..f4d3977038 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -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'; @@ -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 { @@ -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: [], @@ -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 { @@ -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); diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts index e16a54e92b..f1039592b8 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useRef } from 'react'; +import { createContext, useContext, useRef } from 'react'; import { valuesEqual } from '@maka/ui'; import { compareDesktopSessionCatalogSummaries, @@ -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(null); + export function useSessionCatalogController(): SessionCatalogController { - const controllerRef = useRef(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; } diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 5260750e57..ef81094374 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -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(); @@ -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(), @@ -86,6 +95,7 @@ export function DesktopFeatureServicesProvider(props: { readonly children?: ReactNode; }) { return ( + @@ -121,5 +131,6 @@ export function DesktopFeatureServicesProvider(props: { + ); } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 69b765349b..ef6eca9a46 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -19,7 +19,8 @@ import { useRef, useState } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import type { TransientUserMessageProjection } from '@maka/ui'; +import { valuesEqual, type TransientUserMessageProjection } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; import { currentTranscriptRange } from './transcript-reading-position.js'; import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; import { @@ -28,6 +29,43 @@ import { } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; +/** + * Catalog bookkeeping that republishes at event rate but renders only in the + * rail — ordering, unread and flag markers, preview text, admission revision. + * Nothing under the shell's whole-row read renders them, so a patch that moves + * only these fields must not re-render the whole chat surface. Every other + * field still compares, and a row field added later republishes until someone + * proves it belongs here — the failure direction is a re-render, not a stale + * value the UI swears is current. + */ +const NON_RENDERED_ROW_KEYS = { + activityAt: true, + hasUnread: true, + isFlagged: true, + lastMessagePreview: true, + localCreatedAt: true, + revision: true, + statusUpdatedAt: true, + subagentRuntime: true, +} satisfies Partial>; + +export function shellSessionRowEqual( + a: DesktopSessionSummary | undefined, + b: DesktopSessionSummary | undefined, +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + const keys = new Set([ + ...(Object.keys(a) as (keyof DesktopSessionSummary)[]), + ...(Object.keys(b) as (keyof DesktopSessionSummary)[]), + ]); + for (const key of keys) { + if (key in NON_RENDERED_ROW_KEYS) continue; + if (!valuesEqual(a[key], b[key])) return false; + } + return true; +} + interface TranscriptSource { range(): { readonly sessionId: string; readonly hasOlder: boolean }; snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; @@ -98,11 +136,13 @@ export function useAppShellSessionUiState< catalog, selectSessionById, view.sessionId, + shellSessionRowEqual, ); const requestedCatalogSession = useExternalStoreSelector( catalog, selectSessionById, requestedSessionId, + shellSessionRowEqual, ); // Locally staged tasks cannot admit Host reads until creation completes. const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index e25f0cf865..5637d665a1 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -19,7 +19,12 @@ import type { MessageQueueEntryProjection, ShellRunUpdate } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import { createTranscriptViewportNavigation, type InteractionQueues, type LiveTurnBuffer } from '@maka/ui'; +import { + createTranscriptViewportNavigation, + valuesEqual, + type InteractionQueues, + type LiveTurnBuffer, +} from '@maka/ui'; import { createObservableState } from './observable-state.js'; import type { SessionExecutionProjection } from '../../../../shared/session-execution-projection.js'; @@ -200,7 +205,9 @@ export function createAppShellSessionUiStateController( const previous = current[sessionId]; if (!projection) return previous?.available ? { ...current, [sessionId]: { ...previous, available: false } } : current; - if (previous === projection) return current; + // The observation channel re-publishes the projection on every frame — + // catalog metadata writes included — with a fresh object each time. + if (previous !== undefined && valuesEqual(previous, projection)) return current; return { ...current, [sessionId]: projection }; }); }, diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index a875aef72b..03df268905 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -17,7 +17,6 @@ * under the License. */ -import type { SessionChangedEvent } from '@maka/core/session'; import type { SessionSnapshot } from '@maka/core/session-reference'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; @@ -51,8 +50,6 @@ export interface ConversationServices extends Pick< 'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges' > { readonly sessions: { - list(): Promise; - subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; readSnapshot(sessionId: string, options?: { readonly maxChars?: number }): Promise; }; readonly skills: { diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index bd9c0549c9..781a972f88 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -22,3 +22,4 @@ export { prepareTranscriptForSend, restoreSessionTranscriptRange, } from './controller/transcript-reading-position.js'; +export { shellSessionRowEqual } from './controller/use-app-shell-session-ui-state.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx index 6ef746c9f1..27559b0746 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -24,6 +24,14 @@ import type { QuoteRef } from '@maka/core/events'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import type { ConversationSession } from '../ports.js'; import { useConversationServices } from '../services.js'; +import { + selectSessionById, + selectSessions, + useSessionCatalogController, + type SessionCatalogState, +} from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; +import { shellSessionRowEqual } from '../controller/use-app-shell-session-ui-state.js'; import { useSessionReferenceComposer, type SessionReferenceSession, @@ -82,6 +90,17 @@ function skillListsEqual( }); } +/** + * Mention targets come from the catalog the shell already holds — not a + * `sessions.list()` per `sessions:changed`, which paid a full-catalog IPC for + * every event. Locally staged rows are excluded: a pending session has no + * Host-side data for the reference to resolve yet. + */ +const selectMentionableSessions = ( + state: SessionCatalogState, +): readonly ConversationSession[] => + state.sessions.filter((session) => session.localState !== 'pending'); + function conversationSessionListsEqual( current: readonly ConversationSession[], next: readonly ConversationSession[], @@ -107,6 +126,22 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent const services = useConversationServices(); const locale = useUiLocale(); const mentionCopy = getConversationCopy(locale).mentions; + const sessionCatalog = useSessionCatalogController(); + const sessions = useExternalStoreSelector( + sessionCatalog, + selectMentionableSessions, + undefined, + conversationSessionListsEqual, + ); + // The skills reload is driven by the active row's published content, not by + // `sessions:changed` reasons: a flag/rename/activity bump republishes nothing + // here, while any field a skill could key on still refreshes the list. + const skillRelevantRow = useExternalStoreSelector( + sessionCatalog, + selectSessionById, + surface.sessionId, + shellSessionRowEqual, + ); const [catalog, setCatalog] = useState<{ contextKey: string; loading: boolean; @@ -117,7 +152,6 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent loading: true, skills: EMPTY_SKILLS, }); - const [sessions, setSessions] = useState([]); const contextKey = surface.sessionId ? `session\u0000${surface.sessionId}` : [ surface.sessionId ?? '', surface.projectPath ?? '', @@ -136,29 +170,6 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent ? sessions.find((session) => session.id === surface.sessionId)?.runtimeHostId : surface.newTaskTarget?.hostId; - useEffect(() => { - let cancelled = false; - const refreshSessions = () => { - void services.sessions.list().then((next) => { - if (!cancelled) { - setSessions((previous) => - conversationSessionListsEqual(previous, next) ? previous : next, - ); - } - }).catch(() => { - if (!cancelled) { - setSessions((previous) => (previous.length === 0 ? previous : [])); - } - }); - }; - refreshSessions(); - const unsubscribe = services.sessions.subscribeChanges(refreshSessions); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [services]); - useEffect(() => { let cancelled = false; let requestVersion = 0; @@ -202,28 +213,15 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent const unsubscribeContext = surface.sessionId ? services.mcp.subscribeChanges(refresh) : services.newTasks.subscribeChanges(refresh); - const unsubscribeSession = surface.sessionId - ? services.sessions.subscribeChanges((event) => { - if ( - event.sessionId === surface.sessionId && - (event.reason === 'updated' || - event.reason === 'mode-change' || - event.reason === 'turn-status-change' || - event.reason === 'rebound') - ) { - refresh(); - } - }) - : () => undefined; return () => { cancelled = true; requestVersion += 1; unsubscribeContext(); - unsubscribeSession(); }; }, [ contextKey, services, + skillRelevantRow, surface.sessionId ? undefined : surface.newSessionModel?.llmConnectionSlug, surface.sessionId ? undefined : surface.newSessionModel?.model, surface.sessionId ? undefined : surface.newSessionCollaborationMode, @@ -312,7 +310,9 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent ]); } -export function ComposerMentionsProvider(props: ComposerMentionsSurface & { readonly children: ReactNode }) { +export function ComposerMentionsProvider( + props: ComposerMentionsSurface & { readonly children: ReactNode }, +) { const mentions = useConversationMentions(props); return {props.children}; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index cf069785a9..953a9f664f 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -74,6 +74,7 @@ import { useMountedRef, useToast, useUiLocale, + valuesEqual, } from '@maka/ui'; import { EmptyState as AstryxEmptyState } from '@astryxdesign/core'; import { ArtifactPreview } from './artifact-preview'; @@ -149,7 +150,9 @@ export function ArtifactPane(props: { if (artifactPaneMountedRef.current && requestSeq === artifactListRequestSeqRef.current) { recordsSessionIdRef.current = sessionId; setRecordsSessionId(sessionId); - setRecords(next); + // The 2s poll re-reads an unchanged list almost every tick; keep the + // published identity so a no-change answer does not re-render the pane. + setRecords((previous) => (valuesEqual(previous, next) ? previous : next)); setListError(null); } } catch (error) { diff --git a/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx index 2d55bd8769..e722f102e5 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx @@ -83,6 +83,9 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { const editingRef = useRef(false); const browserPanelMountedRef = useMountedRef(); const browserPanelSessionIdRef = useRef(sessionId); + // Which session the held `state`/`address` describe — a hidden stretch must + // not wipe them, and a session switch while hidden must still reset on show. + const stateSessionRef = useRef(undefined); browserPanelSessionIdRef.current = sessionId; @@ -90,12 +93,18 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { return browserPanelMountedRef.current && browserPanelSessionIdRef.current === ownerSessionId; }, []); - // Subscribe to this session's state pushes + seed the initial state. + // Subscribe to this session's state pushes + seed the current state only + // while the panel is shown: hidden pushes are missed on purpose, and the + // getState reseed on the way back catches up. useEffect(() => { + if (hidden) return; let alive = true; - editingRef.current = false; - setState(EMPTY_STATE); - setAddress(''); + if (stateSessionRef.current !== sessionId) { + stateSessionRef.current = sessionId; + editingRef.current = false; + setState(EMPTY_STATE); + setAddress(''); + } const apply = (next: BrowserState) => { if (!alive) return; setState(next); @@ -112,7 +121,7 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { alive = false; off(); }; - }, [browser, sessionId]); + }, [browser, sessionId, hidden]); // Mirror the strip's on-screen rect to main every animation frame while it is // showable. Position shifts on window resize and sidebar drags even when the diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index f26ddd3a80..4aad6a5131 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -37,7 +37,6 @@ import { useMemo, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; -import type { SessionChangedEvent } from '@maka/core/session'; import { Composer } from '@maka/ui'; import { ComposerMentionsProvider, @@ -47,6 +46,11 @@ import { ConversationServicesProvider, type ConversationServices, } from '../src/renderer/features/conversation'; +import { + createSessionCatalogController, + SessionCatalogContext, +} from '../src/renderer/application/contracts/session-catalog/session-catalog-state.js'; +import type { DesktopSessionSummary } from '../src/shared/desktop-session-projection.js'; import { desktopSlashCommandAvailability } from '../src/renderer/desktop-slash-command'; import { getShellCopy } from '../src/renderer/locales/shell-copy'; import { withScopedMakaBridge } from './maka-bridge'; @@ -54,6 +58,38 @@ import { withScopedMakaBridge } from './maka-bridge'; const COMPOSER_INPUT = '.maka-composer-editor [contenteditable="true"]'; const MENU_LABEL = '命令和技能'; const SESSION_ID = 'session-slash-menu'; +const sessionCatalog = createSessionCatalogController(); +const sessionRow: DesktopSessionSummary = { + id: SESSION_ID, + revision: 1, + activityAt: 100, + name: 'Slash menu session', + 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', +}; +sessionCatalog.commitSessions([sessionRow]); +let sessionRowRevision = sessionRow.revision; +/** Publish a Session row change, the way a thinking-level change does. */ +function publishSessionUpdate() { + sessionRowRevision += 1; + sessionCatalog.commitPatch(SESSION_ID, { + ...sessionRow, + revision: sessionRowRevision, + thinkingLevel: sessionRowRevision % 2 === 0 ? 'high' : 'low', + }); +} /** * What app-shell.tsx builds for `slashCommands`, from the same three @@ -79,8 +115,6 @@ const invocableSkills = [ { ref: 'workspace/workspace-only', id: 'workspace-only', name: 'Workspace Only', description: 'Maka workspace suggestion.' }, ]; -/** Publish a Session 'updated' event, the way a thinking-level change does. */ -let publishSessionUpdate: (() => void) | undefined; /** Projection loads served so far, so a story can wait for one to land. */ let projectionLoads = 0; let holdNextProjection = false; @@ -110,14 +144,6 @@ const makaBridge = { searchFiles: async () => ({ ok: true, files: [] }), subscribeChanges: () => () => {}, }, - sessions: { - subscribeChanges(listener: (event: SessionChangedEvent) => void) { - publishSessionUpdate = () => listener({ sessionId: SESSION_ID, reason: 'updated', ts: Date.now() }); - return () => { - publishSessionUpdate = undefined; - }; - }, - }, mcp: { subscribeChanges: () => () => {} }, workspace: { searchFiles: async () => ({ ok: true, files: [] }) }, }; @@ -128,13 +154,6 @@ const conversationServices: ConversationServices = { reconcileMessage: async () => undefined, subscribeChanges: () => () => undefined, sessions: { - list: async () => [], - subscribeChanges(listener: (event: SessionChangedEvent) => void) { - publishSessionUpdate = () => listener({ sessionId: SESSION_ID, reason: 'updated', ts: Date.now() }); - return () => { - publishSessionUpdate = undefined; - }; - }, readSnapshot: async () => { throw new Error('Session snapshots are not used in slash menu stories'); }, @@ -187,18 +206,20 @@ function SlashMenuHarness({ return (
- - - + + + + +
); @@ -219,16 +240,18 @@ function ContextSwitchHarness(): React.ReactElement {
- - - + + + + +
@@ -522,7 +545,7 @@ export const SurvivesASameContentProjectionRefresh: Story = { try { for (let round = 0; round < 3; round += 1) { const before = projectionLoads; - publishSessionUpdate?.(); + publishSessionUpdate(); await waitFor(() => expect(projectionLoads).toBeGreaterThan(before)); await new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve()));