Explorar el Código

Merge pull request #1197 from alvinunreal/feat/sidebar-clickable-sessions

feat(tui): clickable subagent sessions in the sidebar
Alvin hace 1 día
padre
commit
f1cc0f34f2
Se han modificado 8 ficheros con 1926 adiciones y 67 borrados
  1. 185 2
      src/index.test.ts
  2. 78 7
      src/index.ts
  3. 193 3
      src/tui-state.test.ts
  4. 162 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

+ 185 - 2
src/index.test.ts

@@ -7,7 +7,7 @@ import pluginModuleDefault, {
   sessionManagerMultiplexerConfig,
   shouldEnableMultiplexer,
 } from './index';
-import { readTuiSnapshot } from './tui-state';
+import { readTuiSnapshot, snapshotSectionsEqual } from './tui-state';
 import { createInternalAgentTextPart } from './utils/internal-initiator';
 
 function createPluginClient(
@@ -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();
 
@@ -736,7 +756,170 @@ describe('plugin TUI agent activity', () => {
     const after = readTuiSnapshot(projectDir);
     expect(after.activeSessions).toEqual(before.activeSessions);
     expect(after.agentModels).toEqual(before.agentModels);
-    expect(after.updatedAt).toBe(before.updatedAt);
+    expect(snapshotSectionsEqual(after, before)).toBe(true);
+  });
+
+  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+$/,
+    );
   });
 });
 

+ 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,
@@ -237,8 +239,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;
   };
@@ -248,9 +252,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);
   };
@@ -510,6 +536,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,
@@ -1307,9 +1355,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' ||
@@ -1351,6 +1399,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) {
@@ -1652,8 +1714,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,
@@ -1678,6 +1741,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);

+ 193 - 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,41 @@ 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('readTuiSnapshotAsync cache is bounded (LRU eviction)', async () => {
+    recordTuiAgentModel(LUNA, tempDir);
+    const first = await readTuiSnapshotAsync(tempDir);
+    expect(first.agentModels.explorer).toBe(LUNA.model);
+
+    // Poll 8 other projects: the first entry must be evicted even though
+    // its file is unchanged (a fresh read returns a new object identity).
+    for (let i = 0; i < 8; i += 1) {
+      const dir = path.join(tempDir, `project-${i}`);
+      recordTuiAgentModel(GPT, dir);
+      await readTuiSnapshotAsync(dir);
+    }
+
+    const reRead = await readTuiSnapshotAsync(tempDir);
+    expect(reRead.agentModels.explorer).toBe(LUNA.model);
+    expect(reRead).not.toBe(first);
+  });
+
   test('persists enabled agent models', () => {
     recordTuiAgentModels(
       {
@@ -353,20 +391,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 +640,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' });
+  });
+});

+ 162 - 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,59 @@ 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.
+// Bounded LRU: a long-lived daemon polling many projects must not
+// retain a snapshot per visited path.
+const ASYNC_SNAPSHOT_CACHE_MAX = 8;
+const asyncSnapshotCache = new Map<
+  string,
+  { stat: string; snapshot: TuiSnapshot }
+>();
+
+function snapshotStatKey(stat: fs.Stats): string {
+  return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
+}
+
+function rememberAsyncSnapshot(
+  statePath: string,
+  statKey: string,
+  snapshot: TuiSnapshot,
+): void {
+  // Map insertion order doubles as the LRU order: re-insert to refresh.
+  asyncSnapshotCache.delete(statePath);
+  asyncSnapshotCache.set(statePath, { stat: statKey, snapshot });
+  while (asyncSnapshotCache.size > ASYNC_SNAPSHOT_CACHE_MAX) {
+    const oldest = asyncSnapshotCache.keys().next().value;
+    if (oldest === undefined) break;
+    asyncSnapshotCache.delete(oldest);
+  }
+}
+
 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) {
+      // Refresh LRU position on hit.
+      asyncSnapshotCache.delete(statePath);
+      asyncSnapshotCache.set(statePath, cached);
+      return cached.snapshot;
+    }
+    const snapshot = parseSnapshot(
+      await fs.promises.readFile(statePath, 'utf8'),
     );
+    rememberAsyncSnapshot(statePath, statKey, snapshot);
+    return snapshot;
   } catch {
+    asyncSnapshotCache.delete(statePath);
     return emptySnapshot();
   }
 }
@@ -288,16 +369,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 +496,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 +509,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,16 +8,23 @@ 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,
   resolveSidebarSlotOrder,
+  selectionGuard,
+  shortSessionID,
   splitSidebarModelId,
   syncTmuxPaneRegistration,
   default as tuiPlugin,
@@ -25,6 +32,7 @@ import {
 import {
   recordTuiAgentActivity,
   recordTuiAgentModels,
+  recordTuiSessionParent,
   type TuiSnapshot,
 } from './tui-state';
 
@@ -39,6 +47,7 @@ function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
     activeSessions: {},
     activityPids: {},
     sessionParents: {},
+    sessionDetails: {},
     ...overrides,
   };
 }
@@ -843,6 +852,501 @@ 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 });
+    }
+  });
+});
+
 describe('resolveSidebarSlotOrder', () => {
   const NAME = 'oh-my-opencode-slim';
 

+ 655 - 43
src/tui.ts

@@ -23,6 +23,7 @@ import {
   readTuiSnapshot,
   readTuiSnapshotAsync,
   resolveTuiSnapshotRoot,
+  snapshotSectionsEqual,
   type TuiSnapshot,
 } from './tui-state';
 import { isPluginDisabledByEnv } from './utils/env';
@@ -51,7 +52,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 {
@@ -400,6 +408,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(),
@@ -413,19 +601,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())] : [' '],
   );
 }
 
@@ -434,17 +805,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) {
@@ -455,13 +839,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(
@@ -469,21 +878,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,
@@ -491,10 +914,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(
@@ -561,14 +1055,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%',
@@ -611,14 +1118,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,
+            ),
+          ),
+        ];
       }),
     ],
   );
@@ -733,6 +1292,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 {
@@ -747,11 +1309,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;
+    };
   };
 }
 
@@ -762,6 +1328,8 @@ function v2ThemeView(theme: V2TuiThemeTokens): {
   borderActive: unknown;
   text: unknown;
   textMuted: unknown;
+  success?: unknown;
+  warning?: unknown;
 } {
   return {
     accent: undefined,
@@ -769,6 +1337,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 } : {}),
   };
 }
 
@@ -799,7 +1369,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));
     }
@@ -818,6 +1389,9 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
     ) {
       return;
     }
+    if (!directoryChanged && snapshotSectionsEqual(nextSnapshot, snapshot())) {
+      return;
+    }
     setSnapshot(nextSnapshot);
     ctx.renderer.requestRender();
   };
@@ -837,20 +1411,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 () => {
@@ -920,7 +1510,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));
       }
@@ -931,6 +1522,12 @@ const plugin: TuiDualContractModule = {
         remoteCache,
       );
       if (!isRefreshCurrent(currentDirectory, getTuiDirectory(api))) return;
+      if (
+        !directoryChanged &&
+        snapshotSectionsEqual(nextSnapshot, snapshot())
+      ) {
+        return;
+      }
       setSnapshot(nextSnapshot);
       api.renderer.requestRender();
     };
@@ -956,21 +1553,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: resolveSidebarSlotOrder(api.tuiConfig?.plugin, PLUGIN_NAME),
       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,
+      });
+    }
   }
 }