Просмотр исходного кода

feat(tui): clickable subagent sessions in the sidebar

Sidebar agent rows become mouse targets: one active subagent opens its
transcript directly; several expand an inline list (alias, model,
colored status) to pick from. Rows highlight on hover in place, without
rebuilding the tree, and clicks respect primary button and text
selection.

Session identity (alias/model/status) is projected from the background
job board into tui-state.json and scoped to the visible conversation
root, so panes and windows stay independent. The startup sweep no
longer wipes live activity owned by the same PID (second plugin init in
one process), and the 1s snapshot poll now stat-caches unchanged files
instead of re-reading them.
dhaern 1 день назад
Родитель
Сommit
8cb16dd787
8 измененных файлов с 1883 добавлено и 65 удалено
  1. 183 0
      src/index.test.ts
  2. 78 7
      src/index.ts
  3. 175 3
      src/tui-state.test.ts
  4. 139 11
      src/tui-state.ts
  5. 504 0
      src/tui.test.ts
  6. 655 43
      src/tui.ts
  7. 82 0
      src/utils/background-job-coordinator.test.ts
  8. 67 1
      src/utils/background-job-coordinator.ts

+ 183 - 0
src/index.test.ts

@@ -436,6 +436,26 @@ describe('plugin TUI agent activity', () => {
     expect(readTuiSnapshot(projectDir).activeSessions).toEqual({});
   });
 
+  test('second plugin init in the same PID does not wipe the first instance activity', async () => {
+    await hooks?.['chat.message']?.(
+      { sessionID: 'oracle-live', agent: 'oracle' } as never,
+      {} as never,
+    );
+    await busy('oracle-live');
+    expect(readTuiSnapshot(projectDir).activeSessions).toEqual({
+      'oracle-live': 'oracle',
+    });
+
+    const second = await createActivityPlugin();
+    try {
+      expect(readTuiSnapshot(projectDir).activeSessions).toEqual({
+        'oracle-live': 'oracle',
+      });
+    } finally {
+      await second.dispose?.();
+    }
+  });
+
   test('server disposal preserves activity owned by another plugin instance', async () => {
     const otherHooks = await createActivityPlugin();
 
@@ -708,6 +728,169 @@ describe('plugin TUI agent activity', () => {
       root: 'fixer',
     });
   });
