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}`
+}