diff --git a/apps/desktop/src/renderer/src/App.test.tsx b/apps/desktop/src/renderer/src/App.test.tsx index 3b5c45e1..40e9d6aa 100644 --- a/apps/desktop/src/renderer/src/App.test.tsx +++ b/apps/desktop/src/renderer/src/App.test.tsx @@ -419,6 +419,60 @@ describe('Shell split panes', () => { expect(screen.getByRole('region', { name: 'Beta' })).toHaveClass('border-accent/40') }) + it('prefixes each pane header with the session\'s project title', async () => { + invokeMock.mockImplementation(async (method) => { + switch (method) { + case 'ping': + return 'pong' + case 'app.info': + return { homeDir: 'C:\\Users\\tester' } + case 'session.list': + return [ + { + id: 'sess-alpha', + projectId: 'proj-ari', + title: 'New session', + updatedAt: NOW - 60_000, + messageCount: 1, + }, + { + id: 'sess-beta', + projectId: 'proj-docs', + title: 'Draft notes', + updatedAt: NOW - 120_000, + messageCount: 1, + }, + ] + case 'project.list': + return [ + { id: 'proj-ari', name: 'Ari', path: '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/projects/ari', status: 'ok', open: true }, + { id: 'proj-docs', name: 'Docs', path: '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/projects/docs', status: 'ok', open: true }, + ] + case 'providers.detect': + return [] + case 'providers.models': + return [] + case 'files.index': + return { paths: [] } + case 'endpoints.list': + return [] + case 'session.load': + return { session: null, activeTurnId: null } + default: + throw new Error(`unexpected method: ${String(method)}`) + } + }) + + openTwoPanes() + render() + + expect( + await screen.findByRole('region', { name: 'Ari - New session' }, { timeout: 10_000 }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Docs - Draft notes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close Ari - New session' })).toBeInTheDocument() + }) + it('keeps the newest session list when an older one answers last', async () => { // Lists overlap at boot and on the event feed, so answers can arrive out of // order. A stale one — started before Beta existed — would prune Beta's pane. diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 3c8385da..d721cb1e 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -15,6 +15,7 @@ import { Titlebar } from './shell/Titlebar' import { GalleryView } from './views' import { SessionView } from './features/session/SessionView' import { + UNFILED_GROUP_ID, moveProjectInList, projectMoveForDelta, shellRootFor, @@ -39,6 +40,7 @@ import { useCommands } from './features/palette/useCommands' import { ContentSearchOverlay } from './features/search' import { AwakenSplash, AWAKEN_MAX_MS } from './features/moment' import { useSessionActivity } from './features/session/use-session-activity' +import { formatPaneTitle } from './features/split/pane-title' import { SplitView } from './features/split/SplitView' import { splitLayoutActions, useSplitLayout } from './features/split/use-split-layout' import { focusNeighbour } from './features/split/split-geometry' @@ -981,7 +983,15 @@ function Shell() { sessions.find((s) => s.id === id)?.title ?? null} + titleOf={(id) => { + const session = sessions.find((s) => s.id === id) + if (session === undefined) return null + const projectName = + session.projectId === UNFILED_GROUP_ID + ? null + : (projects.find((p) => p.id === session.projectId)?.name ?? null) + return formatPaneTitle(session.title, projectName) + }} onFocus={splitLayoutActions.focus} onClose={splitLayoutActions.close} onSplit={splitLayoutActions.split} diff --git a/apps/desktop/src/renderer/src/features/split/PaneFrame.tsx b/apps/desktop/src/renderer/src/features/split/PaneFrame.tsx index 253c8384..281acd42 100644 --- a/apps/desktop/src/renderer/src/features/split/PaneFrame.tsx +++ b/apps/desktop/src/renderer/src/features/split/PaneFrame.tsx @@ -89,6 +89,7 @@ export function PaneFrame({ > { + it('joins a project name and session title with a hyphen', () => { + expect(formatPaneTitle('New session', 'Ari')).toBe('Ari - New session') + }) + + it('falls back to the session title when there is no project', () => { + expect(formatPaneTitle('Alpha', null)).toBe('Alpha') + expect(formatPaneTitle('Alpha', undefined)).toBe('Alpha') + expect(formatPaneTitle('Alpha', '')).toBe('Alpha') + }) + + it('stays null while the session itself has no name yet', () => { + expect(formatPaneTitle(null, 'Ari')).toBe(null) + expect(formatPaneTitle(undefined, 'Ari')).toBe(null) + expect(formatPaneTitle('', 'Ari')).toBe(null) + }) +}) diff --git a/apps/desktop/src/renderer/src/features/split/pane-title.ts b/apps/desktop/src/renderer/src/features/split/pane-title.ts new file mode 100644 index 00000000..2f68e5a0 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/split/pane-title.ts @@ -0,0 +1,12 @@ +/** + * The pane header's on-screen name: the session, prefixed with its project + * when one is known. Ad-hoc / unfiled sessions keep the session title alone. + */ +export function formatPaneTitle( + sessionTitle: string | null | undefined, + projectName: string | null | undefined, +): string | null { + if (sessionTitle == null || sessionTitle === '') return null + if (projectName == null || projectName === '') return sessionTitle + return `${projectName} - ${sessionTitle}` +}