+
+  test('chat.message model is published to sessionDetails when the session is already busy', async () => {
+    await busy('ora-child');
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-child',
+        agent: 'oracle',
+        model: { providerID: 'openai', modelID: 'gpt-5.6' },
+      } as never,
+      {} as never,
+    );
+
+    expect(readTuiSnapshot(projectDir).sessionDetails['ora-child']).toEqual({
+      model: 'openai/gpt-5.6',
+      status: 'busy',
+    });
+  });
+
+  test('model observed before busy is recovered on activation (v2 order)', async () => {
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-early',
+        agent: 'oracle',
+        model: { providerID: 'openai', modelID: 'gpt-5.6' },
+      } as never,
+      {} as never,
+    );
+    expect(readTuiSnapshot(projectDir).sessionDetails).toEqual({});
+
+    await busy('ora-early');
+    expect(readTuiSnapshot(projectDir).sessionDetails['ora-early']).toEqual({
+      model: 'openai/gpt-5.6',
+      status: 'busy',
+    });
+  });
+
+  test('two same-agent sessions keep distinct models in sessionDetails', async () => {
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-a',
+        agent: 'oracle',
+        model: { providerID: 'openai', modelID: 'gpt-5.6' },
+      } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-b',
+        agent: 'oracle',
+        model: { providerID: 'anthropic', modelID: 'claude-opus' },
+      } as never,
+      {} as never,
+    );
+    await busy('ora-a');
+    await busy('ora-b');
+
+    const details = readTuiSnapshot(projectDir).sessionDetails;
+    expect(details['ora-a']?.model).toBe('openai/gpt-5.6');
+    expect(details['ora-b']?.model).toBe('anthropic/claude-opus');
+  });
+
+  test('chat.message model after idle does not resurrect sessionDetails', async () => {
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-idle',
+        agent: 'oracle',
+        model: { providerID: 'openai', modelID: 'gpt-5.6' },
+      } as never,
+      {} as never,
+    );
+    await busy('ora-idle');
+    await hooks?.event?.({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'ora-idle', status: { type: 'idle' } },
+      },
+    } as never);
+
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'ora-idle',
+        agent: 'oracle',
+        model: { providerID: 'openai', modelID: 'gpt-5.6' },
+      } as never,
+      {} as never,
+    );
+
+    expect(readTuiSnapshot(projectDir).activeSessions).toEqual({});
+    expect(readTuiSnapshot(projectDir).sessionDetails).toEqual({});
+  });
+
+  const launchChild = async (
+    parentID: string,
+    childID: string,
+    callID: string,
+  ) => {
+    await hooks?.['tool.execute.before']?.(
+      { tool: 'task', sessionID: parentID, callID } as never,
+      {
+        args: {
+          background: true,
+          subagent_type: 'oracle',
+          description: 'sidebar child',
+        },
+      } as never,
+    );
+    await hooks?.['tool.execute.after']?.(
+      { tool: 'task', sessionID: parentID, callID } as never,
+      {
+        output: [
+          `task_id: ${childID}`,
+          'state: running',
+          '',
+          '<task_result>',
+          'Background task started.',
+          '</task_result>',
+        ].join('\n'),
+      } as never,
+    );
+  };
+
+  test('launch then busy persists the board alias into sessionDetails', async () => {
+    await hooks?.['chat.message']?.(
+      { sessionID: 'parent-1', agent: 'orchestrator' } as never,
+      {} as never,
+    );
+    await launchChild('parent-1', 'child-launch-first', 'call-launch-first');
+    await hooks?.['chat.message']?.(
+      { sessionID: 'child-launch-first', agent: 'oracle' } as never,
+      {} as never,
+    );
+    await busy('child-launch-first');
+
+    const snapshot = readTuiSnapshot(projectDir);
+    expect(snapshot.sessionParents['child-launch-first']).toBe('parent-1');
+    expect(snapshot.sessionDetails['child-launch-first']?.alias).toMatch(
+      /^ora-\d+$/,
+    );
+    expect(snapshot.activeSessions['child-launch-first']).toBe('oracle');
+  });
+
+  test('busy then launch backfills the alias without resurrecting idle sessions', async () => {
+    await hooks?.['chat.message']?.(
+      { sessionID: 'parent-2', agent: 'orchestrator' } as never,
+      {} as never,
+    );
+    await hooks?.['chat.message']?.(
+      { sessionID: 'child-busy-first', agent: 'oracle' } as never,
+      {} as never,
+    );
+    await busy('child-busy-first');
+    expect(
+      readTuiSnapshot(projectDir).sessionDetails['child-busy-first']?.alias,
+    ).toBeUndefined();
+
+    await launchChild('parent-2', 'child-busy-first', 'call-busy-first');
+
+    const snapshot = readTuiSnapshot(projectDir);
+    expect(snapshot.sessionParents['child-busy-first']).toBe('parent-2');
+    expect(snapshot.sessionDetails['child-busy-first']?.alias).toMatch(
+      /^ora-\d+$/,
+    );
+  });
 });
 
 describe('background task admission model resolution', () => {

+ 78 - 7
src/index.ts

@@ -77,11 +77,13 @@ import {
 } from './tools/task-activity';
 import {
   clearTuiAgentActivities,
+  clearTuiSessionAlias,
   readTuiSnapshot,
   recordTuiAgentActivity,
   recordTuiAgentModel,
   recordTuiAgentModels,
   recordTuiSessionParent,
+  updateTuiSessionDetails,
 } from './tui-state';
 import {
   BackgroundJobBoard,
@@ -234,8 +236,10 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       sessionMetadata,
     );
   // Busy/retry arrived before the session's agent was known. chat.message
-  // latches the agent and flushes these so the spinner still starts.
-  const pendingTuiBusySessions = new Set<string>();
+  // latches the agent and flushes these so the spinner still starts. The
+  // observed status is kept so the flushed activation records the right
+  // sidebar detail (busy vs retry).
+  const pendingTuiBusySessions = new Map<string, 'busy' | 'retry'>();
   const tuiActivityDirectory = (sessionID: string): string => {
     return sessionMetadata.getDirectory(sessionID) ?? ctx.directory;
   };
@@ -245,9 +249,31 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   // child→parent links; roots are never stored per-activity, so a
   // late-learned link re-roots everything consistently. Process identity
   // cannot scope this because v2 daemons are shared across windows.
-  const markTuiAgentActive = (sessionID: string, agentName: string): void => {
+  const markTuiAgentActive = (
+    sessionID: string,
+    agentName: string,
+    status?: 'busy' | 'retry',
+  ): void => {
     const directory = tuiActivityDirectory(sessionID);
-    recordTuiAgentActivity({ sessionID, agentName, active: true }, directory);
+    // Alias from an already-registered board record (launch may have
+    // arrived before or after busy; both orders converge here or via the
+    // coordinator's identity listener).
+    const alias = backgroundJobBoard?.get(sessionID)?.alias;
+    const model = sessionMetadata.getModel(sessionID);
+    const details = {
+      ...(alias ? { alias } : {}),
+      ...(model ? { model } : {}),
+      ...(status ? { status } : {}),
+    };
+    recordTuiAgentActivity(
+      {
+        sessionID,
+        agentName,
+        active: true,
+        ...(Object.keys(details).length > 0 ? { details } : {}),
+      },
+      directory,
+    );
     ownedTuiActivitySessions.set(sessionID, directory);
     void hydrateTuiSessionParent(sessionID, directory);
   };
@@ -507,6 +533,28 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     const backgroundJobCoordinator = new BackgroundJobCoordinator(
       backgroundJobBoard,
     );
+    // Project launch identity (alias↔session) into TUI state so the
+    // clickable sidebar can label active subagent sessions. Best-effort:
+    // a failed tui-state write must never fail a launch.
+    backgroundJobCoordinator.addLaunchIdentityListener((event) => {
+      const directory = tuiActivityDirectory(event.taskID);
+      if (event.kind === 'registered') {
+        if (event.parentSessionID && event.parentSessionID !== event.taskID) {
+          recordTuiSessionParent(
+            event.taskID,
+            event.parentSessionID,
+            directory,
+          );
+        }
+        updateTuiSessionDetails(
+          event.taskID,
+          { alias: event.alias },
+          directory,
+        );
+      } else {
+        clearTuiSessionAlias(event.taskID, directory);
+      }
+    });
     backgroundJobSupervisor = new BackgroundJobSupervisor({
       backgroundJobStore: backgroundJobCoordinator,
       wallClockTimeoutMs: runtime.backgroundJobs.wallClockTimeoutMs,
@@ -1289,9 +1337,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           const agentName = sessionMetadata.getAgent(eventSessionID);
           if (agentName) {
             pendingTuiBusySessions.delete(eventSessionID);
-            markTuiAgentActive(eventSessionID, agentName);
+            markTuiAgentActive(eventSessionID, agentName, statusType);
           } else {
-            pendingTuiBusySessions.add(eventSessionID);
+            pendingTuiBusySessions.set(eventSessionID, statusType);
           }
         } else if (
           event.type === 'session.idle' ||
@@ -1333,6 +1381,20 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           if (!internalAdmission) {
             sessionMetadata.setModel(info.sessionID, model);
           }
+          // Per-session sidebar detail: the model actually observed for
+          // this session (two same-agent sessions may differ). Published
+          // regardless of admission origin: the executing model is a
+          // runtime fact, not selection tracking.
+          updateTuiSessionDetails(
+            info.sessionID,
+            { model },
+            tuiActivityDirectory(info.sessionID),
+          );
+          // Managed background-task sessions are identified by their session
+          // ID. If the model serving one changed (fallback re-prompt, runtime
+          // switch), migrate the admission accounting so provider/model caps
+          // keep tracking the model actually in use. No-op for other
+          // sessions and idempotent when the model is unchanged.
           backgroundTaskConcurrency.migrateTask(info.sessionID, model);
         }
         if (typeof info?.agent === 'string' && providerID && modelID) {
@@ -1634,8 +1696,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           pendingTuiBusySessions.has(input.sessionID) ||
           ownedTuiActivitySessions.has(input.sessionID)
         ) {
+          const pendingStatus = pendingTuiBusySessions.get(input.sessionID);
           pendingTuiBusySessions.delete(input.sessionID);
-          markTuiAgentActive(input.sessionID, agent);
+          markTuiAgentActive(input.sessionID, agent, pendingStatus);
         }
         companionManager.onSessionStatus({
           sessionId: input.sessionID,
@@ -1660,6 +1723,14 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         if (!internalAdmission) {
           sessionMetadata.setModel(input.sessionID, model);
         }
+        // v2 synthesizes message.updated without provider/model; publish
+        // the observed model here so sessionDetails is not empty for the
+        // entire run. Only-if-active: idle sessions are not resurrected.
+        updateTuiSessionDetails(
+          input.sessionID,
+          { model },
+          tuiActivityDirectory(input.sessionID),
+        );
         backgroundTaskConcurrency.migrateTask(input.sessionID, model);
       }
       taskSessionManagerHook.observeChatMessage(input, output);

+ 175 - 3
src/tui-state.test.ts

@@ -4,13 +4,16 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import {
   clearTuiAgentActivities,
+  clearTuiSessionAlias,
   getTuiStatePath,
   readTuiSnapshot,
+  readTuiSnapshotAsync,
   recordTuiAgentActivity,
   recordTuiAgentModel,
   recordTuiAgentModels,
   recordTuiSessionParent,
   resolveTuiSessionRoot,
+  updateTuiSessionDetails,
 } from './tui-state';
 
 let previousXdgDataHome: string | undefined;
@@ -40,6 +43,23 @@ function recordLuna(): void {
 }
 
 describe('tui-state persistence', () => {
+  test('readTuiSnapshotAsync caches by stat and invalidates on write', async () => {
+    recordTuiAgentModel(LUNA, tempDir);
+    const first = await readTuiSnapshotAsync(tempDir);
+    expect(first.agentModels.explorer).toBe(LUNA.model);
+
+    // Same stat (dev:ino:mtimeMs:size) → cached snapshot object identity.
+    const second = await readTuiSnapshotAsync(tempDir);
+    expect(second).toBe(first);
+
+    // A real write publishes via tmp+rename (new inode) → cache miss and
+    // the updated content is observed by the next poll.
+    recordTuiAgentModel(GPT, tempDir);
+    const third = await readTuiSnapshotAsync(tempDir);
+    expect(third).not.toBe(first);
+    expect(third.agentModels.explorer).toBe(GPT.model);
+  });
+
   test('persists enabled agent models', () => {
     recordTuiAgentModels(
       {
@@ -353,20 +373,26 @@ describe('tui-state persistence', () => {
     }
   });
 
-  test('clears persisted activity while preserving model state', () => {
+  test('startup sweep keeps live own-PID activity and still drops dead residue', () => {
     recordTuiAgentModels(
       { agentModels: { explorer: 'openai/gpt-5.6-luna' } },
       tempDir,
     );
     recordTuiAgentActivity(
-      { sessionID: 'explorer-a', agentName: 'explorer', active: true },
+      {
+        sessionID: 'explorer-a',
+        agentName: 'explorer',
+        active: true,
+        details: { alias: 'exp-1' },
+      },
       tempDir,
     );
 
     clearTuiAgentActivities(tempDir);
 
     const snapshot = readTuiSnapshot(tempDir);
-    expect(snapshot.activeSessions).toEqual({});
+    expect(snapshot.activeSessions).toEqual({ 'explorer-a': 'explorer' });
+    expect(snapshot.sessionDetails['explorer-a']?.alias).toBe('exp-1');
     expect(snapshot.agentModels).toEqual({
       explorer: 'openai/gpt-5.6-luna',
     });
@@ -596,3 +622,149 @@ describe('tui-state persistence', () => {
     ).toEqual([]);
   });
 });
+
+describe('sessionDetails (clickable sidebar projection)', () => {
+  test('activation with details round-trips alias/model/status', () => {
+    recordTuiAgentActivity(
+      {
+        sessionID: 'ora-1-ses',
+        agentName: 'oracle',
+        active: true,
+        details: { alias: 'ora-1', model: 'openai/gpt-5.6', status: 'busy' },
+      },
+      tempDir,
+    );
+    expect(readTuiSnapshot(tempDir).sessionDetails).toEqual({
+      'ora-1-ses': { alias: 'ora-1', model: 'openai/gpt-5.6', status: 'busy' },
+    });
+  });
+
+  test('deactivation removes details together with the activity', () => {
+    recordTuiAgentActivity(
+      {
+        sessionID: 'ora-1-ses',
+        agentName: 'oracle',
+        active: true,
+        details: { alias: 'ora-1' },
+      },
+      tempDir,
+    );
+    recordTuiAgentActivity({ sessionID: 'ora-1-ses', active: false }, tempDir);
+    const snapshot = readTuiSnapshot(tempDir);
+    expect(snapshot.activeSessions).toEqual({});
+    expect(snapshot.sessionDetails).toEqual({});
+  });
+
+  test('detail updates only apply to active sessions (no resurrection)', () => {
+    // Idle first: a late detail update must not recreate the entry.
+    recordTuiAgentActivity({ sessionID: 'gone', active: false }, tempDir);
+    updateTuiSessionDetails('gone', { alias: 'ora-9' }, tempDir);
+    expect(readTuiSnapshot(tempDir).sessionDetails).toEqual({});
+
+    // Active session: updates merge into existing details.
+    recordTuiAgentActivity(
+      { sessionID: 'live', agentName: 'oracle', active: true },
+      tempDir,
+    );
+    updateTuiSessionDetails('live', { alias: 'ora-1' }, tempDir);
+    updateTuiSessionDetails('live', { model: 'openai/gpt-5.6' }, tempDir);
+    expect(readTuiSnapshot(tempDir).sessionDetails).toEqual({
+      live: { alias: 'ora-1', model: 'openai/gpt-5.6' },
+    });
+  });
+
+  test('clearTuiSessionAlias retracts only the alias, keeping model/status', () => {
+    recordTuiAgentActivity(
+      {
+        sessionID: 'live',
+        agentName: 'oracle',
+        active: true,
+        details: { alias: 'ora-1', model: 'openai/gpt-5.6', status: 'busy' },
+      },
+      tempDir,
+    );
+    clearTuiSessionAlias('live', tempDir);
+    expect(readTuiSnapshot(tempDir).sessionDetails).toEqual({
+      live: { model: 'openai/gpt-5.6', status: 'busy' },
+    });
+    // Alias-less entry with no other fields is removed entirely.
+    recordTuiAgentActivity(
+      {
+        sessionID: 'bare',
+        agentName: 'fixer',
+        active: true,
+        details: { alias: 'fix-1' },
+      },
+      tempDir,
+    );
+    clearTuiSessionAlias('bare', tempDir);
+    expect(readTuiSnapshot(tempDir).sessionDetails.bare).toBeUndefined();
+  });
+
+  test('orphaned details are swept by clearTuiAgentActivities', () => {
+    // Simulate a live foreign recorder (PID 1 is always running and is
+    // never this process) plus an orphaned details entry with no activity.
+    const statePath = getTuiStatePath(tempDir);
+    fs.mkdirSync(path.dirname(statePath), { recursive: true });
+    fs.writeFileSync(
+      statePath,
+      `${JSON.stringify({
+        version: 1,
+        updatedAt: 1,
+        activeSessions: { 'live-foreign': 'oracle' },
+        activityPids: { 'live-foreign': 1 },
+        sessionDetails: {
+          'live-foreign': { alias: 'ora-7' },
+          orphan: { alias: 'ora-8' },
+        },
+      })}\n`,
+    );
+
+    clearTuiAgentActivities(tempDir);
+    const after = readTuiSnapshot(tempDir);
+    expect(after.activeSessions).toEqual({ 'live-foreign': 'oracle' });
+    expect(after.sessionDetails).toEqual({
+      'live-foreign': { alias: 'ora-7' },
+    });
+  });
+
+  test('parser drops malformed detail entries without losing valid ones', () => {
+    const statePath = getTuiStatePath(tempDir);
+    fs.mkdirSync(path.dirname(statePath), { recursive: true });
+    fs.writeFileSync(
+      statePath,
+      `${JSON.stringify({
+        version: 1,
+        updatedAt: 1,
+        sessionDetails: {
+          good: { alias: 'ora-1', status: 'busy' },
+          badStatus: { alias: 'ora-2', status: 'weird' },
+          nonObject: 'nope',
+          nullEntry: null,
+          empty: {},
+        },
+      })}\n`,
+    );
+    const parsed = readTuiSnapshot(tempDir);
+    expect(parsed.sessionDetails).toEqual({
+      good: { alias: 'ora-1', status: 'busy' },
+      badStatus: { alias: 'ora-2' },
+    });
+  });
+
+  test('legacy snapshot without sessionDetails parses with an empty section', () => {
+    const statePath = getTuiStatePath(tempDir);
+    fs.mkdirSync(path.dirname(statePath), { recursive: true });
+    fs.writeFileSync(
+      statePath,
+      `${JSON.stringify({
+        version: 1,
+        updatedAt: 1,
+        agentModels: { oracle: 'openai/gpt-5.6' },
+      })}\n`,
+    );
+    const parsed = readTuiSnapshot(tempDir);
+    expect(parsed.sessionDetails).toEqual({});
+    expect(parsed.agentModels).toEqual({ oracle: 'openai/gpt-5.6' });
+  });
+});

+ 139 - 11
src/tui-state.ts

@@ -3,6 +3,19 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 
+/**
+ * Per-session metadata projection for the clickable sidebar. Entries only
+ * exist for sessions present in `activeSessions`; they never activate a
+ * session by themselves. The key is always the full sessionID — never an
+ * alias — and the parent link lives exclusively in `sessionParents`.
+ */
+export interface TuiSessionDetails {
+  alias?: string;
+  /** providerID/modelID observed for this specific session. */
+  model?: string;
+  status?: 'busy' | 'retry';
+}
+
 export interface TuiSnapshot {
   version: 1;
   updatedAt: number;
@@ -23,6 +36,8 @@ export interface TuiSnapshot {
    * cannot scope the sidebar; the session tree can.
    */
   sessionParents: Record<string, string>;
+  /** Per-active-session details (alias/model/status) for the sidebar. */
+  sessionDetails: Record<string, TuiSessionDetails>;
 }
 
 const STATE_DIR = 'oh-my-opencode-slim';
@@ -71,6 +86,7 @@ function emptySnapshot(): TuiSnapshot {
     activeSessions: {},
     activityPids: {},
     sessionParents: {},
+    sessionDetails: {},
   };
 }
 
@@ -92,6 +108,25 @@ function parsePidRecord(value: unknown): Record<string, number> {
   return out;
 }
 
+function parseSessionDetails(
+  value: unknown,
+): Record<string, TuiSessionDetails> {
+  if (value === null || typeof value !== 'object') return {};
+  const out: Record<string, TuiSessionDetails> = {};
+  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
+    if (entry === null || typeof entry !== 'object') continue;
+    const rec = entry as { alias?: unknown; model?: unknown; status?: unknown };
+    const details: TuiSessionDetails = {};
+    if (typeof rec.alias === 'string') details.alias = rec.alias;
+    if (typeof rec.model === 'string') details.model = rec.model;
+    if (rec.status === 'busy' || rec.status === 'retry') {
+      details.status = rec.status;
+    }
+    if (Object.keys(details).length > 0) out[key] = details;
+  }
+  return out;
+}
+
 function parseSnapshot(value: string): TuiSnapshot {
   const parsed = JSON.parse(value) as Partial<TuiSnapshot> | undefined;
   if (parsed?.version !== 1) return emptySnapshot();
@@ -105,6 +140,7 @@ function parseSnapshot(value: string): TuiSnapshot {
     activeSessions: parsed.activeSessions ?? {},
     activityPids: parsePidRecord(parsed.activityPids),
     sessionParents: parseStringRecord(parsed.sessionParents),
+    sessionDetails: parseSessionDetails(parsed.sessionDetails),
   };
 }
 
@@ -129,14 +165,36 @@ function readTuiSnapshotStrict(statePath: string): TuiSnapshot | null {
   }
 }
 
+// Stat-based read cache for the polling TUI refresh (1/s per window).
+// Writers publish via tmp+rename, so a changed write always has a new
+// dev/ino — mtimeMs+size alone would also be sufficient, but inode
+// identity rules out same-mtime rewrites. Cache misses fall through to
+// a normal read; any stat/read failure bypasses the cache entirely.
+const asyncSnapshotCache = new Map<
+  string,
+  { stat: string; snapshot: TuiSnapshot }
+>();
+
+function snapshotStatKey(stat: fs.Stats): string {
+  return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
+}
+
 export async function readTuiSnapshotAsync(
   projectDir: string,
 ): Promise<TuiSnapshot> {
+  const statePath = getTuiStatePath(projectDir);
   try {
-    return parseSnapshot(
-      await fs.promises.readFile(getTuiStatePath(projectDir), 'utf8'),
+    const stat = await fs.promises.stat(statePath);
+    const statKey = snapshotStatKey(stat);
+    const cached = asyncSnapshotCache.get(statePath);
+    if (cached && cached.stat === statKey) return cached.snapshot;
+    const snapshot = parseSnapshot(
+      await fs.promises.readFile(statePath, 'utf8'),
     );
+    asyncSnapshotCache.set(statePath, { stat: statKey, snapshot });
+    return snapshot;
   } catch {
+    asyncSnapshotCache.delete(statePath);
     return emptySnapshot();
   }
 }
@@ -288,16 +346,23 @@ function cloneSnapshot(snapshot: TuiSnapshot): TuiSnapshot {
     activeSessions: { ...snapshot.activeSessions },
     activityPids: { ...snapshot.activityPids },
     sessionParents: { ...snapshot.sessionParents },
+    sessionDetails: Object.fromEntries(
+      Object.entries(snapshot.sessionDetails).map(([key, details]) => [
+        key,
+        { ...details },
+      ]),
+    ),
   };
 }
 
-function snapshotSectionsEqual(a: TuiSnapshot, b: TuiSnapshot): boolean {
+export function snapshotSectionsEqual(a: TuiSnapshot, b: TuiSnapshot): boolean {
   return (
     JSON.stringify(a.agentModels) === JSON.stringify(b.agentModels) &&
     JSON.stringify(a.agentVariants) === JSON.stringify(b.agentVariants) &&
     JSON.stringify(a.activeSessions) === JSON.stringify(b.activeSessions) &&
     JSON.stringify(a.activityPids) === JSON.stringify(b.activityPids) &&
-    JSON.stringify(a.sessionParents) === JSON.stringify(b.sessionParents)
+    JSON.stringify(a.sessionParents) === JSON.stringify(b.sessionParents) &&
+    JSON.stringify(a.sessionDetails) === JSON.stringify(b.sessionDetails)
   );
 }
 
@@ -408,7 +473,12 @@ export function recordTuiAgentModel(
 
 export function recordTuiAgentActivity(
   input:
-    | { sessionID: string; agentName: string; active: true }
+    | {
+        sessionID: string;
+        agentName: string;
+        active: true;
+        details?: TuiSessionDetails;
+      }
     | { sessionID: string; active: false },
   projectDir: string,
 ): void {
@@ -416,25 +486,83 @@ export function recordTuiAgentActivity(
     if (input.active) {
       snapshot.activeSessions[input.sessionID] = input.agentName;
       snapshot.activityPids[input.sessionID] = process.pid;
+      if (input.details && Object.keys(input.details).length > 0) {
+        const current = snapshot.sessionDetails[input.sessionID] ?? {};
+        snapshot.sessionDetails[input.sessionID] = {
+          ...current,
+          ...input.details,
+        };
+      }
     } else {
       delete snapshot.activeSessions[input.sessionID];
       delete snapshot.activityPids[input.sessionID];
+      delete snapshot.sessionDetails[input.sessionID];
     }
   });
 }
 
-// Startup cleanup: drop crash residue (dead recorder pid), legacy entries
-// written before ownership existed, and entries from this very process
-// (fresh start owns nothing yet). Keep live activities owned by other
-// windows sharing the project directory (#1147); their sidebar visibility
-// is scoped by session tree at render time, not by process.
+/**
+ * Update per-session sidebar details (alias/model/status) for an ACTIVE
+ * session only. A late detail update after idle must never resurrect an
+ * activity entry — the whole update is dropped when the session is gone
+ * from `activeSessions` at commit time (under the same lock).
+ */
+export function updateTuiSessionDetails(
+  sessionID: string,
+  details: TuiSessionDetails,
+  projectDir: string,
+): void {
+  updateSnapshot(projectDir, (snapshot) => {
+    if (snapshot.activeSessions[sessionID] === undefined) return;
+    const current = snapshot.sessionDetails[sessionID] ?? {};
+    snapshot.sessionDetails[sessionID] = { ...current, ...details };
+  });
+}
+
+/**
+ * Retract only the alias of an active session (e.g. its board record was
+ * dropped). Model/status survive; the session stays visible in the
+ * sidebar under its abbreviated sessionID until it goes idle.
+ */
+export function clearTuiSessionAlias(
+  sessionID: string,
+  projectDir: string,
+): void {
+  updateSnapshot(projectDir, (snapshot) => {
+    if (snapshot.activeSessions[sessionID] === undefined) return;
+    const current = snapshot.sessionDetails[sessionID];
+    if (current === undefined || current.alias === undefined) return;
+    const next: TuiSessionDetails = { ...current };
+    delete next.alias;
+    if (Object.keys(next).length === 0) {
+      delete snapshot.sessionDetails[sessionID];
+    } else {
+      snapshot.sessionDetails[sessionID] = next;
+    }
+  });
+}
+
+// Startup cleanup: drop crash residue (dead recorder pid) and legacy
+// entries written before ownership existed. Keep live activities even
+// when they belong to this PID — a second plugin init in the same
+// process must not wipe the first instance's in-flight sessions.
+// Per-instance cleanup is `recordTuiAgentActivity(active: false)` on
+// dispose, not this sweep. Sidebar visibility is still scoped by
+// session tree at render time (#1147).
 export function clearTuiAgentActivities(projectDir: string): void {
   updateSnapshot(projectDir, (snapshot) => {
     for (const sessionID of Object.keys(snapshot.activeSessions)) {
       const pid = snapshot.activityPids[sessionID];
-      if (pid === undefined || pid === process.pid || !isProcessRunning(pid)) {
+      if (pid === undefined || !isProcessRunning(pid)) {
         delete snapshot.activeSessions[sessionID];
         delete snapshot.activityPids[sessionID];
+        delete snapshot.sessionDetails[sessionID];
+      }
+    }
+    // Consistency sweep: details never outlive their activity entry.
+    for (const sessionID of Object.keys(snapshot.sessionDetails)) {
+      if (snapshot.activeSessions[sessionID] === undefined) {
+        delete snapshot.sessionDetails[sessionID];
       }
     }
   });

+ 504 - 0
src/tui.test.ts

@@ -8,15 +8,22 @@ import { readTmuxPane } from './multiplexer/tmux-pane-registry';
 import {
   type ActiveTmuxPaneRegistration,
   applyRemoteAgentModels,
+  compareAliasNumeric,
   createSerializedRefresh,
+  createSidebarInteraction,
   fetchRemoteAgentModels,
   getActiveSidebarAgentNames,
   getContrastForeground,
   getSidebarActivityIndicator,
   getSidebarAgentNames,
+  getSidebarAgentTargets,
   isRefreshCurrent,
+  makeRouteNavigator,
   readCompactSidebar,
   readConfigInvalid,
+  resolveHoverBackground,
+  selectionGuard,
+  shortSessionID,
   splitSidebarModelId,
   syncTmuxPaneRegistration,
   default as tuiPlugin,
@@ -24,6 +31,7 @@ import {
 import {
   recordTuiAgentActivity,
   recordTuiAgentModels,
+  recordTuiSessionParent,
   type TuiSnapshot,
 } from './tui-state';
 
@@ -38,6 +46,7 @@ function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
     activeSessions: {},
     activityPids: {},
     sessionParents: {},
+    sessionDetails: {},
     ...overrides,
   };
 }
@@ -841,3 +850,498 @@ describe('dual-contract plugin module', () => {
     }
   });
 });
+
+describe('clickable sidebar sessions', () => {
+  test('getSidebarAgentTargets groups active subagents by agent with alias/model/status', () => {
+    const snapshot = createSnapshot({
+      activeSessions: {
+        'ora-1-ses': 'oracle',
+        'ora-2-ses': 'oracle',
+        'fix-ses': 'fixer',
+        'root-ses': 'oracle',
+      },
+      sessionParents: {
+        'ora-1-ses': 'conv-1',
+        'ora-2-ses': 'conv-1',
+        'fix-ses': 'conv-1',
+        // root-ses has no parent: a root session running oracle directly
+        // must not be offered as a subagent destination.
+      },
+      sessionDetails: {
+        'ora-1-ses': {
+          alias: 'ora-1',
+          model: 'openai/gpt-5.6',
+          status: 'busy',
+        },
+        'ora-2-ses': { alias: 'ora-2', status: 'retry' },
+      },
+    });
+
+    const targets = getSidebarAgentTargets(snapshot, 'conv-1');
+    expect(targets.map((t) => t.agentName).sort()).toEqual(['fixer', 'oracle']);
+    const oracle = targets.find((t) => t.agentName === 'oracle');
+    expect(oracle?.sessions.map((s) => s.sessionID)).toEqual([
+      'ora-1-ses',
+      'ora-2-ses',
+    ]);
+    expect(oracle?.sessions[0].alias).toBe('ora-1');
+    expect(oracle?.sessions[0].model).toBe('openai/gpt-5.6');
+    expect(oracle?.sessions[1].status).toBe('retry');
+
+    // Other conversation: no targets even though sessions are active.
+    expect(getSidebarAgentTargets(snapshot, 'conv-2')).toEqual([]);
+    // Home route: no scoping possible, no navigation offered.
+    expect(getSidebarAgentTargets(snapshot, undefined)).toEqual([]);
+  });
+
+  test('alias ordering is numeric (ora-2 before ora-10), unaliased last', () => {
+    const snapshot = createSnapshot({
+      activeSessions: {
+        a: 'oracle',
+        b: 'oracle',
+        c: 'oracle',
+      },
+      sessionParents: { a: 'conv', b: 'conv', c: 'conv' },
+      sessionDetails: {
+        a: { alias: 'ora-10' },
+        b: { alias: 'ora-2' },
+        // c has no alias (board record dropped): sorts last by sessionID.
+      },
+    });
+    const [group] = getSidebarAgentTargets(snapshot, 'conv');
+    expect(group.sessions.map((s) => s.sessionID)).toEqual(['b', 'a', 'c']);
+    expect(compareAliasNumeric('ora-2', 'ora-10')).toBeLessThan(0);
+  });
+
+  test('expansion state: toggle, independent agents, reset on scope change', () => {
+    const interaction = createSidebarInteraction((id) => id);
+    expect(interaction.expandedAgents().size).toBe(0);
+    interaction.toggleAgent('oracle');
+    interaction.toggleAgent('fixer');
+    expect([...interaction.expandedAgents()].sort()).toEqual([
+      'fixer',
+      'oracle',
+    ]);
+    // Toggle off one leaves the other.
+    interaction.toggleAgent('oracle');
+    expect([...interaction.expandedAgents()]).toEqual(['fixer']);
+    // Same conversation root: navigating parent→child must not reset.
+    interaction.syncScope('/p', 'conv-1');
+    expect([...interaction.expandedAgents()]).toEqual(['fixer']);
+    interaction.syncScope('/p', 'conv-1');
+    expect([...interaction.expandedAgents()]).toEqual(['fixer']);
+    // Root change within the same project resets expansion.
+    interaction.syncScope('/p', 'conv-2');
+    expect(interaction.expandedAgents().size).toBe(0);
+  });
+
+  test('makeRouteNavigator wraps v1 (name,params) and v2 (route object) shapes', () => {
+    const v1Calls: unknown[][] = [];
+    const v1Owner = {
+      navigate(...args: unknown[]) {
+        v1Calls.push([this === v1Owner, ...args]);
+      },
+    };
+    const v1 = makeRouteNavigator(v1Owner, 'navigate', false);
+    v1?.('ses-1');
+    expect(v1Calls).toEqual([[true, 'session', { sessionID: 'ses-1' }]]);
+
+    const v2Calls: unknown[] = [];
+    const v2Owner = {
+      navigate(route: unknown) {
+        v2Calls.push({ self: this === v2Owner, route });
+      },
+    };
+    const v2 = makeRouteNavigator(v2Owner, 'navigate', true);
+    v2?.('ses-2');
+    expect(v2Calls).toEqual([
+      { self: true, route: { type: 'session', sessionID: 'ses-2' } },
+    ]);
+
+    expect(makeRouteNavigator(undefined, 'navigate', false)).toBeUndefined();
+    expect(makeRouteNavigator({}, 'navigate', false)).toBeUndefined();
+    const throwingOwner = {
+      navigate() {
+        throw new Error('host');
+      },
+    };
+    const throwing = makeRouteNavigator(throwingOwner, 'navigate', false);
+    expect(() => throwing?.('ses-3')).not.toThrow();
+  });
+
+  test('resolveHoverBackground prefers theme.hover then backgroundElement', () => {
+    expect(
+      resolveHoverBackground({
+        hover: '#333333',
+        backgroundElement: '#222222',
+        background: '#111111',
+        text: '#ffffff',
+      }),
+    ).toBe('#333333');
+    expect(
+      resolveHoverBackground({
+        backgroundElement: '#222222',
+        background: '#111111',
+        text: '#ffffff',
+      }),
+    ).toBe('#222222');
+  });
+
+  test('expansion state is local to each sidebar instance', () => {
+    const first = createSidebarInteraction((id) => id);
+    const second = createSidebarInteraction((id) => id);
+    first.toggleAgent('oracle');
+    expect([...first.expandedAgents()]).toEqual(['oracle']);
+    expect(second.expandedAgents().size).toBe(0);
+  });
+
+  test('selectionGuard only blocks non-empty selected text', () => {
+    let selected = '';
+    const guard = selectionGuard({
+      getSelection: () => ({
+        getSelectedText: () => selected,
+      }),
+    });
+
+    expect(guard()).toBe(false);
+    selected = 'selected text';
+    expect(guard()).toBe(true);
+  });
+
+  test('duplicate aliases in the same group get a short id suffix', () => {
+    const snapshot = createSnapshot({
+      activeSessions: {
+        ses_aaaa1111bbbb2222: 'oracle',
+        ses_cccc3333dddd4444: 'oracle',
+      },
+      sessionParents: {
+        ses_aaaa1111bbbb2222: 'conv',
+        ses_cccc3333dddd4444: 'conv',
+      },
+      sessionDetails: {
+        ses_aaaa1111bbbb2222: { alias: 'ora-1' },
+        ses_cccc3333dddd4444: { alias: 'ora-1' },
+      },
+    });
+    const [group] = getSidebarAgentTargets(snapshot, 'conv');
+    expect(group.sessions.map((s) => s.alias)).toEqual([
+      'ora-1 bbbb2222',
+      'ora-1 dddd4444',
+    ]);
+  });
+
+  test('shortSessionID keeps ids readable for unaliased rows', () => {
+    expect(shortSessionID('ses_1234567890abcdef')).toBe('90abcdef');
+    expect(shortSessionID('short')).toBe('short');
+  });
+
+  test('mouse contract: onMouseUp via element/setProp fires on click, onClick does not', async () => {
+    // @opentui 0.5.8: Renderable exposes setters for onMouseUp/Over/Out but
+    // NOT for onClick — assigning onClick via setProp is a silent no-op.
+    // Our sidebar helpers (element/setProp) must use the mouse setters, and
+    // clicks must bubble from the text child to the parent box handler.
+    const { createElement, insert, setProp } = await import('@opentui/solid');
+
+    const events: string[] = [];
+    const setup = await testRender(
+      () => {
+        const root = createElement('box');
+        setProp(root, 'width', '100%');
+        setProp(root, 'height', 3);
+        setProp(root, 'onMouseUp', () => events.push('up'));
+        setProp(root, 'onMouseOver', () => events.push('over'));
+        setProp(root, 'onClick', () => events.push('click-should-not-fire'));
+        const label = createElement('text');
+        insert(label, 'clickme');
+        insert(root, label);
+        return root as never;
+      },
+      { width: 20, height: 6 },
+    );
+
+    try {
+      await setup.renderOnce();
+      const lines = setup.captureCharFrame().split('\n');
+      const row = lines.findIndex((l) => l.includes('clickme'));
+      expect(row).toBeGreaterThan(-1);
+      const col = lines[row].indexOf('clickme');
+
+      await setup.mockMouse.moveTo(col + 2, row);
+      await setup.mockMouse.click(col + 2, row);
+
+      expect(events).toContain('up');
+      expect(events).toContain('over');
+      expect(events).not.toContain('click-should-not-fire');
+    } finally {
+      setup.renderer.destroy();
+    }
+  });
+
+  async function mountClickableSidebar(opts: {
+    projectDir: string;
+    sessionID: string;
+    navigate?: (name: string, params?: Record<string, unknown>) => void;
+  }) {
+    const disposers: Array<() => void> = [];
+    let slotPlugin: { slots: { sidebar_content: () => unknown } } | undefined;
+    await tuiPlugin.tui(
+      {
+        state: { path: { directory: opts.projectDir } },
+        route: {
+          current: { name: 'session', params: { sessionID: opts.sessionID } },
+          navigate: opts.navigate,
+        },
+        lifecycle: {
+          onDispose: (callback: () => void) => {
+            disposers.push(callback);
+            return () => {};
+          },
+        },
+        renderer: { requestRender: () => {} },
+        slots: {
+          register: (plugin: typeof slotPlugin) => {
+            slotPlugin = plugin;
+            return 'click-slot';
+          },
+        },
+        theme: {
+          current: {
+            accent: '#22c55e',
+            background: '#111111',
+            backgroundElement: '#222222',
+            borderActive: '#555555',
+            success: '#00ff00',
+            text: '#ffffff',
+            textMuted: '#aaaaaa',
+            warning: '#ffcc00',
+          },
+        },
+      } as Parameters<typeof tuiPlugin.tui>[0],
+      {},
+      { version: 'test' } as Parameters<typeof tuiPlugin.tui>[2],
+    );
+    return { slotPlugin, disposers };
+  }
+
+  function withIsolatedDataHome(root: string): () => void {
+    const originalDataHome = process.env.XDG_DATA_HOME;
+    process.env.XDG_DATA_HOME = path.join(root, 'data');
+    return () => {
+      if (originalDataHome === undefined) delete process.env.XDG_DATA_HOME;
+      else process.env.XDG_DATA_HOME = originalDataHome;
+    };
+  }
+
+  test('mounted sidebar: 1 session navigates', async () => {
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-click-'));
+    const projectDir = path.join(root, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+    const restoreDataHome = withIsolatedDataHome(root);
+    const navigated: unknown[] = [];
+    let setup: Awaited<ReturnType<typeof testRender>> | undefined;
+    let mounted: Awaited<ReturnType<typeof mountClickableSidebar>> | undefined;
+
+    try {
+      recordTuiAgentModels(
+        { agentModels: { oracle: 'openai/gpt-5.6' } },
+        projectDir,
+      );
+      recordTuiSessionParent('ora-only', 'conv-1', projectDir);
+      recordTuiAgentActivity(
+        {
+          sessionID: 'ora-only',
+          agentName: 'oracle',
+          active: true,
+          details: { alias: 'ora-1', status: 'busy' },
+        },
+        projectDir,
+      );
+
+      mounted = await mountClickableSidebar({
+        projectDir,
+        sessionID: 'conv-1',
+        navigate: (...args) => {
+          navigated.push(args);
+        },
+      });
+      setup = await testRender(
+        () => mounted?.slotPlugin?.slots.sidebar_content() as never,
+        { width: 52, height: 16 },
+      );
+      await setup.renderOnce();
+
+      const lines = setup.captureCharFrame().split('\n');
+      const oracleRow = lines.findIndex((l) => l.includes('oracle'));
+      expect(oracleRow).toBeGreaterThan(-1);
+      const col = Math.max(lines[oracleRow].indexOf('oracle'), 0);
+      await setup.mockMouse.click(col + 2, oracleRow);
+      expect(navigated).toEqual([['session', { sessionID: 'ora-only' }]]);
+    } finally {
+      setup?.renderer.destroy();
+      for (const dispose of mounted?.disposers ?? []) dispose();
+      restoreDataHome();
+      fs.rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('mounted sidebar: N sessions expand on first click, child click navigates', async () => {
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-click-n-'));
+    const projectDir = path.join(root, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+    const restoreDataHome = withIsolatedDataHome(root);
+    const navigated: unknown[] = [];
+    let setup: Awaited<ReturnType<typeof testRender>> | undefined;
+    let mounted: Awaited<ReturnType<typeof mountClickableSidebar>> | undefined;
+
+    try {
+      recordTuiAgentModels(
+        { agentModels: { oracle: 'openai/gpt-5.6' } },
+        projectDir,
+      );
+      recordTuiSessionParent('ora-a', 'conv-1', projectDir);
+      recordTuiSessionParent('ora-b', 'conv-1', projectDir);
+      recordTuiAgentActivity(
+        {
+          sessionID: 'ora-a',
+          agentName: 'oracle',
+          active: true,
+          details: {
+            alias: 'ora-1',
+            model: 'openai/gpt-6-astra-xhigh',
+            status: 'busy',
+          },
+        },
+        projectDir,
+      );
+      recordTuiAgentActivity(
+        {
+          sessionID: 'ora-b',
+          agentName: 'oracle',
+          active: true,
+          details: {
+            alias: 'ora-2',
+            model: 'anthropic/claude-opus-long-context',
+            status: 'retry',
+          },
+        },
+        projectDir,
+      );
+
+      mounted = await mountClickableSidebar({
+        projectDir,
+        sessionID: 'conv-1',
+        navigate: (...args) => {
+          navigated.push(args);
+        },
+      });
+      setup = await testRender(
+        () => mounted?.slotPlugin?.slots.sidebar_content() as never,
+        { width: 80, height: 18 },
+      );
+      await setup.renderOnce();
+
+      let lines = setup.captureCharFrame().split('\n');
+      const oracleRow = lines.findIndex((l) => l.includes('oracle'));
+      expect(oracleRow).toBeGreaterThan(-1);
+      const col = Math.max(lines[oracleRow].indexOf('oracle'), 0);
+      await setup.mockMouse.click(col + 2, oracleRow);
+      expect(navigated).toEqual([]);
+
+      await setup.renderOnce();
+      lines = setup.captureCharFrame().split('\n');
+      const childRow = lines.findIndex((l) => l.includes('ora-1'));
+      expect(childRow).toBeGreaterThan(-1);
+      const firstChildLine = lines[childRow];
+      const secondChildRow = lines.findIndex(
+        (line, index) => index > childRow && line.includes('ora-2'),
+      );
+      expect(secondChildRow).toBeGreaterThan(childRow);
+      const secondChildLine = lines[secondChildRow];
+      expect(firstChildLine.indexOf('active')).toBeGreaterThan(
+        firstChildLine.indexOf('gpt-6-astra-xhigh'),
+      );
+      expect(firstChildLine).toMatch(/gpt-6-astra-xhigh\s+active/);
+      expect(secondChildLine).toMatch(/claude-opus-long-context\s+retrying/);
+
+      const beforeHover = setup
+        .captureSpans()
+        .lines.map((line) =>
+          line.spans.map((span) => [
+            span.bg.r,
+            span.bg.g,
+            span.bg.b,
+            span.bg.a,
+          ]),
+        );
+      const childCol = Math.max(lines[childRow].indexOf('ora-1'), 0);
+      await setup.mockMouse.moveTo(childCol + 1, childRow);
+      await setup.renderOnce();
+      const afterHover = setup
+        .captureSpans()
+        .lines.map((line) =>
+          line.spans.map((span) => [
+            span.bg.r,
+            span.bg.g,
+            span.bg.b,
+            span.bg.a,
+          ]),
+        );
+      expect(afterHover[childRow]).not.toEqual(beforeHover[childRow]);
+      expect(afterHover[secondChildRow]).toEqual(beforeHover[secondChildRow]);
+      await setup.mockMouse.click(childCol + 1, childRow);
+      expect(navigated).toEqual([['session', { sessionID: 'ora-a' }]]);
+    } finally {
+      setup?.renderer.destroy();
+      for (const dispose of mounted?.disposers ?? []) dispose();
+      restoreDataHome();
+      fs.rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('mounted sidebar without navigate does not act', async () => {
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-click-none-'));
+    const projectDir = path.join(root, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+    const restoreDataHome = withIsolatedDataHome(root);
+    let setup: Awaited<ReturnType<typeof testRender>> | undefined;
+    let mounted: Awaited<ReturnType<typeof mountClickableSidebar>> | undefined;
+
+    try {
+      recordTuiAgentModels(
+        { agentModels: { oracle: 'openai/gpt-5.6' } },
+        projectDir,
+      );
+      recordTuiSessionParent('ora-only', 'conv-1', projectDir);
+      recordTuiAgentActivity(
+        {
+          sessionID: 'ora-only',
+          agentName: 'oracle',
+          active: true,
+          details: { alias: 'ora-1', status: 'busy' },
+        },
+        projectDir,
+      );
+
+      mounted = await mountClickableSidebar({
+        projectDir,
+        sessionID: 'conv-1',
+      });
+      setup = await testRender(
+        () => mounted?.slotPlugin?.slots.sidebar_content() as never,
+        { width: 52, height: 16 },
+      );
+      await setup.renderOnce();
+      const lines = setup.captureCharFrame().split('\n');
+      const oracleRow = lines.findIndex((l) => l.includes('oracle'));
+      expect(oracleRow).toBeGreaterThan(-1);
+      await setup.mockMouse.click(2, oracleRow);
+      await setup.renderOnce();
+      expect(setup.captureCharFrame()).not.toContain('ora-1');
+    } finally {
+      setup?.renderer.destroy();
+      for (const dispose of mounted?.disposers ?? []) dispose();
+      restoreDataHome();
+      fs.rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 655 - 43
src/tui.ts

@@ -22,6 +22,7 @@ import {
   readTuiSnapshot,
   readTuiSnapshotAsync,
   resolveTuiSnapshotRoot,
+  snapshotSectionsEqual,
   type TuiSnapshot,
 } from './tui-state';
 import { isPluginDisabledByEnv } from './utils/env';
@@ -50,7 +51,14 @@ const ACTIVITY_FRAMES = [
   '⠏',
 ] as const;
 
-type Child = JSX.Element | string | number | null | undefined | false;
+type Child =
+  | JSX.Element
+  | string
+  | number
+  | null
+  | undefined
+  | false
+  | (() => string);
 
 async function readPackageVersion(): Promise<string | undefined> {
   try {
@@ -399,6 +407,186 @@ export function getActiveSidebarAgentNames(
   return names;
 }
 
+/** One clickable sidebar destination: an active subagent session. */
+export interface SidebarSessionTarget {
+  sessionID: string;
+  agentName: string;
+  alias?: string;
+  model?: string;
+  status?: 'busy' | 'retry';
+}
+
+export interface SidebarAgentTargets {
+  agentName: string;
+  sessions: SidebarSessionTarget[];
+}
+
+/**
+ * Group the active subagent sessions of the visible conversation by agent
+ * for the clickable sidebar. Mirrors the scoping of
+ * getActiveSidebarAgentNames (#1147) with two refinements:
+ * - Only sessions with a known parent link are offered as destinations:
+ *   a root session running an agent directly (e.g. a top-level chat with
+ *   agent=oracle) is not a subagent of this conversation.
+ * - Without a visible route session there is no conversation to scope to;
+ *   return no targets rather than exposing cross-conversation navigation.
+ * Stable ordering: by alias (numeric suffix aware, ora-2 < ora-10), then
+ * by sessionID.
+ */
+export function getSidebarAgentTargets(
+  snapshot: TuiSnapshot,
+  visibleRootID?: string,
+): SidebarAgentTargets[] {
+  if (visibleRootID === undefined) return [];
+  const root = resolveTuiSnapshotRoot(snapshot, visibleRootID);
+  const byAgent = new Map<string, SidebarSessionTarget[]>();
+  for (const [sessionID, agentName] of Object.entries(
+    snapshot.activeSessions,
+  )) {
+    const parent = snapshot.sessionParents[sessionID];
+    if (parent === undefined) continue; // not a known subagent
+    if (resolveTuiSnapshotRoot(snapshot, sessionID) !== root) continue;
+    const details = snapshot.sessionDetails[sessionID];
+    const list = byAgent.get(agentName) ?? [];
+    list.push({
+      sessionID,
+      agentName,
+      alias: details?.alias,
+      model: details?.model,
+      status: details?.status,
+    });
+    byAgent.set(agentName, list);
+  }
+  return [...byAgent.entries()].map(([agentName, sessions]) => ({
+    agentName,
+    sessions: disambiguateDuplicateAliases(
+      sessions.sort(compareSidebarTargets),
+    ),
+  }));
+}
+
+/** When two sessions share an alias (nested branches), append a short id. */
+function disambiguateDuplicateAliases(
+  sessions: SidebarSessionTarget[],
+): SidebarSessionTarget[] {
+  const counts = new Map<string, number>();
+  for (const session of sessions) {
+    if (session.alias === undefined) continue;
+    counts.set(session.alias, (counts.get(session.alias) ?? 0) + 1);
+  }
+  return sessions.map((session) => {
+    if (session.alias === undefined) return session;
+    if ((counts.get(session.alias) ?? 0) < 2) return session;
+    return {
+      ...session,
+      alias: `${session.alias} ${shortSessionID(session.sessionID)}`,
+    };
+  });
+}
+
+function compareSidebarTargets(
+  a: SidebarSessionTarget,
+  b: SidebarSessionTarget,
+): number {
+  if (a.alias !== undefined && b.alias !== undefined && a.alias !== b.alias) {
+    return compareAliasNumeric(a.alias, b.alias);
+  }
+  if (a.alias !== undefined && b.alias === undefined) return -1;
+  if (a.alias === undefined && b.alias !== undefined) return 1;
+  return a.sessionID < b.sessionID ? -1 : a.sessionID > b.sessionID ? 1 : 0;
+}
+
+/** Natural sort for alias counters: ora-2 sorts before ora-10. */
+export function compareAliasNumeric(a: string, b: string): number {
+  const ma = /^(.*?)(\d+)$/.exec(a);
+  const mb = /^(.*?)(\d+)$/.exec(b);
+  if (ma && mb && ma[1] === mb[1]) {
+    return Number.parseInt(ma[2], 10) - Number.parseInt(mb[2], 10);
+  }
+  return a < b ? -1 : a > b ? 1 : 0;
+}
+
+/** Short distinctive id fallback when a session has no board alias. */
+export function shortSessionID(sessionID: string): string {
+  return sessionID.length > 8 ? sessionID.slice(-8) : sessionID;
+}
+
+/**
+ * Per-window sidebar interaction state. `navigate` is feature-detected at
+ * startup: without it the sidebar renders informatively (no handlers).
+ * Expansion state is local to this window and never persisted.
+ */
+export interface SidebarInteraction {
+  navigate?: (sessionID: string) => void;
+  expandedAgents: () => ReadonlySet<string>;
+  toggleAgent: (agentName: string) => void;
+  /** Reset expansion when the project directory or visible root changes. */
+  syncScope: (directory: string, rootID: string | undefined) => void;
+  /** True when this TUI has a non-empty text selection (skip click). */
+  hasSelectedText?: () => boolean;
+}
+
+export function createSidebarInteraction(
+  navigate: ((sessionID: string) => void) | undefined,
+  hasSelectedText?: () => boolean,
+): SidebarInteraction {
+  const [expanded, setExpanded] = createSignal<ReadonlySet<string>>(new Set());
+  let lastDirectory: string | undefined;
+  let lastRootID: string | undefined;
+  return {
+    navigate,
+    hasSelectedText,
+    expandedAgents: expanded,
+    toggleAgent: (agentName: string) => {
+      setExpanded((prev: ReadonlySet<string>) => {
+        const next = new Set<string>(prev);
+        if (next.has(agentName)) next.delete(agentName);
+        else next.add(agentName);
+        return next;
+      });
+    },
+    syncScope: (directory, rootID) => {
+      if (
+        lastDirectory !== undefined &&
+        (lastDirectory !== directory || lastRootID !== rootID)
+      ) {
+        setExpanded(new Set<string>());
+      }
+      lastDirectory = directory;
+      lastRootID = rootID;
+    },
+  };
+}
+
+/** Build a guarded navigation callback from a raw route navigate fn. */
+export function makeRouteNavigator(
+  owner: object | undefined,
+  methodName: 'navigate',
+  v2Shape: boolean,
+): ((sessionID: string) => void) | undefined {
+  if (owner === undefined) return undefined;
+  const raw = (owner as Record<string, unknown>)[methodName];
+  if (typeof raw !== 'function') return undefined;
+  return (sessionID) => {
+    try {
+      if (v2Shape) {
+        (raw as (route: { type: string; sessionID: string }) => void).call(
+          owner,
+          { type: 'session', sessionID },
+        );
+      } else {
+        (raw as (name: string, params?: Record<string, unknown>) => void).call(
+          owner,
+          'session',
+          { sessionID },
+        );
+      }
+    } catch {
+      // Navigation is best-effort; never break the sidebar on a host error.
+    }
+  };
+}
+
 export function getSidebarActivityIndicator(
   active: boolean,
   now = Date.now(),
@@ -412,19 +600,202 @@ interface AgentRowTheme {
   accent: unknown;
   text: unknown;
   textMuted: unknown;
+  background?: unknown;
+  backgroundElement?: unknown;
+  success?: unknown;
+  warning?: unknown;
+  hover?: unknown;
+}
+
+const STATUS_ACTIVE_COLOR = '#22c55e';
+const STATUS_RETRY_COLOR = '#f59e0b';
+const STATUS_COLUMN_WIDTH = 8;
+
+function hasPrimarySelection(hasSelectedText?: () => boolean): boolean {
+  try {
+    return hasSelectedText?.() === true;
+  } catch {
+    return false;
+  }
+}
+
+function shouldActivateRow(
+  event: { button?: number } | undefined,
+  hasSelectedText?: () => boolean,
+): boolean {
+  if (event?.button !== undefined && event.button !== 0) return false;
+  return !hasPrimarySelection(hasSelectedText);
+}
+
+export function selectionGuard(renderer: {
+  getSelection?: () => unknown;
+}): () => boolean {
+  return () => {
+    const selection = renderer.getSelection?.();
+    if (selection === null || selection === undefined) return false;
+    if (typeof selection !== 'object') return false;
+    const getSelectedText = (selection as { getSelectedText?: unknown })
+      .getSelectedText;
+    if (typeof getSelectedText !== 'function') return false;
+    const text = (getSelectedText as () => unknown).call(selection);
+    return typeof text === 'string' && text.length > 0;
+  };
+}
+
+export function resolveHoverBackground(theme: {
+  background?: unknown;
+  backgroundElement?: unknown;
+  text?: unknown;
+  hover?: unknown;
+}): unknown {
+  if (theme.hover !== undefined && theme.hover !== null) return theme.hover;
+  if (
+    theme.backgroundElement !== undefined &&
+    theme.backgroundElement !== null
+  ) {
+    return theme.backgroundElement;
+  }
+  try {
+    const bg = parseColor((theme.background ?? '#111111') as ColorInput);
+    const fg = parseColor((theme.text ?? '#ffffff') as ColorInput);
+    return RGBA.fromValues(
+      bg.r * 0.82 + fg.r * 0.18,
+      bg.g * 0.82 + fg.g * 0.18,
+      bg.b * 0.82 + fg.b * 0.18,
+      bg.a,
+    );
+  } catch {
+    return '#2a2a2a';
+  }
+}
+
+type HoverPaintTarget = {
+  node: { bg?: unknown; backgroundColor?: unknown };
+  hasBg: boolean;
+  hasBackgroundColor: boolean;
+  bg: unknown;
+  backgroundColor: unknown;
+};
+
+type HoverRowRenderable = {
+  backgroundColor?: unknown;
+  bg?: unknown;
+  getChildren?: () => unknown[];
+  screenX: number;
+  screenY: number;
+  width: number;
+  height: number;
+};
+
+function collectHoverPaintTargets(
+  node: unknown,
+  acc: HoverPaintTarget[],
+): void {
+  if (!node || typeof node !== 'object') return;
+  const rec = node as HoverRowRenderable;
+  const hasBg = 'bg' in rec;
+  const hasBackgroundColor = 'backgroundColor' in rec;
+  if (hasBg || hasBackgroundColor) {
+    acc.push({
+      node: rec,
+      hasBg,
+      hasBackgroundColor,
+      bg: rec.bg,
+      backgroundColor: rec.backgroundColor,
+    });
+  }
+  const children = rec.getChildren?.();
+  if (!Array.isArray(children)) return;
+  for (const child of children) collectHoverPaintTargets(child, acc);
+}
+
+function isPointerInsideRow(
+  row: HoverRowRenderable,
+  event?: { x?: number; y?: number },
+): boolean {
+  if (event?.x === undefined || event?.y === undefined) return false;
+  return (
+    event.x >= row.screenX &&
+    event.x < row.screenX + row.width &&
+    event.y >= row.screenY &&
+    event.y < row.screenY + row.height
+  );
+}
+
+/**
+ * Mutate a stable row's background in place. Must not read a Solid
+ * signal: rebuilding the row between press and release drops the click.
+ *
+ * OpenTUI hit-tests the leaf (usually the text child). `out`/`over` then
+ * bubble. Ignore `out` while the pointer is still inside this row so
+ * moving between alias/model/status does not flicker, and paint both the
+ * box fill and descendant text `bg` so the whole line lights up.
+ */
+function decorateInteractiveRow(
+  node: JSX.Element,
+  opts: {
+    hoverBackground: unknown;
+    onActivate?: () => void;
+    hasSelectedText?: () => boolean;
+  },
+): JSX.Element {
+  const row = node as unknown as HoverRowRenderable;
+  const painted: HoverPaintTarget[] = [];
+  collectHoverPaintTargets(row, painted);
+
+  const applyHover = (active: boolean): void => {
+    for (const target of painted) {
+      if (target.hasBackgroundColor) {
+        target.node.backgroundColor = active
+          ? opts.hoverBackground
+          : target.backgroundColor;
+      }
+      if (target.hasBg) {
+        target.node.bg = active ? opts.hoverBackground : target.bg;
+      }
+    }
+  };
+
+  setProp(node as never, 'onMouseOver', () => {
+    applyHover(true);
+  });
+  setProp(node as never, 'onMouseOut', (event?: { x?: number; y?: number }) => {
+    if (isPointerInsideRow(row, event)) return;
+    applyHover(false);
+  });
+  if (opts.onActivate) {
+    setProp(node as never, 'onMouseUp', (event?: { button?: number }) => {
+      if (!shouldActivateRow(event, opts.hasSelectedText)) return;
+      opts.onActivate?.();
+    });
+  }
+  return node;
+}
+
+function sessionStatusView(
+  status: SidebarSessionTarget['status'],
+  theme: AgentRowTheme,
+): { label: string; color: unknown } {
+  if (status === 'retry') {
+    return { label: 'retrying', color: theme.warning ?? STATUS_RETRY_COLOR };
+  }
+  return { label: 'active', color: theme.success ?? STATUS_ACTIVE_COLOR };
 }
 
 function activityIndicator(
   active: boolean,
-  now: number,
+  now: () => number,
   theme: AgentRowTheme,
 ): JSX.Element {
+  // Nested reactive leaf: only this glyph re-renders on the 100 ms
+  // animation tick. Rebuilding the clickable parent on every frame
+  // would drop the mouse target between press and release.
   return text(
     {
       fg: active ? (theme.accent ?? theme.text) : theme.textMuted,
       width: 2,
     },
-    [getSidebarActivityIndicator(active, now)],
+    active ? [() => getSidebarActivityIndicator(true, now())] : [' '],
   );
 }
 
@@ -433,17 +804,30 @@ function agentRow(
   model: string,
   variant: string | undefined,
   active: boolean,
-  now: number,
+  now: () => number,
   theme: AgentRowTheme,
+  sessionCount?: number,
+  expanded = false,
+  onClick?: () => void,
+  hoverBackground?: unknown,
+  hasSelectedText?: () => boolean,
 ): JSX.Element {
   const modelParts = splitSidebarModelId(model);
   const detailRows: JSX.Element[] = [];
 
   function detailRow(fieldLabel: string, value: string) {
-    return box({ width: '100%', flexDirection: 'row', paddingLeft: 2 }, [
-      text({ fg: theme.textMuted, width: 9 }, [fieldLabel]),
-      text({ fg: theme.textMuted }, [value]),
-    ]);
+    return box(
+      {
+        width: '100%',
+        flexDirection: 'row',
+        paddingLeft: 2,
+        shouldFill: false,
+      },
+      [
+        text({ fg: theme.textMuted, width: 9 }, [fieldLabel]),
+        text({ fg: theme.textMuted }, [value]),
+      ],
+    );
   }
 
   if (modelParts.provider) {
@@ -454,13 +838,38 @@ function agentRow(
     detailRows.push(detailRow('variant', variant));
   }
 
-  return box({ width: '100%', flexDirection: 'column', marginBottom: 1 }, [
-    box({ width: '100%', flexDirection: 'row' }, [
+  const header = box(
+    {
+      width: '100%',
+      flexDirection: 'row',
+      shouldFill: true,
+    },
+    [
       text({ fg: theme.textMuted, width: 14 }, [label]),
       activityIndicator(active, now, theme),
-    ]),
-    ...detailRows,
-  ]);
+      ...(sessionCount !== undefined && sessionCount > 1
+        ? [
+            text({ fg: theme.textMuted, width: 4 }, [expanded ? ' ▴' : ' ▾']),
+            text({ fg: theme.textMuted }, [`${sessionCount}`]),
+          ]
+        : []),
+    ],
+  );
+  decorateInteractiveRow(header, {
+    hoverBackground: hoverBackground ?? resolveHoverBackground(theme),
+    onActivate: onClick,
+    hasSelectedText,
+  });
+
+  return box(
+    {
+      width: '100%',
+      flexDirection: 'column',
+      marginBottom: 1,
+      shouldFill: false,
+    },
+    [header, ...detailRows],
+  );
 }
 
 function compactAgentRow(
@@ -468,21 +877,35 @@ function compactAgentRow(
   model: string,
   _variant: string | undefined,
   active: boolean,
-  now: number,
+  now: () => number,
   theme: AgentRowTheme,
+  sessionCount?: number,
+  expanded = false,
+  onClick?: () => void,
+  hoverBackground?: unknown,
+  hasSelectedText?: () => boolean,
 ): JSX.Element {
   const modelName = splitSidebarModelId(model).model;
-  return box(
+  const row = box(
     {
       width: '100%',
       flexDirection: 'row',
       justifyContent: 'space-between',
+      shouldFill: true,
     },
     [
-      box({ width: 16, flexShrink: 0, flexDirection: 'row' }, [
-        text({ fg: theme.textMuted, width: 14 }, [label]),
-        activityIndicator(active, now, theme),
-      ]),
+      box(
+        {
+          width: 16,
+          flexShrink: 0,
+          flexDirection: 'row',
+          shouldFill: false,
+        },
+        [
+          text({ fg: theme.textMuted, width: 14 }, [label]),
+          activityIndicator(active, now, theme),
+        ],
+      ),
       text(
         {
           fg: theme.textMuted,
@@ -490,10 +913,81 @@ function compactAgentRow(
           truncate: true,
           flexShrink: 1,
         },
+        [
+          sessionCount !== undefined && sessionCount > 1
+            ? `${expanded ? '▴' : '▾'}${sessionCount} ${modelName}`
+            : modelName,
+        ],
+      ),
+    ],
+  );
+  return decorateInteractiveRow(row, {
+    hoverBackground: hoverBackground ?? resolveHoverBackground(theme),
+    onActivate: onClick,
+    hasSelectedText,
+  });
+}
+
+/**
+ * One expanded subagent destination: `ora-1  model  status`.
+ * Hover mutates this box in place so the click target survives ticks.
+ */
+function sessionTargetRow(
+  target: SidebarSessionTarget,
+  theme: AgentRowTheme,
+  onActivate: () => void,
+  hoverBackground: unknown,
+  hasSelectedText?: () => boolean,
+): JSX.Element {
+  const label = target.alias ?? shortSessionID(target.sessionID);
+  const status = sessionStatusView(target.status, theme);
+  const modelName = target.model
+    ? splitSidebarModelId(target.model).model
+    : '—';
+  const row = box(
+    {
+      width: '100%',
+      flexDirection: 'row',
+      paddingLeft: 2,
+      columnGap: 1,
+      shouldFill: true,
+    },
+    [
+      text(
+        {
+          fg: theme.textMuted,
+          flexShrink: 0,
+          wrapMode: 'none',
+        },
+        [label],
+      ),
+      text(
+        {
+          fg: theme.textMuted,
+          wrapMode: 'none',
+          truncate: true,
+          flexGrow: 1,
+          flexShrink: 1,
+          minWidth: 0,
+        },
         [modelName],
       ),
+      text(
+        {
+          fg: status.color,
+          width: STATUS_COLUMN_WIDTH,
+          flexShrink: 0,
+          wrapMode: 'none',
+        },
+        [status.label],
+      ),
     ],
   );
+  return decorateInteractiveRow(row, {
+    hoverBackground,
+    onActivate,
+    hasSelectedText,
+  });
 }
 
 export function getContrastForeground(
@@ -560,14 +1054,27 @@ function renderSidebar(
     borderActive: unknown;
     text: unknown;
     textMuted: unknown;
+    backgroundElement?: unknown;
+    success?: unknown;
+    warning?: unknown;
+    hover?: unknown;
   },
   configInvalid: boolean,
   compactSidebar: boolean,
-  now = Date.now(),
+  now: () => number = Date.now,
   visibleRootID?: string,
+  interaction?: SidebarInteraction,
 ): JSX.Element {
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
   const activeAgents = getActiveSidebarAgentNames(snapshot, visibleRootID);
+  const targetsByAgent = new Map(
+    getSidebarAgentTargets(snapshot, visibleRootID).map((group) => [
+      group.agentName,
+      group.sessions,
+    ]),
+  );
+  const expandedAgents = interaction?.expandedAgents() ?? new Set<string>();
+  const hoverBackground = resolveHoverBackground(theme);
   return box(
     {
       width: '100%',
@@ -610,14 +1117,66 @@ function renderSidebar(
       box({ width: '100%', marginTop: 1 }, [
         text({ fg: theme.text }, ['Agents']),
       ]),
-      ...getSidebarAgentNames(snapshot).map((agentName) => {
+      ...getSidebarAgentNames(snapshot).flatMap((agentName) => {
         const model = snapshot.agentModels[agentName] ?? 'pending';
         const variant = snapshot.agentVariants[agentName];
         const active = activeAgents.has(agentName);
-        if (compactSidebar) {
-          return compactAgentRow(agentName, model, variant, active, now, theme);
-        }
-        return agentRow(agentName, model, variant, active, now, theme);
+        const sessions = targetsByAgent.get(agentName) ?? [];
+        // Rows only become interactive when this window can navigate AND
+        // the agent has live subagent sessions in this conversation.
+        const clickable =
+          interaction?.navigate !== undefined && sessions.length > 0;
+        const expanded =
+          clickable && sessions.length > 1 && expandedAgents.has(agentName);
+        const onAgentClick = clickable
+          ? () => {
+              if (sessions.length === 1) {
+                interaction?.navigate?.(sessions[0].sessionID);
+              } else {
+                interaction?.toggleAgent(agentName);
+              }
+            }
+          : undefined;
+        const agentRowEl = compactSidebar
+          ? compactAgentRow(
+              agentName,
+              model,
+              variant,
+              active,
+              now,
+              theme,
+              clickable ? sessions.length : undefined,
+              expanded,
+              onAgentClick,
+              hoverBackground,
+              interaction?.hasSelectedText,
+            )
+          : agentRow(
+              agentName,
+              model,
+              variant,
+              active,
+              now,
+              theme,
+              clickable ? sessions.length : undefined,
+              expanded,
+              onAgentClick,
+              hoverBackground,
+              interaction?.hasSelectedText,
+            );
+        if (!expanded) return [agentRowEl];
+        return [
+          agentRowEl,
+          ...sessions.map((target) =>
+            sessionTargetRow(
+              target,
+              theme,
+              () => interaction?.navigate?.(target.sessionID),
+              hoverBackground,
+              interaction?.hasSelectedText,
+            ),
+          ),
+        ];
       }),
     ],
   );
@@ -683,6 +1242,9 @@ interface V2TuiThemeTokens {
   text: { default: unknown; subdued: unknown };
   background: { default: unknown };
   border: { default: unknown };
+  /** Optional semantic tokens; v2 hosts may omit them. */
+  success?: unknown;
+  warning?: unknown;
 }
 
 interface V2TuiSlotClaim {
@@ -697,11 +1259,15 @@ interface V2TuiSlotClaim {
 interface V2TuiContext {
   location?: { directory: string };
   client?: unknown;
-  renderer: { requestRender: () => void };
+  renderer: { requestRender: () => void; getSelection?: () => unknown };
   theme: V2TuiThemeTokens;
   ui: {
     slot: (claim: V2TuiSlotClaim) => () => void;
-    router: { current: () => { type?: string; sessionID?: string } };
+    router: {
+      current: () => { type?: string; sessionID?: string };
+      /** Optional navigation capability; absent on hosts that don't expose it. */
+      navigate?: (route: { type: string; sessionID: string }) => void;
+    };
   };
 }
 
@@ -712,6 +1278,8 @@ function v2ThemeView(theme: V2TuiThemeTokens): {
   borderActive: unknown;
   text: unknown;
   textMuted: unknown;
+  success?: unknown;
+  warning?: unknown;
 } {
   return {
     accent: undefined,
@@ -719,6 +1287,8 @@ function v2ThemeView(theme: V2TuiThemeTokens): {
     borderActive: theme.border.default,
     text: theme.text.default,
     textMuted: theme.text.subdued,
+    ...(theme.success !== undefined ? { success: theme.success } : {}),
+    ...(theme.warning !== undefined ? { warning: theme.warning } : {}),
   };
 }
 
@@ -749,7 +1319,8 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
     syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
     let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
     if (disposed) return;
-    if (currentDirectory !== configDirectory) {
+    const directoryChanged = currentDirectory !== configDirectory;
+    if (directoryChanged) {
       configDirectory = currentDirectory;
       ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
     }
@@ -768,6 +1339,9 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
     ) {
       return;
     }
+    if (!directoryChanged && snapshotSectionsEqual(nextSnapshot, snapshot())) {
+      return;
+    }
     setSnapshot(nextSnapshot);
     ctx.renderer.requestRender();
   };
@@ -787,20 +1361,36 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
 
   const visibleSession = () => resolveRouteSessionId(ctx.ui.router.current());
 
+  // Clickable sidebar: navigation is optional on v2 hosts (feature-detected
+  // at startup); without it the sidebar renders informatively.
+  const interaction = createSidebarInteraction(
+    makeRouteNavigator(ctx.ui.router, 'navigate', true),
+    selectionGuard(ctx.renderer),
+  );
+
   const disposeSlot = ctx.ui.slot({
     append: 'sidebar.content',
     render: () =>
-      reactiveElement(() =>
-        renderSidebar(
-          snapshot(),
+      reactiveElement(() => {
+        const visible = visibleSession();
+        const currentSnapshot = snapshot();
+        interaction.syncScope(
+          configDirectory,
+          visible === undefined
+            ? undefined
+            : resolveTuiSnapshotRoot(currentSnapshot, visible),
+        );
+        return renderSidebar(
+          currentSnapshot,
           version,
           v2ThemeView(ctx.theme),
           configInvalid,
           compactSidebar,
-          animationNow(),
-          visibleSession(),
-        ),
-      ),
+          animationNow,
+          visible,
+          interaction,
+        );
+      }),
   });
 
   return () => {
@@ -870,7 +1460,8 @@ const plugin: TuiDualContractModule = {
       const currentDirectory = getTuiDirectory(api);
       syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
       let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
-      if (currentDirectory !== configDirectory) {
+      const directoryChanged = currentDirectory !== configDirectory;
+      if (directoryChanged) {
         configDirectory = currentDirectory;
         ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
       }
@@ -881,6 +1472,12 @@ const plugin: TuiDualContractModule = {
         remoteCache,
       );
       if (!isRefreshCurrent(currentDirectory, getTuiDirectory(api))) return;
+      if (
+        !directoryChanged &&
+        snapshotSectionsEqual(nextSnapshot, snapshot())
+      ) {
+        return;
+      }
       setSnapshot(nextSnapshot);
       api.renderer.requestRender();
     };
@@ -906,21 +1503,36 @@ const plugin: TuiDualContractModule = {
       clearTmuxPaneRegistration(tmuxRegistration);
     });
 
+    // Clickable sidebar: v1 hosts always expose api.route.navigate.
+    const interaction = createSidebarInteraction(
+      makeRouteNavigator(api.route, 'navigate', false),
+      selectionGuard(api.renderer),
+    );
+
     api.slots.register({
       order: 900,
       slots: {
         sidebar_content() {
-          return reactiveElement(() =>
-            renderSidebar(
-              snapshot(),
+          return reactiveElement(() => {
+            const visible = resolveRouteSessionId(api.route.current);
+            const currentSnapshot = snapshot();
+            interaction.syncScope(
+              configDirectory,
+              visible === undefined
+                ? undefined
+                : resolveTuiSnapshotRoot(currentSnapshot, visible),
+            );
+            return renderSidebar(
+              currentSnapshot,
               version,
               api.theme.current,
               configInvalid,
               compactSidebar,
-              animationNow(),
-              resolveRouteSessionId(api.route.current),
-            ),
-          );
+              animationNow,
+              visible,
+              interaction,
+            );
+          });
         },
       },
     });

+ 82 - 0
src/utils/background-job-coordinator.test.ts

@@ -248,4 +248,86 @@ describe('BackgroundJobCoordinator', () => {
     if (!lease) throw new Error('terminal notification lease was not acquired');
     expect(coordinator.releaseLease(lease)).toBe(true);
   });
+
+  test('notifies launch identity on accepted register, drop, and clearParent', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const events: Array<{ kind: string; taskID: string; alias: string }> = [];
+    coordinator.addLaunchIdentityListener((event) => {
+      events.push({
+        kind: event.kind,
+        taskID: event.taskID,
+        alias: event.alias,
+      });
+    });
+
+    const first = coordinator.registerLaunch({
+      taskID: 'ses_ora_1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+    });
+    expect(events).toEqual([
+      { kind: 'registered', taskID: 'ses_ora_1', alias: first.alias },
+    ]);
+
+    coordinator.drop('ses_ora_1');
+    expect(events.at(-1)).toEqual({
+      kind: 'removed',
+      taskID: 'ses_ora_1',
+      alias: first.alias,
+    });
+
+    const sibling = coordinator.registerLaunch({
+      taskID: 'ses_ora_2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+    });
+    coordinator.clearParent('parent-1');
+    expect(events.at(-1)).toEqual({
+      kind: 'removed',
+      taskID: 'ses_ora_2',
+      alias: sibling.alias,
+    });
+    expect(board.list('parent-1')).toEqual([]);
+  });
+
+  test('does not notify identity for a rejected launch, and a throwing listener does not fail the launch', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const first = coordinator.registerLaunch({
+      taskID: 'ses_busy',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+    });
+    const lease = coordinator.acquireMessageLease(
+      first.taskID,
+      first.generation,
+    );
+    expect(lease).toBeDefined();
+
+    const events: string[] = [];
+    coordinator.addLaunchIdentityListener(() => {
+      throw new Error('identity listener failed');
+    });
+    coordinator.addLaunchIdentityListener((event) => {
+      events.push(`${event.kind}:${event.taskID}`);
+    });
+
+    expect(() =>
+      coordinator.registerLaunch({
+        taskID: 'ses_busy',
+        parentSessionID: 'parent-1',
+        agent: 'oracle',
+      }),
+    ).toThrow();
+    expect(events).toEqual([]);
+
+    const accepted = coordinator.registerLaunch({
+      taskID: 'ses_ok',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+    expect(accepted.taskID).toBe('ses_ok');
+    expect(events).toEqual(['registered:ses_ok']);
+  });
 });

+ 67 - 1
src/utils/background-job-coordinator.ts

@@ -15,6 +15,21 @@ import { log } from './logger';
 type TerminalStateListener = (taskID: string) => void;
 type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 
+/**
+ * Identity projection event for accepted/removed launches. Consumed by the
+ * host plugin to mirror alias↔session links into TUI state; consumers must
+ * be best-effort (failures are logged, never propagated to the launch).
+ */
+export interface BackgroundJobIdentityEvent {
+  kind: 'registered' | 'removed';
+  taskID: string;
+  parentSessionID: string;
+  agent: string;
+  alias: string;
+}
+
+type LaunchIdentityListener = (event: BackgroundJobIdentityEvent) => void;
+
 /**
  * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
  * It sits between the board and its consumers, providing:
@@ -29,6 +44,7 @@ type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 export class BackgroundJobCoordinator implements BackgroundJobStore {
   private terminalStateListeners: TerminalStateListener[] = [];
   private terminalOutcomeListeners: TerminalOutcomeListener[] = [];
+  private launchIdentityListeners: LaunchIdentityListener[] = [];
   // Stores session IDs (which equal task IDs) awaiting close after background job completes
   private readonly deferredIdleCloses = new Set<string>();
 
@@ -39,6 +55,26 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     });
   }
 
+  // ── Launch identity projection (best-effort, sidebar details) ─────
+
+  addLaunchIdentityListener(listener: LaunchIdentityListener): void {
+    this.launchIdentityListeners.push(listener);
+  }
+
+  private notifyLaunchIdentity(event: BackgroundJobIdentityEvent): void {
+    for (const listener of this.launchIdentityListeners) {
+      try {
+        listener(event);
+      } catch (error) {
+        log('Coordinator launch identity listener threw', {
+          taskID: event.taskID,
+          kind: event.kind,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
+    }
+  }
+
   // ── Terminal state notification (guaranteed delivery) ─────────────
 
   addTerminalStateListener(listener: TerminalStateListener): void {
@@ -135,7 +171,15 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
   // ── Mutation methods (sole writer to board) ──────────────────────
 
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
-    return this.board.registerLaunch(input);
+    const record = this.board.registerLaunch(input);
+    this.notifyLaunchIdentity({
+      kind: 'registered',
+      taskID: record.taskID,
+      parentSessionID: record.parentSessionID,
+      agent: record.agent,
+      alias: record.alias,
+    });
+    return record;
   }
 
   acquireCancellationLease(
@@ -377,10 +421,32 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
   }
 
   clearParent(parentSessionID: string): void {
+    // Capture identities before the board removes them so the projection
+    // can retract aliases for every affected record.
+    const removed = this.board.list(parentSessionID);
     this.board.clearParent(parentSessionID);
+    for (const record of removed) {
+      this.notifyLaunchIdentity({
+        kind: 'removed',
+        taskID: record.taskID,
+        parentSessionID: record.parentSessionID,
+        agent: record.agent,
+        alias: record.alias,
+      });
+    }
   }
 
   drop(taskID: string): void {
+    const record = this.board.get(taskID);
     this.board.drop(taskID);
+    if (record) {
+      this.notifyLaunchIdentity({
+        kind: 'removed',
+        taskID: record.taskID,
+        parentSessionID: record.parentSessionID,
+        agent: record.agent,
+        alias: record.alias,
+      });
+    }
   }
 }