diff --git a/apps/desktop/src/main/__tests__/session-list-layout.test.ts b/apps/desktop/src/main/__tests__/session-list-layout.test.ts index 161711d1d1..c15c765bd9 100644 --- a/apps/desktop/src/main/__tests__/session-list-layout.test.ts +++ b/apps/desktop/src/main/__tests__/session-list-layout.test.ts @@ -22,7 +22,7 @@ import { afterEach, describe, it } from 'node:test'; import { readSessionListViewMode, writeSessionListViewMode, -} from '../../renderer/session-list-layout.js'; +} from '../../renderer/features/session-navigation/testing.js'; const VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; diff --git a/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts b/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts new file mode 100644 index 0000000000..d2fb1b3db2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const featureRoot = join( + desktopRoot, + 'src', + 'renderer', + 'features', + 'session-navigation', +); + +function sourceFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx|md)$/.test(entry.name) ? [path] : []; + }); +} + +describe('Session Navigation feature boundary', () => { + it('contains no Desktop global bridge or shell/process imports', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + const source = readFileSync(path, 'utf8'); + const name = relative(desktopRoot, path); + if (source.includes('window.maka')) violations.push(`${name}: Desktop global`); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const imported = match[1] ?? ''; + if ( + imported.includes('app-shell') || + imported.includes('/preload/') || + imported.includes('/main/') + ) { + violations.push(`${name}: ${imported}`); + } + } + } + assert.deepEqual(violations, []); + }); + + it('is consumed outside the feature only through public entries', () => { + const allowed = /\/features\/session-navigation\/(?:index|testing)(?:\.js)?$/; + const violations: string[] = []; + for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { + for (const path of sourceFiles(root)) { + if (path.startsWith(featureRoot)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll( + /from\s+['"]([^'"]*features\/session-navigation[^'"]*)['"]/g, + )) { + const imported = match[1] ?? ''; + const normalized = imported.replace(/\\/g, '/'); + const explicitEntry = normalized.endsWith('/features/session-navigation') + ? `${normalized}/index` + : normalized; + if (!allowed.test(explicitEntry)) { + violations.push(`${relative(desktopRoot, path)}: ${imported}`); + } + } + } + } + assert.deepEqual(violations, []); + }); + + it('keeps fake services out of the production entry', () => { + const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); + assert.equal( + productionEntry.includes('createFakeSessionNavigationServices'), + false, + ); + assert.equal(productionEntry.includes("from './testing"), false); + }); + + it('keeps rail projection, persistence, and row mutation ownership out of AppShell', () => { + const appShell = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), + 'utf8', + ); + for (const forbidden of [ + ' = {}, +): SessionNavigationSession { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + profileId: 'local', + profileName: 'Local', + profileKind: 'local', + ...overrides, + }; +} + +const project: ProjectRecord = { + id: 'project', + name: 'Project', + locations: [{ path: '/repo', isWorktree: false }], + available: true, +}; + +let latestController: SessionNavigationController | undefined; + +function ControllerProbe(props: UseSessionNavigationControllerInput) { + latestController = useSessionNavigationController(props); + return null; +} + +function renderController( + root: ReturnType['root'], + input: UseSessionNavigationControllerInput, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + SessionNavigationServicesProvider, + { services: createFakeSessionNavigationServices() }, + createElement(ControllerProbe, input), + ), + }), + ); +} + +function controller(): SessionNavigationController { + assert.ok(latestController); + return latestController; +} + +function input( + sessions: SessionNavigationSession[], + activeSessionId: string | undefined, + calls: string[] = [], + targets: unknown[] = [], +): UseSessionNavigationControllerInput { + return { + sessions, + activeSessionId, + hiddenSessionIds: new Set(['hidden']), + projects: [project], + activateSession: (sessionId) => calls.push(`activate:${sessionId ?? 'none'}`), + clearActiveMessages: () => calls.push('clear-messages'), + clearSessionRendererState: (sessionId) => calls.push(`clear:${sessionId}`), + exitWorkHub: () => calls.push('exit-workhub'), + refreshSessions: async () => sessions, + selectSessionSurface: () => calls.push('select-sessions'), + setSearchTarget: (target) => targets.push(target), + toastApi: { + success: () => undefined, + error: () => undefined, + confirm: async () => true, + }, + }; +} + +afterEach(() => { + latestController = undefined; + cleanupFakeDom(); +}); + +describe('useSessionNavigationController', () => { + it('projects linked, archived, hidden, Project, and Runtime Host Sessions once', async () => { + const { root } = installReactRenderer(); + const sessions = [ + session('root', { projectId: 'project', cwd: '/repo' }), + session('child', { + parentSessionId: 'root', + subagentParent: { + kind: 'subagent', + parentSessionId: 'root', + spawnedBy: { + parentRunId: 'run', + parentTurnId: 'turn', + toolCallId: 'tool', + }, + lifecycle: 'foreground', + }, + }), + session('remote', { + profileId: 'remote-profile', + profileName: 'Remote Mac', + profileKind: 'remote', + }), + session('archived', { isArchived: true }), + session('hidden'), + ]; + + await act(async () => renderController(root, input(sessions, 'child'))); + + assert.deepEqual( + controller().selectors.visibleSessions.map(({ id }) => id), + ['root', 'remote'], + ); + assert.equal(controller().selectors.activeRowId, 'root'); + assert.equal(controller().selectors.activeParentSession?.id, 'root'); + assert.deepEqual(controller().selectors.branchBanner, { + parentSessionId: 'root', + parentSessionName: 'root', + }); + assert.deepEqual( + controller().selectors.groups.map(({ id }) => id), + ['project:project', 'runtime-host:remote-profile'], + ); + assert.equal(controller().selectors.sessionMeta(sessions[2]!), 'Remote Mac'); + }); + + it('owns Session jumps and preserves turn-target clearing semantics', async () => { + const { root } = installReactRenderer(); + const calls: string[] = []; + const targets: unknown[] = []; + const sessions = [session('a')]; + await act(async () => renderController(root, input(sessions, undefined, calls, targets))); + + await act(async () => controller().commands.openSession('a', 'turn-2', 9)); + await act(async () => controller().commands.openSession('a')); + + assert.deepEqual(calls, [ + 'exit-workhub', + 'select-sessions', + 'activate:a', + 'exit-workhub', + 'select-sessions', + 'activate:a', + ]); + assert.equal(typeof (targets[0] as { nonce: unknown }).nonce, 'number'); + assert.deepEqual( + { ...(targets[0] as Record), nonce: 0 }, + { sessionId: 'a', turnId: 'turn-2', sequence: 9, nonce: 0 }, + ); + assert.equal(targets[1], null); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts similarity index 60% rename from apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts rename to apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts index d3e37e4e69..324065fe05 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts @@ -20,7 +20,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; -import { createAppShellSessionRowActions } from '../../renderer/app-shell-session-row-actions.js'; +import { createSessionNavigationRowActions } from '../../renderer/features/session-navigation/testing.js'; function summary(id: string, overrides: Partial = {}): SessionSummary { return { @@ -40,27 +40,28 @@ function summary(id: string, overrides: Partial = {}): SessionSu }; } -function installWindow(calls: string[]): () => void { - const target = globalThis as unknown as { window?: unknown }; - const previous = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - writable: true, - value: { - maka: { - sessions: { - setFlagged: async (id: string, value: boolean, options?: { revisionFamily?: boolean }) => { calls.push(`flag:${id}:${value}:${options?.revisionFamily === true}`); }, - archive: async (id: string, options?: { revisionFamily?: boolean }) => { calls.push(`archive:${id}:${options?.revisionFamily === true}`); }, - unarchive: async (id: string, options?: { revisionFamily?: boolean }) => { calls.push(`unarchive:${id}:${options?.revisionFamily === true}`); }, - rename: async (id: string, name: string, options?: { revisionFamily?: boolean }) => { calls.push(`rename:${id}:${name}:${options?.revisionFamily === true}`); }, - remove: async (id: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }) => { calls.push(`remove:${id}:${options?.revisionFamily === true}:${options?.requireArchived === true}`); return 'removed' as const; }, - }, - }, +function createService(calls: string[]) { + return { + list: async () => [], + setFlagged: async (id: string, value: boolean, options: { revisionFamily: true }) => { + calls.push(`flag:${id}:${value}:${options.revisionFamily}`); + }, + archive: async (id: string, options: { revisionFamily: true }) => { + calls.push(`archive:${id}:${options.revisionFamily}`); + }, + unarchive: async (id: string, options: { revisionFamily: true }) => { + calls.push(`unarchive:${id}:${options.revisionFamily}`); + }, + rename: async (id: string, name: string, options: { revisionFamily: true }) => { + calls.push(`rename:${id}:${name}:${options.revisionFamily}`); + }, + remove: async ( + id: string, + options: { revisionFamily: true; requireArchived: boolean }, + ) => { + calls.push(`remove:${id}:${options.revisionFamily}:${options.requireArchived}`); + return 'removed' as const; }, - }); - return () => { - if (previous === undefined) delete target.window; - else Object.defineProperty(target, 'window', { configurable: true, writable: true, value: previous }); }; } @@ -76,16 +77,16 @@ describe('revision-family session row actions', () => { }); const branch = summary('branch', { parentSessionId: 'root', branchOfTurnId: 'turn-1' }); const activeIdRef = { current: 'root' as string | undefined }; - const restore = installWindow(calls); - const actions = createAppShellSessionRowActions({ + const actions = createSessionNavigationRowActions({ uiLocale: 'en', activeIdRef, + clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { cleared.push(id); }, pendingSessionRowActionsRef: { current: new Set() }, refreshSessions: async () => [root, version, branch], + service: createService(calls), sessionsRef: { current: [root, version, branch] }, setActiveId: (id) => { selections.push(id); activeIdRef.current = id; }, - setMessages: () => undefined, toastApi: { success: () => undefined, error: () => undefined, @@ -93,15 +94,11 @@ describe('revision-family session row actions', () => { }, }); - try { - await actions.flagSession('version', true); - await actions.renameSession('branch', 'Independent branch'); - await actions.archiveSession('version'); - activeIdRef.current = 'version'; - await actions.deleteSession('root'); - } finally { - restore(); - } + await actions.flagSession('version', true); + await actions.renameSession('branch', 'Independent branch'); + await actions.archiveSession('version'); + activeIdRef.current = 'version'; + await actions.deleteSession('root'); assert.deepEqual(calls, [ 'flag:version:true:true', diff --git a/apps/desktop/src/main/__tests__/session-navigation-services-adapter.test.ts b/apps/desktop/src/main/__tests__/session-navigation-services-adapter.test.ts new file mode 100644 index 0000000000..7325755f4b --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-services-adapter.test.ts @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopSessionNavigationServices } from '../../renderer/platform/desktop/create-session-navigation-services.js'; + +describe('createDesktopSessionNavigationServices', () => { + it('maps the narrow catalog mutation contract to the Desktop bridge', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + const sessions = new Proxy({}, { + get: (_target, property) => (...args: unknown[]) => { + calls.push({ name: String(property), args }); + if (property === 'list') return Promise.resolve([]); + if (property === 'remove') return Promise.resolve('removed'); + return Promise.resolve(undefined); + }, + }); + const services = createDesktopSessionNavigationServices({ + sessions, + } as unknown as MakaBridge); + + await services.sessions.list(); + await services.sessions.setFlagged('s', true, { revisionFamily: true }); + await services.sessions.archive('s', { revisionFamily: true }); + await services.sessions.unarchive('s', { revisionFamily: true }); + await services.sessions.rename('s', 'Renamed', { revisionFamily: true }); + const disposition = await services.sessions.remove('s', { + revisionFamily: true, + requireArchived: false, + }); + + assert.equal(disposition, 'removed'); + assert.deepEqual(calls, [ + { name: 'list', args: [] }, + { name: 'setFlagged', args: ['s', true, { revisionFamily: true }] }, + { name: 'archive', args: ['s', { revisionFamily: true }] }, + { name: 'unarchive', args: ['s', { revisionFamily: true }] }, + { name: 'rename', args: ['s', 'Renamed', { revisionFamily: true }] }, + { + name: 'remove', + args: ['s', { revisionFamily: true, requireArchived: false }], + }, + ]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts similarity index 81% rename from apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts rename to apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 4e4057977f..3281407947 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -20,7 +20,10 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; -import { createAppShellSessionRowActions } from '../../renderer/app-shell-session-row-actions.js'; +import { + createSessionNavigationRowActions, + type SessionNavigationSessionService, +} from '../../renderer/features/session-navigation/testing.js'; function summary(id: string, overrides: Partial = {}): SessionSummary { return { @@ -57,11 +60,11 @@ type SweepHarness = { }; /** - * Installs a `window.maka.sessions` whose `remove` fails for the named ids and + * Creates a Session service whose `remove` fails for the named ids and * whose `list` answers with `surviving` (or throws when it is `undefined`, * standing in for a catalog that cannot be read back). */ -function installWindow( +function installService( harness: SweepHarness, options: { rejectIds?: readonly string[]; @@ -76,39 +79,29 @@ function installWindow( */ catalog?: readonly SessionSummary[]; } = {}, -): () => void { - const target = globalThis as unknown as { window?: unknown }; - const previous = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - writable: true, - value: { - maka: { - sessions: { - remove: async (id: string, removeOptions?: { requireArchived?: boolean }) => { - harness.removeOptions.push([id, removeOptions?.requireArchived === true]); - if (options.rejectWithUndefinedIds?.includes(id)) { - return Promise.reject(undefined); - } - if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); - const target = options.catalog?.find((session) => session.id === id); - if (removeOptions?.requireArchived && target && !target.isArchived) return 'restored'; - harness.removed.push(id); - options.onRemove?.(id); - return 'removed'; - }, - list: async () => { - harness.listCalls += 1; - if (!options.surviving) throw new Error('catalog unavailable'); - return [...options.surviving]; - }, - }, - }, +): SessionNavigationSessionService { + return { + setFlagged: async () => undefined, + archive: async () => undefined, + unarchive: async () => undefined, + rename: async () => undefined, + remove: async (id, removeOptions) => { + harness.removeOptions.push([id, removeOptions.requireArchived]); + if (options.rejectWithUndefinedIds?.includes(id)) { + return Promise.reject(undefined); + } + if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); + const target = options.catalog?.find((session) => session.id === id); + if (removeOptions.requireArchived && target && !target.isArchived) return 'restored'; + harness.removed.push(id); + options.onRemove?.(id); + return 'removed'; + }, + list: async () => { + harness.listCalls += 1; + if (!options.surviving) throw new Error('catalog unavailable'); + return [...options.surviving]; }, - }); - return () => { - if (previous === undefined) delete target.window; - else Object.defineProperty(target, 'window', { configurable: true, writable: true, value: previous }); }; } @@ -118,21 +111,23 @@ function createActions(input: { activeIdRef: { current: string | undefined }; pending?: Set; refreshed?: SessionSummary[]; + service: SessionNavigationSessionService; }) { - return createAppShellSessionRowActions({ + return createSessionNavigationRowActions({ uiLocale: 'en', activeIdRef: input.activeIdRef, + clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { input.harness.cleared.push(id); }, pendingSessionRowActionsRef: { current: input.pending ?? new Set() }, refreshSessions: async () => input.refreshed ?? [], + service: input.service, sessionsRef: { current: input.sessions }, setActiveId: (id) => { input.harness.selections.push(id); input.activeIdRef.current = id; }, - setMessages: () => undefined, toastApi: { success: (title: string) => { input.harness.toasts.push(title); @@ -163,10 +158,10 @@ describe('purgeSessions', () => { summary('b'), ]; const activeIdRef = { current: 'a-v2' as string | undefined }; - const restore = installWindow(h); - const actions = createActions({ harness: h, sessions, activeIdRef }); + const service = installService(h); + const actions = createActions({ harness: h, sessions, activeIdRef, service }); - const outcome = await actions.purgeSessions(['a-v2', 'b']).finally(restore); + const outcome = await actions.purgeSessions(['a-v2', 'b']); assert.deepEqual(h.removed, ['a-v2', 'b']); assert.deepEqual(outcome, { @@ -196,14 +191,15 @@ describe('purgeSessions', () => { // person agreed to two and one went, which needs saying. const h = harness(); const catalog = [restored('kept'), summary('doomed')]; - const restore = installWindow(h, { catalog }); + const service = installService(h, { catalog }); const actions = createActions({ harness: h, sessions: [...catalog], activeIdRef: { current: undefined }, + service, }); - const outcome = await actions.purgeSessions(['kept', 'doomed']).finally(restore); + const outcome = await actions.purgeSessions(['kept', 'doomed']); assert.deepEqual(h.removed, ['doomed']); assert.equal(outcome.removed, 1); @@ -219,7 +215,7 @@ describe('purgeSessions', () => { // reached this task. const h = harness(); const catalog = [summary('first'), summary('second')]; - const restore = installWindow(h, { + const service = installService(h, { catalog, onRemove: (id) => { if (id === 'first') catalog[1] = restored('second'); @@ -229,9 +225,10 @@ describe('purgeSessions', () => { harness: h, sessions: [...catalog], activeIdRef: { current: undefined }, + service, }); - const outcome = await actions.purgeSessions(['first', 'second']).finally(restore); + const outcome = await actions.purgeSessions(['first', 'second']); assert.deepEqual(h.removed, ['first']); assert.equal(outcome.removed, 1); @@ -242,11 +239,11 @@ describe('purgeSessions', () => { it('keeps everything the renderer holds for a task the delete left alone', async () => { const h = harness(); const catalog = [summary('first'), restored('rescued')]; - const restore = installWindow(h, { catalog }); + const service = installService(h, { catalog }); const activeIdRef = { current: 'rescued' as string | undefined }; - const actions = createActions({ harness: h, sessions: [...catalog], activeIdRef }); + const actions = createActions({ harness: h, sessions: [...catalog], activeIdRef, service }); - const outcome = await actions.purgeSessions(['first', 'rescued']).finally(restore); + const outcome = await actions.purgeSessions(['first', 'rescued']); assert.deepEqual(outcome.restored, ['rescued']); assert.equal(outcome.firstFailure, undefined); @@ -266,14 +263,15 @@ describe('purgeSessions', () => { // would drop the id with no outcome at all, which is how a confirmed count // stops adding up. const h = harness(); - const restore = installWindow(h, { catalog: [summary('stale'), summary('plain')] }); + const service = installService(h, { catalog: [summary('stale'), summary('plain')] }); const actions = createActions({ harness: h, sessions: [restored('stale'), summary('plain')], activeIdRef: { current: undefined }, + service, }); - const outcome = await actions.purgeSessions(['stale', 'plain']).finally(restore); + const outcome = await actions.purgeSessions(['stale', 'plain']); assert.deepEqual(h.removeOptions, [ ['stale', true], @@ -287,15 +285,16 @@ describe('purgeSessions', () => { it('skips an id whose row action is already in flight instead of racing it', async () => { const h = harness(); const sessions = [summary('busy'), summary('free')]; - const restore = installWindow(h, { surviving: [summary('busy')] }); + const service = installService(h, { surviving: [summary('busy')] }); const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, pending: new Set(['busy:delete']), + service, }); - const outcome = await actions.purgeSessions(['busy', 'free']).finally(restore); + const outcome = await actions.purgeSessions(['busy', 'free']); assert.deepEqual(h.removed, ['free']); assert.deepEqual(outcome.remaining, ['busy']); @@ -308,13 +307,13 @@ describe('purgeSessions', () => { // catalog can settle it. const h = harness(); const sessions = [summary('committed'), summary('survivor')]; - const restore = installWindow(h, { + const service = installService(h, { rejectIds: ['committed', 'survivor'], surviving: [summary('survivor')], }); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); - const outcome = await actions.purgeSessions(['committed', 'survivor']).finally(restore); + const outcome = await actions.purgeSessions(['committed', 'survivor']); assert.equal(h.listCalls, 1); assert.deepEqual(outcome.remaining, ['survivor']); @@ -327,14 +326,14 @@ describe('purgeSessions', () => { it('retains the first failing Session even when the rejection value is undefined', async () => { const h = harness(); const sessions = [summary('first'), summary('second')]; - const restore = installWindow(h, { + const service = installService(h, { rejectWithUndefinedIds: ['first'], rejectIds: ['second'], surviving: sessions, }); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); - const outcome = await actions.purgeSessions(['first', 'second']).finally(restore); + const outcome = await actions.purgeSessions(['first', 'second']); assert.ok(outcome.firstFailure); assert.equal(outcome.firstFailure.sessionId, 'first'); @@ -346,15 +345,16 @@ describe('purgeSessions', () => { // reporting on that would have called a completed sweep a total failure. const h = harness(); const sessions = [summary('a')]; - const restore = installWindow(h, { rejectIds: ['a'], surviving: undefined }); + const service = installService(h, { rejectIds: ['a'], surviving: undefined }); const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, refreshed: sessions, + service, }); - const outcome = await actions.purgeSessions(['a']).finally(restore); + const outcome = await actions.purgeSessions(['a']); assert.equal(outcome.verified, false); assert.deepEqual(outcome.remaining, []); @@ -368,12 +368,11 @@ describe('deleteSession', () => { // time, so a restore revokes it the same way. const h = harness(); const sessions = [summary('archived-row'), restored('active-row')]; - const restore = installWindow(h); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); + const service = installService(h); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); await actions.deleteSession('archived-row'); await actions.deleteSession('active-row'); - restore(); // An active task never had an archived premise to lose, so requiring one // would refuse every delete from the rail. @@ -388,12 +387,11 @@ describe('deleteSession', () => { // The row was archived when the confirm named it; the catalog the delete // commits against says otherwise by the time it lands. const sessions = [summary('rescued')]; - const restore = installWindow(h, { catalog: [restored('rescued')] }); + const service = installService(h, { catalog: [restored('rescued')] }); const activeIdRef = { current: 'rescued' as string | undefined }; - const actions = createActions({ harness: h, sessions, activeIdRef }); + const actions = createActions({ harness: h, sessions, activeIdRef, service }); await actions.deleteSession('rescued'); - restore(); assert.deepEqual(h.removed, []); assert.deepEqual(h.cleared, []); diff --git a/apps/desktop/src/main/__tests__/session-revisions.test.ts b/apps/desktop/src/main/__tests__/session-revisions.test.ts index 3a20042092..9fc679c741 100644 --- a/apps/desktop/src/main/__tests__/session-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-revisions.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { collapseSessionRevisions, revisionFamilySessionIds } from '@maka/core/session-revisions'; import { type SessionSummary } from '@maka/core/session'; -import { deriveSessionRevisionNavigation } from '../../renderer/session-revisions.js'; +import { deriveSessionRevisionNavigation } from '../../renderer/features/session-navigation/testing.js'; function summary(id: string, overrides: Partial = {}): SessionSummary { return { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 47282012f6..20c83217a8 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -28,14 +28,13 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { generalizedErrorMessageChinese } from '@maka/core/redaction'; import { sessionExpectsEventStream } from '@maka/core/session-event-health'; import { type ShellRunUpdate } from '@maka/core/events'; -import type { LiveTurnProjection, NavSelection, SessionViewMode } from '@maka/ui'; +import type { LiveTurnProjection, NavSelection } from '@maka/ui'; import { messageReadErrorMessage } from './app-shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; -import { writeSessionListViewMode } from './session-list-layout.js'; import { createSessionEventStreamSubscription, evaluateSessionEventStreamSnapshot, @@ -60,7 +59,6 @@ import { } from './desktop-transcript-range-store.js'; type RefBox = { current: T }; -const LAYOUT_PERSIST_DEBOUNCE_MS = 200; type SessionEventHealthUpdater = ( updater: (current: Record) => Record, @@ -120,9 +118,6 @@ export function useAppShellHostEffects() { export function useAppShellPersistenceEffects(options: { navigationState: NavigationState; - sessionListCollapsed: boolean; - sessionListWidth: number; - sessionListViewMode: SessionViewMode; themePalette: ThemePalette; themePref: ThemePreference; }) { @@ -143,28 +138,6 @@ export function useAppShellPersistenceEffects(options: { applyThemePalette(options.themePalette); }, [options.themePalette]); - // PR-FE-BUG-HUNT-5 (kenji bug-hunt 2026-06-24 LOW): pointer drag on - // the sidebar resizer fires `setSessionListWidth` on every move - // event — at ~60Hz over a long drag, that's a couple hundred - // localStorage writes for a single resize gesture. The setting - // converges to the user's final width at rest; intermediate - // values aren't load-bearing. 200ms trailing debounce keeps the - // last-render value in storage without flushing every pixel. - useEffect(() => { - const handle = window.setTimeout(() => { - safeLocalStorageSet('maka-chat-list-width-v1', String(options.sessionListWidth)); - }, LAYOUT_PERSIST_DEBOUNCE_MS); - return () => window.clearTimeout(handle); - }, [options.sessionListWidth]); - - useEffect(() => { - safeLocalStorageSet('maka-chat-list-collapsed-v1', options.sessionListCollapsed ? 'true' : 'false'); - }, [options.sessionListCollapsed]); - - useEffect(() => { - writeSessionListViewMode(options.sessionListViewMode); - }, [options.sessionListViewMode]); - // Persist the active destination and each hub's last selected module. // Strict localStorage availability check — Vite dev sometimes runs through // a worker where it isn't defined. diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 30ed340d22..4ddb902f48 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -25,6 +25,7 @@ import { useRef, useState, type CSSProperties, + type ComponentProps, type Dispatch, type SetStateAction, } from 'react'; @@ -57,9 +58,6 @@ import { type ToastDiagnosticTarget, type ToastErrorAction, type NavSelection, - SessionListPanel, - type SessionHistoryGroup, - type SessionViewMode, TitlebarSessionIdentity, type TurnFooterActionMeta, type WorkspacePickerModel, @@ -94,6 +92,10 @@ import { } from './features/workbar'; import { GoalHost, useGoalController } from './features/goals'; import { ModuleHubHost, useModuleHubController } from './features/module-hub'; +import { + SessionNavigationHost, + useSessionNavigationController, +} from './features/session-navigation'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; @@ -133,21 +135,11 @@ import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; -import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; -import { deriveSessionRail } from './session-rail'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; -import { deriveBranchBanner } from './branch-banner'; import { readNavigationState, selectNavigation } from './nav-selection'; -import { sessionMatchesRail } from './session-nav-filter'; -import { deriveSessionRevisionNavigation } from './session-revisions'; import { deriveDesktopExecutionBoundarySurface } from './desktop-execution-boundary-surface'; import { useActiveExecutionBoundary } from './use-active-execution-boundary'; -import { - SESSION_LIST_EXPANDED_MAX_WIDTH, - SESSION_LIST_EXPANDED_MIN_WIDTH, - readSessionListViewMode, -} from './session-list-layout'; import { modelSetupToastCopy } from './model-connection-errors'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { @@ -188,7 +180,6 @@ import { type TurnRevisionDraft, } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; -import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; @@ -219,7 +210,6 @@ import { useShellConnections } from './use-shell-connections'; import { useNewTaskTarget } from './use-new-task-target'; import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; -import { useShellLayout } from './use-shell-layout'; import { useShellResume } from './use-shell-resume'; function rebaseWorkspaceFileReferences( @@ -246,7 +236,6 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; -import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; type ComposerImportOwner = { sessionId: string | undefined; @@ -685,13 +674,8 @@ function AppShellContent({ const persistedComposerDefaults = loadComposerDefaults(); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); - const [viewMode, setViewMode] = useState(() => readSessionListViewMode()); const composerRef = useRef(null); const retractedWorkspaceReferencesRef = useRef>({}); - // The rail's toggle has to reach Astryx's resizable state, not just this - // boolean — see the prop's note on SessionListPanel. The sidenav is mounted - // for the whole shell, so the handle is always live by the time it is called. - const sessionSideNavHandleRef = useRef(null); const [revisionDraft, setRevisionDraft] = useState(null); const revisionDraftRef = useRef(null); const commitRevisionDraft = useCallback((draft: TurnRevisionDraft | null) => { @@ -826,18 +810,14 @@ function AppShellContent({ // mask. Per @kenji PR109d review: pending state prevents double-click // duplicate sibling turns by disabling the action button between // click and `sessions:changed turn-status-change` arriving. - // The four de-dup registries (turn-footer actions, session-row actions, - // per-session permission-mode / model changes) all share the same keyed-Set - // shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors - // into React state (drives the disabled mask) and arms a 5s auto-clear - // fallback timer; the other three stay ref-only and clear in their action's - // `finally`. + // These de-dup registries (turn-footer actions and per-session permission-mode + // / model changes) share the same keyed-Set shape; see + // useKeyedPendingRegistry. Session-row mutations live in Session Navigation. const turnActionRegistry = useKeyedPendingRegistry({ trackState: true, autoClearMs: 5000, }); const pendingTurnActions = turnActionRegistry.keys; - const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => @@ -879,28 +859,6 @@ function AppShellContent({ sessionModelChangeRegistry.keysRef.current.delete(sessionId); } - const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { - uiLocale, - activeIdRef, - clearSessionRendererState, - pendingSessionRowActionsRef: sessionRowActionRegistry.keysRef, - refreshSessions, - sessionsRef, - setActiveId, - setMessages, - toastApi, - }); - const sessionRowActions = useMemo[0]['rowActions']>>( - () => ({ - onToggleFlag: (sessionId, next) => sessionRowActionHandlers.flagSession(sessionId, next), - onArchive: (sessionId) => sessionRowActionHandlers.archiveSession(sessionId), - onUnarchive: (sessionId) => sessionRowActionHandlers.unarchiveSession(sessionId), - onRename: (sessionId, name) => sessionRowActionHandlers.renameSession(sessionId, name), - onDelete: (sessionId) => sessionRowActionHandlers.deleteSession(sessionId), - }), - [], - ); - const { setPermissionMode, setSessionModel, @@ -1080,16 +1038,15 @@ function AppShellContent({ }); } - function openSessionInChat(sessionId: string, turnId?: string, sequence?: number): void { - setWorkHubActive(false); - setNavSelection({ section: 'sessions' }); - setActiveId(sessionId); - if (turnId) { - setSearchScrollTarget({ sessionId, turnId, sequence, nonce: Date.now() }); - } else { - setSearchScrollTarget(null); - } - } + const openSessionInChatRef = useRef< + (sessionId: string, turnId?: string, sequence?: number) => void + >(() => undefined); + const openSessionInChat = useCallback( + (sessionId: string, turnId?: string, sequence?: number): void => { + openSessionInChatRef.current(sessionId, turnId, sequence); + }, + [], + ); /* PR-FE-BUG-HUNT-0 (kenji bug-hunt 2026-06-24): SearchModal + CommandPalette callbacks used to be inline arrows in JSX, so @@ -1101,8 +1058,6 @@ function AppShellContent({ dead while a stream was active. Same root cause for the palette selection effect that resets keyboard highlight on every deps change. Stable refs + memos keep the timers alive. */ - const openSessionInChatRef = useRef(openSessionInChat); - openSessionInChatRef.current = openSessionInChat; const { searchModalOpen, setSearchModalOpen, @@ -1133,37 +1088,11 @@ function AppShellContent({ }, [shellCopy], ); - const sessionListSelectSession = useCallback((sessionId: string) => { - openSessionInChatRef.current(sessionId); - }, []); const openWorkHub = useCallback(() => { setNavSelection({ section: 'sessions' }); setWorkHubActive(true); }, [setNavSelection]); - // PR109f: branched session context. When the active session was - // created via `sessions:branchFromTurn`, its `parentSessionId` is - // set; render a banner above the chat surface so the user knows - // they're in a derived conversation and can jump back to the parent. - // - // v1 intentionally omits the fromAbortedTurn hint because checking - // it requires loading the parent's full message log. The session - // banner stays at "分自 ${parentName}" until parent-message - // preloading lands; "从中断前" is only surfaced in the aborted - // turn's branch footer tooltip where the active turn status is known. - const branchBanner = useMemo( - () => deriveBranchBanner(activeSession, sessions), - [activeSession?.parentSessionId, sessions], - ); - const revisionNavigation = useMemo( - () => deriveSessionRevisionNavigation(sessions, activeId), - [sessions, activeId], - ); - - function handleBranchBannerClick(parentSessionId: string): void { - openSessionInChat(parentSessionId); - } - // Transient placeholder while the real SessionSummary loads, so the composer // does not flash a value the session never had. const activeSessionForView: SessionSummary | undefined = @@ -1373,12 +1302,6 @@ function AppShellContent({ onRetry: () => reloadActiveExecutionBoundary(activeId), } : undefined; - const { - sessionListWidth, - setSessionListWidth, - sessionListCollapsed, - setSessionListCollapsed, - } = useShellLayout(); const desktopSlashCommands = useMemo( () => { const streaming = turnActive || activeStreamingLive; @@ -1594,7 +1517,7 @@ function AppShellContent({ }); // Sidebar Project groups are Local. Their catalog mutations remain on the // default-scoped bridge until Settings receives its own Host selector. - const projectRowActions: Parameters[0]['projectActions'] = + const projectRowActions: ComponentProps['projectActions'] = projectCapabilities.setLocalDefault ? { onNew: createSessionInProject, @@ -1663,54 +1586,54 @@ function AppShellContent({ reportError: reportWorkbarError, }); - // One projection owns rail membership, active highlight, and titlebar parent. - // Companion forks remain hidden until their authoritative cleanup completes. - const { - sessions: visibleSessions, - activeRowId: sidebarActiveId, - activeParentSession: railParentSession, - } = useMemo( - () => - deriveSessionRail(sessions, activeId, (session) => - !workbar.selectors.hiddenSessionIds.has(session.id) && - sessionMatchesRail(session), - ), - [sessions, activeId, workbar.selectors.hiddenSessionIds], + const exitWorkHub = useCallback(() => setWorkHubActive(false), []); + const selectSessionSurface = useCallback( + () => setNavSelection({ section: 'sessions' }), + [setNavSelection], ); + const clearActiveMessages = useCallback(() => setMessages([]), [setMessages]); + const sessionNavigation = useSessionNavigationController({ + sessions, + activeSessionId: activeId, + hiddenSessionIds: workbar.selectors.hiddenSessionIds, + projects: localProjects, + activateSession: setActiveId, + clearActiveMessages, + clearSessionRendererState, + exitWorkHub, + refreshSessions, + selectSessionSurface, + setSearchTarget: setSearchScrollTarget, + toastApi, + }); + useLayoutEffect(() => { + openSessionInChatRef.current = sessionNavigation.commands.openSession; + }, [sessionNavigation.commands.openSession]); + const visibleSessions = sessionNavigation.selectors.visibleSessions; + const sessionListCollapsed = sessionNavigation.layout.collapsed; + const sessionListWidth = sessionNavigation.layout.width; + const sessionSideNavHandleRef = sessionNavigation.layout.collapseHandleRef; const titlebarParentSession = useMemo(() => { - if (!railParentSession) return undefined; - const parentId = railParentSession.id; + const parent = sessionNavigation.selectors.activeParentSession; + if (!parent) return undefined; + const parentId = parent.id; return { - name: railParentSession.name, + name: parent.name, onOpen: () => openSessionInChatRef.current(parentId), }; - }, [railParentSession]); - const sessionProjectGroups = useMemo( - () => deriveDesktopSessionGroups(visibleSessions, localProjects, uiLocale), - [visibleSessions, localProjects, uiLocale], - ); - const worktreeSessionIds = useMemo( - () => - deriveWorktreeSessionIds( - visibleSessions.filter( - (session) => session.profileKind !== 'remote', - ), - localProjects, - ), - [visibleSessions, localProjects], - ); + }, [sessionNavigation.selectors.activeParentSession]); const archivedTasksBridge = useMemo( () => ({ sessions, projects: localProjects, onRestore: (sessionId) => - void sessionRowActionHandlers.unarchiveSession(sessionId), + void sessionNavigation.commands.unarchiveSession(sessionId), onDelete: (sessionId) => - void sessionRowActionHandlers.deleteSession(sessionId), + void sessionNavigation.commands.deleteSession(sessionId), onPurge: (sessionIds) => - sessionRowActionHandlers.purgeSessions(sessionIds), + sessionNavigation.commands.purgeSessions(sessionIds), }), - [sessions, localProjects], + [sessions, localProjects, sessionNavigation.commands], ); const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, { @@ -1719,7 +1642,7 @@ function AppShellContent({ setActiveId, setNavSelection, setSearchModalOpen, - setSessionListCollapsed, + setSessionListCollapsed: sessionNavigation.layout.setCollapsed, workbar: { rightCollapsed: workbar.selectors.rightCollapsed, toggleRight: workbar.commands.toggleRight, @@ -2251,9 +2174,6 @@ function AppShellContent({ }); useAppShellPersistenceEffects({ navigationState, - sessionListCollapsed, - sessionListWidth, - sessionListViewMode: viewMode, themePalette, themePref, }); @@ -2702,7 +2622,7 @@ function AppShellContent({ key={activeSessionForView.id} sessionName={activeSessionForView.name} onRenameSession={(name) => { - void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); + void sessionNavigation.commands.renameSession(activeSessionForView.id, name); }} project={ titlebarProjectName @@ -2740,47 +2660,26 @@ function AppShellContent({ aria-hidden={shellObscured ? 'true' : undefined} inert={shellObscured ? true : undefined} sideNav={ - { - if (width >= SESSION_LIST_EXPANDED_MIN_WIDTH) setSessionListWidth(width); - }} - minWidth={SESSION_LIST_EXPANDED_MIN_WIDTH} - maxWidth={SESSION_LIST_EXPANDED_MAX_WIDTH} + { - setWorkHubActive(false); - setNavSelection(selection); - }} - onSelectSession={sessionListSelectSession} + onSelect={setNavSelection} onOpenSettings={openSettings} buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} - onNew={() => { - setWorkHubActive(false); - void createSession(); - }} + onNew={() => void createSession()} workHubEntry={workHubEnabled ? { active: workHubActive, label: 'WorkHub', onSelect: openWorkHub, } : undefined} - rowActions={sessionRowActions} projectActions={projectRowActions} /> } @@ -3057,9 +2956,9 @@ function AppShellContent({ ? (target) => openSessionInChat(activeId, target.turnId, target.sequence) : undefined} scrollBehavior={readScrollMotionBehavior()} - branchBanner={branchBanner} - onBranchBannerClick={handleBranchBannerClick} - revisionNavigation={revisionNavigation} + branchBanner={sessionNavigation.selectors.branchBanner} + onBranchBannerClick={openSessionInChat} + revisionNavigation={sessionNavigation.selectors.revisionNavigation} onRevisionNavigate={openSessionInChat} onNew={createSession} onPromptSuggestion={(prompt) => composerRef.current?.appendText(prompt)} @@ -3232,37 +3131,3 @@ function AppShellContent({ ); } - -function runtimeHostSessionMeta(session: DesktopSessionSummary): string | undefined { - return session.profileKind === 'remote' ? session.profileName : undefined; -} - -function deriveDesktopSessionGroups( - sessions: readonly DesktopSessionSummary[], - projects: readonly ProjectRecord[], - locale: UiLocale, -): SessionHistoryGroup[] { - const local: DesktopSessionSummary[] = []; - const remote = new Map(); - for (const session of sessions) { - if (session.profileKind !== 'remote') { - local.push(session); - continue; - } - const key = session.profileId; - const group = remote.get(key) ?? { - label: session.profileName, - sessions: [], - }; - group.sessions.push(session); - remote.set(key, group); - } - return [ - ...deriveProjectGroups(local, projects, locale), - ...[...remote].map(([id, group]) => ({ - id: `runtime-host:${id}`, - label: group.label, - sessions: group.sessions, - })), - ]; -} diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md new file mode 100644 index 0000000000..7fadedaf47 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/README.md @@ -0,0 +1,67 @@ + + +# Session Navigation feature + +Session Navigation is the renderer feature boundary for the Session rail. It +owns: + +- rail membership, linked-session highlighting, Project/Runtime Host grouping, + worktree badges, branch banners, and revision navigation; +- collapsed/expanded state, width, grouping mode, and their existing local + persistence keys; +- explicit jumps into a Session, including search turn targets; and +- flag, archive, restore, rename, delete, and archived-task purge lifecycles. + +## Dependency direction + +- Consumers import production APIs from `features/session-navigation`. +- Tests may additionally import `features/session-navigation/testing`. +- Desktop Sessions bridge calls go through `SessionNavigationServices`; only + `platform/desktop/create-session-navigation-services.ts` reads that bridge. +- Session Navigation may use shared renderer storage/copy, core types, and Maka + UI, but must not import AppShell, preload implementation, or main-process + code. + +AppShell remains responsible for the authoritative catalog snapshot and for +composing explicit cross-feature intents: top-level destination selection, +WorkHub exit, active-Session selection, transcript clearing, and renderer-state +cleanup. Session Navigation does not own catalog authority, transcript/runtime +state, Session controls, task submission, or Module Hub routing. + +## Public surface + +- `useSessionNavigationController` owns layout, projections, jumps, and row + mutation commands. +- `` maps that controller onto the complete + `SessionListPanel` surface. +- `selectors.activeParentSession`, `branchBanner`, and `revisionNavigation` + are the narrow projections still consumed by shell/conversation chrome. + +## Lifecycle invariants + +- Archived, linked-subagent, and hidden companion Sessions follow the existing + single-rail projection; a linked child highlights its visible root. +- Local Sessions group by Project while remote Sessions group by Runtime Host. +- Opening a Session first exits WorkHub, selects the Sessions destination, then + activates the Session and replaces or clears the turn-scroll target. +- At most one row mutation runs per Session. Mutations retain revision-family + semantics, and renderer state is cleared only after the Host confirms removal. +- Width persistence remains trailing-debounced; width, collapse, and grouping + reuse the existing local-storage keys and hydration rules. diff --git a/apps/desktop/src/renderer/app-shell-session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts similarity index 90% rename from apps/desktop/src/renderer/app-shell-session-row-actions.ts rename to apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index cc4a002448..aca2d257f1 100644 --- a/apps/desktop/src/renderer/app-shell-session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -17,10 +17,11 @@ * under the License. */ -import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; +import type { SessionNavigationSessionService } from '../ports.js'; type RefBox = { current: T }; @@ -66,7 +67,7 @@ export interface SessionPurgeOutcome { }; } -export interface AppShellSessionRowActions { +export interface SessionNavigationRowActions { flagSession(sessionId: string, flagged: boolean): Promise; archiveSession(sessionId: string): Promise; unarchiveSession(sessionId: string): Promise; @@ -75,26 +76,28 @@ export interface AppShellSessionRowActions { purgeSessions(sessionIds: readonly string[]): Promise; } -export function createAppShellSessionRowActions(deps: { +export function createSessionNavigationRowActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; + clearActiveMessages: () => void; clearSessionRendererState: (sessionId: string) => void; pendingSessionRowActionsRef: RefBox>; - refreshSessions: () => Promise; - sessionsRef: RefBox; + refreshSessions: () => Promise>; + service: SessionNavigationSessionService; + sessionsRef: RefBox>; setActiveId: (sessionId: string | undefined) => void; - setMessages: (messages: StoredMessage[]) => void; toastApi: ToastApi; -}): AppShellSessionRowActions { +}): SessionNavigationRowActions { const { uiLocale, activeIdRef, + clearActiveMessages, clearSessionRendererState, pendingSessionRowActionsRef, refreshSessions, + service, sessionsRef, setActiveId, - setMessages, toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionRowActions; @@ -125,7 +128,7 @@ export function createAppShellSessionRowActions(deps: { async function flagSession(sessionId: string, flagged: boolean) { return runSessionRowAction(sessionId, 'flag', flagged ? copy.flagFailedTitle : copy.unflagFailedTitle, async () => { - await window.maka.sessions.setFlagged(sessionId, flagged, { revisionFamily: true }); + await service.setFlagged(sessionId, flagged, { revisionFamily: true }); await refreshSessions(); }); } @@ -133,10 +136,10 @@ export function createAppShellSessionRowActions(deps: { async function archiveSession(sessionId: string) { return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - await window.maka.sessions.archive(sessionId, { revisionFamily: true }); + await service.archive(sessionId, { revisionFamily: true }); if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { setActiveId(undefined); - setMessages([]); + clearActiveMessages(); } for (const id of familyIds) clearSessionRendererState(id); await refreshSessions(); @@ -145,14 +148,14 @@ export function createAppShellSessionRowActions(deps: { async function unarchiveSession(sessionId: string) { return runSessionRowAction(sessionId, 'archive', copy.unarchiveFailedTitle, async () => { - await window.maka.sessions.unarchive(sessionId, { revisionFamily: true }); + await service.unarchive(sessionId, { revisionFamily: true }); await refreshSessions(); }); } async function renameSession(sessionId: string, name: string) { return runSessionRowAction(sessionId, 'rename', copy.renameFailedTitle, async () => { - await window.maka.sessions.rename(sessionId, name, { revisionFamily: true }); + await service.rename(sessionId, name, { revisionFamily: true }); await refreshSessions(); }); } @@ -194,14 +197,14 @@ export function createAppShellSessionRowActions(deps: { // Read before the write: the family comes off the live catalog, which no // longer lists it afterwards. const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - const disposition = await window.maka.sessions.remove(sessionId, { + const disposition = await service.remove(sessionId, { revisionFamily: true, requireArchived: options.requireArchived, }); if (disposition === 'restored') return disposition; if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { setActiveId(undefined); - setMessages([]); + clearActiveMessages(); } for (const id of familyIds) clearSessionRendererState(id); return disposition; @@ -270,7 +273,7 @@ export function createAppShellSessionRowActions(deps: { } let listed: SessionSummary[] | undefined; try { - listed = await window.maka.sessions.list(); + listed = await service.list(); } catch { listed = undefined; } diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts new file mode 100644 index 0000000000..64107e1990 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -0,0 +1,287 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from 'react'; +import type { ProjectRecord } from '@maka/core/project'; +import type { SessionSummary } from '@maka/core/session'; +import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; +import { useUiLocale, type SessionHistoryGroup, type SessionViewMode } from '@maka/ui'; +import { safeLocalStorageSet } from '../../../browser-storage.js'; +import { useStableActions } from '../../../use-stable-actions.js'; +import type { BranchBanner } from '../model/branch-banner.js'; +import { deriveBranchBanner } from '../model/branch-banner.js'; +import { + readSessionListCollapsed, + readSessionListViewMode, + readSessionListWidth, + SESSION_LIST_EXPANDED_MAX_WIDTH, + SESSION_LIST_EXPANDED_MIN_WIDTH, + writeSessionListViewMode, +} from '../model/session-list-layout.js'; +import { deriveSessionNavigationGroups } from '../model/session-navigation-groups.js'; +import { sessionMatchesRail } from '../model/session-nav-filter.js'; +import { deriveWorktreeSessionIds } from '../model/session-project-grouping.js'; +import { deriveSessionRail } from '../model/session-rail.js'; +import { + deriveSessionRevisionNavigation, + type SessionRevisionNavigation, +} from '../model/session-revisions.js'; +import type { SessionNavigationSession } from '../ports.js'; +import { useSessionNavigationServices } from '../services-context.js'; +import { + createSessionNavigationRowActions, + type SessionNavigationRowActions, +} from './session-row-actions.js'; + +const LAYOUT_PERSIST_DEBOUNCE_MS = 200; + +export type SessionNavigationSearchTarget = { + sessionId: string; + turnId: string; + sequence?: number; + nonce: number; +}; + +export type SessionNavigationToastApi = { + success(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; + confirm(options: { + title: string; + description: string; + confirmLabel: string; + cancelLabel: string; + destructive?: boolean; + }): Promise; +}; + +export interface UseSessionNavigationControllerInput { + sessions: readonly SessionNavigationSession[]; + activeSessionId: string | undefined; + hiddenSessionIds: ReadonlySet; + projects: readonly ProjectRecord[]; + activateSession(sessionId: string | undefined): void; + clearActiveMessages(): void; + clearSessionRendererState(sessionId: string): void; + exitWorkHub(): void; + refreshSessions(): Promise>; + selectSessionSurface(): void; + setSearchTarget(target: SessionNavigationSearchTarget | null): void; + toastApi: SessionNavigationToastApi; +} + +export interface SessionNavigationLayout { + collapsed: boolean; + width: number; + viewMode: SessionViewMode; + collapseHandleRef: RefObject; + setCollapsed: Dispatch>; + setWidth(width: number): void; + setViewMode(mode: SessionViewMode): void; +} + +export interface SessionNavigationSelectors { + visibleSessions: SessionNavigationSession[]; + activeRowId: string | undefined; + activeParentSession: SessionNavigationSession | undefined; + branchBanner: BranchBanner | undefined; + revisionNavigation: SessionRevisionNavigation | undefined; + groups: SessionHistoryGroup[]; + worktreeSessionIds: ReadonlySet; + sessionMeta(session: SessionSummary): string | undefined; +} + +export interface SessionNavigationCommands extends SessionNavigationRowActions { + openSession(sessionId: string, turnId?: string, sequence?: number): void; +} + +export interface SessionNavigationController { + layout: SessionNavigationLayout; + selectors: SessionNavigationSelectors; + commands: SessionNavigationCommands; +} + +/** Owns Session rail projection, layout persistence, jumps, and row mutations. */ +export function useSessionNavigationController( + input: UseSessionNavigationControllerInput, +): SessionNavigationController { + const locale = useUiLocale(); + const { sessions: service } = useSessionNavigationServices(); + const [width, setWidth] = useState(readSessionListWidth); + const [collapsed, setCollapsed] = useState(readSessionListCollapsed); + const [viewMode, setViewMode] = useState(readSessionListViewMode); + const collapseHandleRef = useRef(null); + const activeIdRef = useRef(input.activeSessionId); + const sessionsRef = useRef>(input.sessions); + const pendingSessionRowActionsRef = useRef(new Set()); + + // Row actions can settle after the render that created them. Publish the + // catalog/selection pair only when that render commits so an interrupted + // concurrent render cannot leak an uncommitted snapshot to a live action. + useLayoutEffect(() => { + activeIdRef.current = input.activeSessionId; + sessionsRef.current = input.sessions; + }, [input.activeSessionId, input.sessions]); + + useEffect(() => { + const handle = window.setTimeout(() => { + safeLocalStorageSet('maka-chat-list-width-v1', String(width)); + }, LAYOUT_PERSIST_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [width]); + + useEffect(() => { + safeLocalStorageSet( + 'maka-chat-list-collapsed-v1', + collapsed ? 'true' : 'false', + ); + }, [collapsed]); + + useEffect(() => { + writeSessionListViewMode(viewMode); + }, [viewMode]); + + const rowActions = useStableActions(createSessionNavigationRowActions, { + uiLocale: locale, + activeIdRef, + clearActiveMessages: input.clearActiveMessages, + clearSessionRendererState: input.clearSessionRendererState, + pendingSessionRowActionsRef, + refreshSessions: input.refreshSessions, + service, + sessionsRef, + setActiveId: input.activateSession, + toastApi: input.toastApi, + }); + + const openSession = useCallback( + (sessionId: string, turnId?: string, sequence?: number): void => { + input.exitWorkHub(); + input.selectSessionSurface(); + input.activateSession(sessionId); + input.setSearchTarget( + turnId + ? { sessionId, turnId, sequence, nonce: Date.now() } + : null, + ); + }, + [ + input.activateSession, + input.exitWorkHub, + input.selectSessionSurface, + input.setSearchTarget, + ], + ); + + const rail = useMemo( + () => + deriveSessionRail(input.sessions, input.activeSessionId, (session) => + !input.hiddenSessionIds.has(session.id) && sessionMatchesRail(session), + ), + [input.activeSessionId, input.hiddenSessionIds, input.sessions], + ); + const groups = useMemo( + () => deriveSessionNavigationGroups(rail.sessions, input.projects, locale), + [locale, input.projects, rail.sessions], + ); + const worktreeSessionIds = useMemo( + () => + deriveWorktreeSessionIds( + rail.sessions.filter((session) => session.profileKind !== 'remote'), + input.projects, + ), + [input.projects, rail.sessions], + ); + const activeSession = input.sessions.find( + (session) => session.id === input.activeSessionId, + ); + const branchBanner = useMemo( + () => deriveBranchBanner(activeSession, input.sessions), + [activeSession, input.sessions], + ); + const revisionNavigation = useMemo( + () => deriveSessionRevisionNavigation(input.sessions, input.activeSessionId), + [input.activeSessionId, input.sessions], + ); + const sessionById = useMemo( + () => new Map(input.sessions.map((session) => [session.id, session])), + [input.sessions], + ); + const sessionMeta = useCallback( + (session: SessionSummary): string | undefined => { + const projected = sessionById.get(session.id); + return projected?.profileKind === 'remote' + ? projected.profileName + : undefined; + }, + [sessionById], + ); + + const layout = useMemo( + () => ({ + collapsed, + width, + viewMode, + collapseHandleRef, + setCollapsed, + setWidth, + setViewMode, + }), + [collapsed, viewMode, width], + ); + const selectors = useMemo( + () => ({ + visibleSessions: rail.sessions, + activeRowId: rail.activeRowId, + activeParentSession: rail.activeParentSession, + branchBanner, + revisionNavigation, + groups, + worktreeSessionIds, + sessionMeta, + }), + [branchBanner, groups, rail, revisionNavigation, sessionMeta, worktreeSessionIds], + ); + const commands = useMemo( + () => ({ + ...rowActions, + openSession, + }), + [openSession, rowActions], + ); + + return useMemo( + () => ({ layout, selectors, commands }), + [commands, layout, selectors], + ); +} diff --git a/apps/desktop/src/renderer/use-shell-layout.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts similarity index 58% rename from apps/desktop/src/renderer/use-shell-layout.ts rename to apps/desktop/src/renderer/features/session-navigation/index.ts index 38f694defb..17ce0481e3 100644 --- a/apps/desktop/src/renderer/use-shell-layout.ts +++ b/apps/desktop/src/renderer/features/session-navigation/index.ts @@ -17,24 +17,9 @@ * under the License. */ -import { useState } from 'react'; -import { - readSessionListCollapsed, - readSessionListWidth, -} from './session-list-layout'; - -/** Owns only the navigation rail portion of the application shell layout. */ -export function useShellLayout() { - const [sessionListWidth, setSessionListWidth] = useState(() => - readSessionListWidth(), - ); - const [sessionListCollapsed, setSessionListCollapsed] = useState(() => - readSessionListCollapsed(), - ); - return { - sessionListWidth, - setSessionListWidth, - sessionListCollapsed, - setSessionListCollapsed, - }; -} +export { SessionNavigationServicesProvider } from './services-context.js'; +export { useSessionNavigationController } from './controller/use-session-navigation-controller.js'; +export { SessionNavigationHost } from './ui/session-navigation-host.js'; +export { deriveSessionRail } from './model/session-rail.js'; +export type { SessionPurgeOutcome } from './controller/session-row-actions.js'; +export type { SessionNavigationServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/branch-banner.ts b/apps/desktop/src/renderer/features/session-navigation/model/branch-banner.ts similarity index 100% rename from apps/desktop/src/renderer/branch-banner.ts rename to apps/desktop/src/renderer/features/session-navigation/model/branch-banner.ts diff --git a/apps/desktop/src/renderer/session-list-layout.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts similarity index 96% rename from apps/desktop/src/renderer/session-list-layout.ts rename to apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts index 35fa6f90a8..107962f1f7 100644 --- a/apps/desktop/src/renderer/session-list-layout.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts @@ -18,7 +18,7 @@ */ import type { SessionViewMode } from '@maka/ui'; -import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage.js'; +import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; export const SESSION_LIST_EXPANDED_DEFAULT_WIDTH = 260; export const SESSION_LIST_EXPANDED_MIN_WIDTH = 180; diff --git a/apps/desktop/src/renderer/session-nav-filter.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts similarity index 100% rename from apps/desktop/src/renderer/session-nav-filter.ts rename to apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts new file mode 100644 index 0000000000..84cf027ea7 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ProjectRecord } from '@maka/core/project'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { SessionHistoryGroup } from '@maka/ui'; +import type { SessionNavigationSession } from '../ports.js'; +import { deriveProjectGroups } from './session-project-grouping.js'; + +/** Groups local Sessions by Project and remote Sessions by Runtime Host. */ +export function deriveSessionNavigationGroups( + sessions: readonly SessionNavigationSession[], + projects: readonly ProjectRecord[], + locale: UiLocale, +): SessionHistoryGroup[] { + const local: SessionNavigationSession[] = []; + const remote = new Map< + string, + { label: string; sessions: SessionNavigationSession[] } + >(); + for (const session of sessions) { + if (session.profileKind !== 'remote') { + local.push(session); + continue; + } + const group = remote.get(session.profileId) ?? { + label: session.profileName, + sessions: [], + }; + group.sessions.push(session); + remote.set(session.profileId, group); + } + return [ + ...deriveProjectGroups(local, projects, locale), + ...[...remote].map(([id, group]) => ({ + id: `runtime-host:${id}`, + label: group.label, + sessions: group.sessions, + })), + ]; +} diff --git a/apps/desktop/src/renderer/session-project-grouping.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts similarity index 97% rename from apps/desktop/src/renderer/session-project-grouping.ts rename to apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts index be59f1bdc7..8fa85cf5a6 100644 --- a/apps/desktop/src/renderer/session-project-grouping.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts @@ -21,7 +21,7 @@ import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SessionHistoryGroup } from '@maka/ui'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; +import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js'; const UNGROUPED_KEY = '__ungrouped__'; diff --git a/apps/desktop/src/renderer/session-rail.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-rail.ts similarity index 100% rename from apps/desktop/src/renderer/session-rail.ts rename to apps/desktop/src/renderer/features/session-navigation/model/session-rail.ts diff --git a/apps/desktop/src/renderer/session-revisions.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-revisions.ts similarity index 100% rename from apps/desktop/src/renderer/session-revisions.ts rename to apps/desktop/src/renderer/features/session-navigation/model/session-revisions.ts diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts new file mode 100644 index 0000000000..60c45db690 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionSummary } from '@maka/core/session'; + +export type SessionNavigationRemoveDisposition = 'removed' | 'restored'; + +export interface SessionNavigationSession extends SessionSummary { + readonly profileId: string; + readonly profileName: string; + readonly profileKind: 'local' | 'remote'; +} + +/** The minimum catalog mutation capability needed by Session Navigation. */ +export interface SessionNavigationSessionService { + list(): Promise; + setFlagged( + sessionId: string, + flagged: boolean, + options: { revisionFamily: true }, + ): Promise; + archive( + sessionId: string, + options: { revisionFamily: true }, + ): Promise; + unarchive( + sessionId: string, + options: { revisionFamily: true }, + ): Promise; + rename( + sessionId: string, + name: string, + options: { revisionFamily: true }, + ): Promise; + remove( + sessionId: string, + options: { revisionFamily: true; requireArchived: boolean }, + ): Promise; +} + +export interface SessionNavigationServices { + readonly sessions: SessionNavigationSessionService; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/services-context.tsx b/apps/desktop/src/renderer/features/session-navigation/services-context.tsx new file mode 100644 index 0000000000..72cbcc6108 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/services-context.tsx @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext, type ReactNode } from 'react'; +import type { SessionNavigationServices } from './ports.js'; + +const SessionNavigationServicesContext = + createContext(null); + +export function SessionNavigationServicesProvider(props: { + services: SessionNavigationServices; + children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useSessionNavigationServices(): SessionNavigationServices { + const services = useContext(SessionNavigationServicesContext); + if (!services) { + throw new Error('SessionNavigationServicesProvider is missing'); + } + return services; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts new file mode 100644 index 0000000000..f39ec85855 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionNavigationServices } from './ports.js'; + +export type { + SessionNavigationServices, + SessionNavigationSession, + SessionNavigationSessionService, +} from './ports.js'; + +export { SessionNavigationServicesProvider } from './services-context.js'; +export { + createSessionNavigationRowActions, +} from './controller/session-row-actions.js'; +export { + useSessionNavigationController, + type SessionNavigationController, + type UseSessionNavigationControllerInput, +} from './controller/use-session-navigation-controller.js'; +export { deriveBranchBanner } from './model/branch-banner.js'; +export { deriveSessionRail } from './model/session-rail.js'; +export { deriveSessionRevisionNavigation } from './model/session-revisions.js'; +export { + readSessionListViewMode, + writeSessionListViewMode, +} from './model/session-list-layout.js'; + +export function createFakeSessionNavigationServices( + overrides: Partial = {}, +): SessionNavigationServices { + return { + sessions: { + list: async () => [], + setFlagged: async () => undefined, + archive: async () => undefined, + unarchive: async () => undefined, + rename: async () => undefined, + remove: async () => 'removed', + }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx new file mode 100644 index 0000000000..64d9b60ca6 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, type ComponentProps } from 'react'; +import { SessionListPanel } from '@maka/ui'; +import type { SessionNavigationController } from '../controller/use-session-navigation-controller.js'; +import { + SESSION_LIST_EXPANDED_MAX_WIDTH, + SESSION_LIST_EXPANDED_MIN_WIDTH, +} from '../model/session-list-layout.js'; + +type PanelProps = ComponentProps; + +export type SessionNavigationHostProps = Pick< + PanelProps, + | 'selection' + | 'scheduledTasks' + | 'streamingSessionIds' + | 'staleSessionIds' + | 'moduleMemory' + | 'onSelect' + | 'onOpenSettings' + | 'buildStamp' + | 'updateReminder' + | 'onOpenUpdate' + | 'onNew' + | 'workHubEntry' + | 'projectActions' +> & { + controller: SessionNavigationController; + onExitWorkHub(): void; + workHubActive: boolean; +}; + +/** Renders the complete Session navigation rail from its feature controller. */ +export function SessionNavigationHost(props: SessionNavigationHostProps) { + const { controller } = props; + const rowActions = useMemo>( + () => ({ + onToggleFlag: (sessionId, next) => { + void controller.commands.flagSession(sessionId, next); + }, + onArchive: (sessionId) => { + void controller.commands.archiveSession(sessionId); + }, + onUnarchive: (sessionId) => { + void controller.commands.unarchiveSession(sessionId); + }, + onRename: (sessionId, name) => { + void controller.commands.renameSession(sessionId, name); + }, + onDelete: (sessionId) => { + void controller.commands.deleteSession(sessionId); + }, + }), + [controller.commands], + ); + + return ( + { + if (width >= SESSION_LIST_EXPANDED_MIN_WIDTH) { + controller.layout.setWidth(width); + } + }} + minWidth={SESSION_LIST_EXPANDED_MIN_WIDTH} + maxWidth={SESSION_LIST_EXPANDED_MAX_WIDTH} + selection={props.selection} + sessions={controller.selectors.visibleSessions} + activeId={props.workHubActive ? undefined : controller.selectors.activeRowId} + scheduledTasks={props.scheduledTasks} + streamingSessionIds={props.streamingSessionIds} + staleSessionIds={props.staleSessionIds} + viewMode={controller.layout.viewMode} + onViewModeChange={controller.layout.setViewMode} + groups={ + controller.layout.viewMode === 'project' + ? controller.selectors.groups + : undefined + } + worktreeSessionIds={controller.selectors.worktreeSessionIds} + sessionMeta={controller.selectors.sessionMeta} + moduleMemory={props.moduleMemory} + onSelect={(selection) => { + props.onExitWorkHub(); + props.onSelect(selection); + }} + onSelectSession={controller.commands.openSession} + onOpenSettings={props.onOpenSettings} + buildStamp={props.buildStamp} + updateReminder={props.updateReminder} + onOpenUpdate={props.onOpenUpdate} + onNew={() => { + props.onExitWorkHub(); + props.onNew(); + }} + workHubEntry={props.workHubEntry} + rowActions={rowActions} + projectActions={props.projectActions} + /> + ); +} diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index c39f931b27..63013c54ed 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -30,6 +30,8 @@ import { GoalServicesProvider } from './features/goals'; import { createDesktopGoalServices } from './platform/desktop/create-goal-services'; import { ModuleHubServicesProvider } from './features/module-hub'; import { createDesktopModuleHubServices } from './platform/desktop/create-module-hub-services'; +import { SessionNavigationServicesProvider } from './features/session-navigation'; +import { createDesktopSessionNavigationServices } from './platform/desktop/create-session-navigation-services'; const ONBOARDING_SNAPSHOT_RETRY_DELAY_MS = 150; const ONBOARDING_SNAPSHOT_TIMEOUT_MS = 2_500; @@ -39,6 +41,7 @@ applyCachedThemeBeforeMount(); const workbarServices = createDesktopWorkbarServices(); const goalServices = createDesktopGoalServices(); const moduleHubServices = createDesktopModuleHubServices(); +const sessionNavigationServices = createDesktopSessionNavigationServices(); /** * Prefetch the onboarding snapshot BEFORE mounting React. The preload @@ -71,12 +74,14 @@ async function prefetchOnboardingSnapshot(): Promise void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => { createRoot(document.getElementById('root')!).render( - - - - - - - , + + + + + + + + + , ); }); diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts new file mode 100644 index 0000000000..de49032287 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { SessionNavigationServices } from '../../features/session-navigation'; + +export type DesktopSessionNavigationBridge = Pick; + +/** The only Desktop bridge adapter used by Session Navigation. */ +export function createDesktopSessionNavigationServices( + bridge: DesktopSessionNavigationBridge = window.maka, +): SessionNavigationServices { + return { + sessions: { + list: () => bridge.sessions.list(), + setFlagged: (sessionId, flagged, options) => + bridge.sessions.setFlagged(sessionId, flagged, options), + archive: (sessionId, options) => + bridge.sessions.archive(sessionId, options), + unarchive: (sessionId, options) => + bridge.sessions.unarchive(sessionId, options), + rename: (sessionId, name, options) => + bridge.sessions.rename(sessionId, name, options), + remove: (sessionId, options) => + bridge.sessions.remove(sessionId, options), + }, + }; +} diff --git a/apps/desktop/src/renderer/settings/task-catalog-rows.ts b/apps/desktop/src/renderer/settings/task-catalog-rows.ts index 2aacaac9f2..b669956500 100644 --- a/apps/desktop/src/renderer/settings/task-catalog-rows.ts +++ b/apps/desktop/src/renderer/settings/task-catalog-rows.ts @@ -18,7 +18,7 @@ */ import type { SessionSummary } from '@maka/core/session'; -import { deriveSessionRail } from '../session-rail.js'; +import { deriveSessionRail } from '../features/session-navigation/index.js'; /** * The archived tasks, counted the way the rail counts tasks. diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 494756b1e8..32fe6a9e70 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -25,7 +25,7 @@ import { Archive, ICON_SIZE, Search } from '@maka/ui/icons'; import { HStack, StackItem } from '@astryxdesign/core'; import { List, ListItem } from '@astryxdesign/core/List'; import { TextInput } from '@astryxdesign/core/TextInput'; -import type { SessionPurgeOutcome } from '../app-shell-session-row-actions.js'; +import type { SessionPurgeOutcome } from '../features/session-navigation'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { getSettingsTasksCopy } from '../locales/settings-tasks-copy.js'; diff --git a/apps/desktop/src/renderer/use-stable-actions.ts b/apps/desktop/src/renderer/use-stable-actions.ts index 7c6b68494e..57b73d27f6 100644 --- a/apps/desktop/src/renderer/use-stable-actions.ts +++ b/apps/desktop/src/renderer/use-stable-actions.ts @@ -18,7 +18,7 @@ */ import { useLayoutEffect, useRef, useState } from 'react'; -import { createDelegatingActions } from './stable-actions'; +import { createDelegatingActions } from './stable-actions.js'; /** * Runs an app-shell action factory in the render body but returns a stable diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f10c8d958a..cd451bd1a9 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -35,9 +35,11 @@ import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions' import { WorkbarTitlebarActions } from '../src/renderer/features/workbar'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { deriveAppShellTurnPresentation } from '../src/renderer/app-shell-turn-view-model'; -import { deriveBranchBanner } from '../src/renderer/branch-banner'; -import { deriveSessionRevisionNavigation } from '../src/renderer/session-revisions'; -import { deriveSessionRail } from '../src/renderer/session-rail'; +import { + deriveBranchBanner, + deriveSessionRail, + deriveSessionRevisionNavigation, +} from '../src/renderer/features/session-navigation/testing'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; import { GoalDialog } from '../src/renderer/features/goals/testing'; diff --git a/apps/desktop/stories/subagent-sessions.stories.tsx b/apps/desktop/stories/subagent-sessions.stories.tsx index 4d0c21d5e6..554bf57a65 100644 --- a/apps/desktop/stories/subagent-sessions.stories.tsx +++ b/apps/desktop/stories/subagent-sessions.stories.tsx @@ -52,7 +52,7 @@ import { } from '@maka/ui'; import { ToolTrow } from '../../../packages/ui/src/tool-activity.js'; import type { ToolActivityItem } from '../../../packages/ui/src/materialize.js'; -import { deriveSessionRail } from '../src/renderer/session-rail.js'; +import { deriveSessionRail } from '../src/renderer/features/session-navigation/testing'; // --------------------------------------------------------------------------- // Fixtures — multi-subagent contiguous tool run diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4707983699..4dd9a91d2d 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 209 files — blocker 0, polish 1, aligned 208. +**Totals:** 213 files — blocker 0, polish 1, aligned 212. ## Exclusions (explicit) @@ -43,6 +43,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState | aligned — uses Astryx (Badge, Banner, Button, EmptyState) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 1abf8f367b..338067576a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -15,6 +15,8 @@ apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx apps/desktop/src/renderer/features/goals/ui/goal-host.tsx apps/desktop/src/renderer/features/module-hub/services-context.tsx apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +apps/desktop/src/renderer/features/session-navigation/services-context.tsx +apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx apps/desktop/src/renderer/features/workbar/services-context.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx