Преглед изворни кода

fix(tui): scope sidebar spinners to the visible conversation (#1183)

* fix(tui): scope sidebar spinners to the visible conversation

Two windows on one project dir animated each other's subagents (#1147).
The activity map was global per project and process identity cannot
scope it (v2 windows share one detached daemon), so scope by session
tree instead: a persistent sessionParents index, host hydration for
sessions with no known link, and sidebar filtering that resolves the
visible route and every active session against that same index.

* fix(tui): scope animation timers to visible conversation; retry malformed parents

Greptile follow-ups on #1183:

- Both animation timers now pass the visible route session to
  getActiveSidebarAgentNames, matching the render scoping: hidden
  foreign-conversation activity no longer rerenders this window's
  sidebar every 100ms frame.
- A malformed non-string parentID in a hydration response releases the
  hydratedTuiParents slot instead of caching a false confirmed root, so
  a later activity retries against the host.

---------

Co-authored-by: Alvin <alvin@boringdystopia.ai>
Raxxoor пре 2 дана
родитељ
комит
d11ccb501c
6 измењених фајлова са 489 додато и 6 уклоњено
  1. 132 0
      src/index.test.ts
  2. 89 0
      src/index.ts
  3. 83 0
      src/tui-state.test.ts
  4. 104 2
      src/tui-state.ts
  5. 36 0
      src/tui.test.ts
  6. 45 4
      src/tui.ts

+ 132 - 0
src/index.test.ts

@@ -467,6 +467,138 @@ describe('plugin TUI agent activity', () => {
     }
     }
   });
   });
 
 
+  test('hydrates the full ancestry chain with the SDK receiver intact', async () => {
+    const calls: string[] = [];
+    const receivers: unknown[] = [];
+    const sessionApi = {
+      async get(this: unknown, input: { path: { id: string } }) {
+        calls.push(input.path.id);
+        receivers.push(this);
+        const parents: Record<string, string | undefined> = {
+          grandchild: 'child',
+          child: 'root',
+          root: undefined,
+        };
+        return { data: { parentID: parents[input.path.id] } };
+      },
+    };
+    const chainHooks = await plugin({
+      client: { session: sessionApi },
+      directory: projectDir,
+      worktree: projectDir,
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    try {
+      await chainHooks?.['chat.message']?.(
+        { sessionID: 'grandchild', agent: 'fixer' } as never,
+        {} as never,
+      );
+      // Fire-and-forget hydration; give the microtask queue a beat.
+      await new Promise((resolve) => setTimeout(resolve, 10));
+
+      const snapshot = readTuiSnapshot(projectDir);
+      expect(snapshot.sessionParents).toEqual({
+        grandchild: 'child',
+        child: 'root',
+      });
+      expect(calls).toEqual(['grandchild', 'child', 'root']);
+      // The SDK method must run with its receiver (#595 class of bug).
+      for (const receiver of receivers) {
+        expect(receiver).toBe(sessionApi);
+      }
+    } finally {
+      await chainHooks?.dispose?.();
+    }
+  });
+
+  test('does not cache an errored host lookup as a confirmed root', async () => {
+    let attempts = 0;
+    const sessionApi = {
+      async get(input: { path: { id: string } }) {
+        attempts += 1;
+        if (attempts === 1) {
+          // HTTP error resolved instead of thrown (SDK default).
+          return { error: { status: 503 }, data: undefined };
+        }
+        if (input.path.id === 'real-root') {
+          return { data: { parentID: undefined } }; // Confirmed root.
+        }
+        return { data: { parentID: 'real-root' } };
+      },
+    };
+    const retryHooks = await plugin({
+      client: { session: sessionApi },
+      directory: projectDir,
+      worktree: projectDir,
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    try {
+      await retryHooks?.['chat.message']?.(
+        { sessionID: 'orphan-a', agent: 'fixer' } as never,
+        {} as never,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 10));
+      expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
+
+      // A later activation must retry: the failed slot was released.
+      await retryHooks?.['chat.message']?.(
+        { sessionID: 'orphan-a', agent: 'fixer' } as never,
+        {} as never,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 10));
+      // 1st: 503 (released). 2nd: retry yields the parent. 3rd: confirms
+      // real-root has no further parent (walk to a confirmed root).
+      expect(attempts).toBe(3);
+      expect(readTuiSnapshot(projectDir).sessionParents['orphan-a']).toBe(
+        'real-root',
+      );
+    } finally {
+      await retryHooks?.dispose?.();
+    }
+  });
+
+  test('does not cache a malformed parentID as a confirmed root', async () => {
+    let attempts = 0;
+    const sessionApi = {
+      async get(input: { path: { id: string } }) {
+        attempts += 1;
+        if (attempts === 1) {
+          // Malformed non-string parent: contract violation, not a root.
+          return { data: { parentID: 123 } };
+        }
+        return { data: { parentID: 'fixed-root' } };
+      },
+    };
+    const malformedHooks = await plugin({
+      client: { session: sessionApi },
+      directory: projectDir,
+      worktree: projectDir,
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    try {
+      await malformedHooks?.['chat.message']?.(
+        { sessionID: 'broken-a', agent: 'fixer' } as never,
+        {} as never,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 10));
+      expect(readTuiSnapshot(projectDir).sessionParents).toEqual({});
+
+      // A later activation must retry: the malformed slot was released.
+      await malformedHooks?.['chat.message']?.(
+        { sessionID: 'broken-a', agent: 'fixer' } as never,
+        {} as never,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 10));
+      expect(attempts).toBeGreaterThanOrEqual(2);
+      expect(readTuiSnapshot(projectDir).sessionParents['broken-a']).toBe(
+        'fixed-root',
+      );
+    } finally {
+      await malformedHooks?.dispose?.();
+    }
   test('chat.message does not light a spinner without session.status busy', async () => {
   test('chat.message does not light a spinner without session.status busy', async () => {
     await hooks?.['chat.message']?.(
     await hooks?.['chat.message']?.(
       { sessionID: 'orch', agent: 'orchestrator' } as never,
       { sessionID: 'orch', agent: 'orchestrator' } as never,

+ 89 - 0
src/index.ts

@@ -77,9 +77,11 @@ import {
 } from './tools/task-activity';
 } from './tools/task-activity';
 import {
 import {
   clearTuiAgentActivities,
   clearTuiAgentActivities,
+  readTuiSnapshot,
   recordTuiAgentActivity,
   recordTuiAgentActivity,
   recordTuiAgentModel,
   recordTuiAgentModel,
   recordTuiAgentModels,
   recordTuiAgentModels,
+  recordTuiSessionParent,
 } from './tui-state';
 } from './tui-state';
 import {
 import {
   BackgroundJobBoard,
   BackgroundJobBoard,
@@ -213,10 +215,85 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const tuiActivityDirectory = (sessionID: string): string => {
   const tuiActivityDirectory = (sessionID: string): string => {
     return sessionMetadata.getDirectory(sessionID) ?? ctx.directory;
     return sessionMetadata.getDirectory(sessionID) ?? ctx.directory;
   };
   };
+  // Sidebar activity scoping (#1147): every active session and the visible
+  // route session resolve their conversation root against the persistent
+  // sessionParents index at render time. The recorder only persists the
+  // 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): void => {
     const directory = tuiActivityDirectory(sessionID);
     const directory = tuiActivityDirectory(sessionID);
     recordTuiAgentActivity({ sessionID, agentName, active: true }, directory);
     recordTuiAgentActivity({ sessionID, agentName, active: true }, directory);
     ownedTuiActivitySessions.set(sessionID, directory);
     ownedTuiActivitySessions.set(sessionID, directory);
+    void hydrateTuiSessionParent(sessionID, directory);
+  };
+  // Sessions that predate this fix or whose session.created event was
+  // missed have no link in the persistent index. Ask the host once per
+  // session and walk up to a confirmed root; absence of parentID on a
+  // valid response is a final answer (top-level chat).
+  const hydratedTuiParents = new Set<string>();
+  const hydrateTuiSessionParent = async (
+    startSessionID: string,
+    directory: string,
+  ): Promise<void> => {
+    const sessionApi = (ctx as { client?: { session?: { get?: unknown } } })
+      .client?.session;
+    if (typeof sessionApi?.get !== 'function') return;
+    const lookup = sessionApi.get as (input: {
+      path: { id: string };
+      query: { directory: string };
+    }) => Promise<{ data?: unknown; error?: unknown; parentID?: unknown }>;
+    const visited = new Set<string>();
+    let current = startSessionID;
+    while (!visited.has(current)) {
+      visited.add(current);
+      const snapshot = readTuiSnapshot(directory);
+      const known = snapshot.sessionParents[current];
+      if (known !== undefined) {
+        current = known; // Persisted link; keep walking toward the root.
+        continue;
+      }
+      if (hydratedTuiParents.has(current)) return;
+      hydratedTuiParents.add(current);
+      let parentID: unknown;
+      try {
+        // Call with the session object as receiver: the SDK's generated
+        // method reads `this._client` (#595 class of regression).
+        const response = await lookup.call(sessionApi, {
+          path: { id: current },
+          query: { directory },
+        });
+        if (response?.error !== undefined) {
+          // HTTP error resolved instead of thrown: release the slot so a
+          // later activity can retry.
+          hydratedTuiParents.delete(current);
+          return;
+        }
+        const info = response?.data;
+        if (info === null || typeof info !== 'object') {
+          // Malformed response outside the host contract: release the
+          // slot rather than caching "confirmed root" on garbage.
+          hydratedTuiParents.delete(current);
+          return;
+        }
+        parentID = (info as { parentID?: unknown }).parentID;
+      } catch {
+        hydratedTuiParents.delete(current);
+        return;
+      }
+      if (typeof parentID === 'string' && parentID !== current) {
+        recordTuiSessionParent(current, parentID, directory);
+        current = parentID;
+        continue;
+      }
+      if (parentID !== undefined && parentID !== null) {
+        // Malformed non-string parent: release the slot so a later
+        // activity can retry instead of caching a false confirmed root.
+        hydratedTuiParents.delete(current);
+      }
+      // Valid response without a parent: confirmed root, stop.
+      return;
+    }
   };
   };
   const markTuiAgentInactive = (sessionID: string): void => {
   const markTuiAgentInactive = (sessionID: string): void => {
     const directory =
     const directory =
@@ -1238,6 +1315,18 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       if (event.type === 'session.created') {
       if (event.type === 'session.created') {
         const createdSessionId = event.properties?.info?.id;
         const createdSessionId = event.properties?.info?.id;
         const createdSessionDir = event.properties?.info?.directory;
         const createdSessionDir = event.properties?.info?.directory;
+        const createdSessionParent = (
+          event.properties as { info?: { parentID?: unknown } } | undefined
+        )?.info?.parentID;
+        if (createdSessionId && typeof createdSessionParent === 'string') {
+          // Persist the child→parent link so any process can resolve the
+          // conversation root, surviving restarts and revives (#1147).
+          recordTuiSessionParent(
+            createdSessionId,
+            createdSessionParent,
+            createdSessionDir ?? ctx.directory,
+          );
+        }
         if (createdSessionId && createdSessionDir) {
         if (createdSessionId && createdSessionDir) {
           sessionMetadata.setDirectory(createdSessionId, createdSessionDir);
           sessionMetadata.setDirectory(createdSessionId, createdSessionDir);
         }
         }

+ 83 - 0
src/tui-state.test.ts

@@ -9,6 +9,8 @@ import {
   recordTuiAgentActivity,
   recordTuiAgentActivity,
   recordTuiAgentModel,
   recordTuiAgentModel,
   recordTuiAgentModels,
   recordTuiAgentModels,
+  recordTuiSessionParent,
+  resolveTuiSessionRoot,
 } from './tui-state';
 } from './tui-state';
 
 
 let previousXdgDataHome: string | undefined;
 let previousXdgDataHome: string | undefined;
@@ -270,6 +272,87 @@ describe('tui-state persistence', () => {
     expect(fs.existsSync(lockPath)).toBe(false);
     expect(fs.existsSync(lockPath)).toBe(false);
   });
   });
 
 
+  test('scopes recorded activity to the owning conversation tree', () => {
+    recordTuiSessionParent('child-a', 'conv-1', tempDir);
+    recordTuiSessionParent('child-b', 'conv-2', tempDir);
+    recordTuiAgentActivity(
+      { sessionID: 'child-a', agentName: 'oracle', active: true },
+      tempDir,
+    );
+    recordTuiAgentActivity(
+      { sessionID: 'child-b', agentName: 'fixer', active: true },
+      tempDir,
+    );
+
+    // Roots resolve through the persistent index; render compares both
+    // sides against it, so conv-2's subagents stay out of conv-1 (#1147).
+    expect(resolveTuiSessionRoot('child-a', tempDir)).toBe('conv-1');
+    expect(resolveTuiSessionRoot('child-b', tempDir)).toBe('conv-2');
+    expect(readTuiSnapshot(tempDir).activeSessions).toEqual({
+      'child-a': 'oracle',
+      'child-b': 'fixer',
+    });
+  });
+
+  test('resolves multi-level ancestry through the index', () => {
+    recordTuiSessionParent('grandchild', 'child', tempDir);
+    recordTuiSessionParent('child', 'root', tempDir);
+
+    expect(resolveTuiSessionRoot('grandchild', tempDir)).toBe('root');
+    expect(resolveTuiSessionRoot('child', tempDir)).toBe('root');
+    expect(resolveTuiSessionRoot('root', tempDir)).toBe('root');
+  });
+
+  test('re-roots a live activity when ancestry is discovered late', () => {
+    // Revived/restarted session recorded before its parent was known.
+    recordTuiAgentActivity(
+      { sessionID: 'child-a', agentName: 'oracle', active: true },
+      tempDir,
+    );
+    expect(resolveTuiSessionRoot('child-a', tempDir)).toBe('child-a');
+
+    recordTuiSessionParent('child-a', 'root-a', tempDir);
+
+    expect(readTuiSnapshot(tempDir).sessionParents['child-a']).toBe('root-a');
+    expect(resolveTuiSessionRoot('child-a', tempDir)).toBe('root-a');
+  });
+
+  test('startup sweep keeps live foreign activities and drops dead residue', async () => {
+    const dead = Bun.spawn(['true']);
+    await dead.exited;
+    const live = Bun.spawn(['sleep', '30']);
+    try {
+      const statePath = getTuiStatePath(tempDir);
+      fs.mkdirSync(path.dirname(statePath), { recursive: true });
+      fs.writeFileSync(
+        statePath,
+        `${JSON.stringify({
+          version: 1,
+          updatedAt: Date.now(),
+          agentModels: {},
+          agentVariants: {},
+          activeSessions: {
+            'dead-a': 'oracle',
+            'live-b': 'fixer',
+            'legacy-c': 'explorer',
+          },
+          activityPids: {
+            'dead-a': dead.pid,
+            'live-b': live.pid,
+          },
+        })}\n`,
+      );
+
+      clearTuiAgentActivities(tempDir);
+
+      expect(readTuiSnapshot(tempDir).activeSessions).toEqual({
+        'live-b': 'fixer',
+      });
+    } finally {
+      live.kill();
+    }
+  });
+
   test('clears persisted activity while preserving model state', () => {
   test('clears persisted activity while preserving model state', () => {
     recordTuiAgentModels(
     recordTuiAgentModels(
       { agentModels: { explorer: 'openai/gpt-5.6-luna' } },
       { agentModels: { explorer: 'openai/gpt-5.6-luna' } },

+ 104 - 2
src/tui-state.ts

@@ -9,6 +9,20 @@ export interface TuiSnapshot {
   agentModels: Record<string, string>;
   agentModels: Record<string, string>;
   agentVariants: Record<string, string>;
   agentVariants: Record<string, string>;
   activeSessions: Record<string, string>;
   activeSessions: Record<string, string>;
+  /**
+   * Recording process per activity; used to sweep crash residue.
+   */
+  activityPids: Record<string, number>;
+  /**
+   * Persistent child→parent index for the project, independent of live
+   * activities. The single authority for scoping: both the visible route
+   * session and every active session resolve their conversation root
+   * against this same index at render time (#1147), so a late-learned
+   * link re-roots everything consistently. Shared v2 daemons record
+   * activities for every window from one process, so process identity
+   * cannot scope the sidebar; the session tree can.
+   */
+  sessionParents: Record<string, string>;
 }
 }
 
 
 const STATE_DIR = 'oh-my-opencode-slim';
 const STATE_DIR = 'oh-my-opencode-slim';
@@ -55,9 +69,29 @@ function emptySnapshot(): TuiSnapshot {
     agentModels: {},
     agentModels: {},
     agentVariants: {},
     agentVariants: {},
     activeSessions: {},
     activeSessions: {},
+    activityPids: {},
+    sessionParents: {},
   };
   };
 }
 }
 
 
+function parseStringRecord(value: unknown): Record<string, string> {
+  if (value === null || typeof value !== 'object') return {};
+  const out: Record<string, string> = {};
+  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
+    if (typeof entry === 'string') out[key] = entry;
+  }
+  return out;
+}
+
+function parsePidRecord(value: unknown): Record<string, number> {
+  if (value === null || typeof value !== 'object') return {};
+  const out: Record<string, number> = {};
+  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
+    if (typeof entry === 'number' && entry > 0) out[key] = entry;
+  }
+  return out;
+}
+
 function parseSnapshot(value: string): TuiSnapshot {
 function parseSnapshot(value: string): TuiSnapshot {
   const parsed = JSON.parse(value) as Partial<TuiSnapshot> | undefined;
   const parsed = JSON.parse(value) as Partial<TuiSnapshot> | undefined;
   if (parsed?.version !== 1) return emptySnapshot();
   if (parsed?.version !== 1) return emptySnapshot();
@@ -69,6 +103,8 @@ function parseSnapshot(value: string): TuiSnapshot {
     agentModels: parsed.agentModels ?? {},
     agentModels: parsed.agentModels ?? {},
     agentVariants: parsed.agentVariants ?? {},
     agentVariants: parsed.agentVariants ?? {},
     activeSessions: parsed.activeSessions ?? {},
     activeSessions: parsed.activeSessions ?? {},
+    activityPids: parsePidRecord(parsed.activityPids),
+    sessionParents: parseStringRecord(parsed.sessionParents),
   };
   };
 }
 }
 
 
@@ -250,6 +286,8 @@ function cloneSnapshot(snapshot: TuiSnapshot): TuiSnapshot {
     agentModels: { ...snapshot.agentModels },
     agentModels: { ...snapshot.agentModels },
     agentVariants: { ...snapshot.agentVariants },
     agentVariants: { ...snapshot.agentVariants },
     activeSessions: { ...snapshot.activeSessions },
     activeSessions: { ...snapshot.activeSessions },
+    activityPids: { ...snapshot.activityPids },
+    sessionParents: { ...snapshot.sessionParents },
   };
   };
 }
 }
 
 
@@ -257,7 +295,9 @@ function snapshotSectionsEqual(a: TuiSnapshot, b: TuiSnapshot): boolean {
   return (
   return (
     JSON.stringify(a.agentModels) === JSON.stringify(b.agentModels) &&
     JSON.stringify(a.agentModels) === JSON.stringify(b.agentModels) &&
     JSON.stringify(a.agentVariants) === JSON.stringify(b.agentVariants) &&
     JSON.stringify(a.agentVariants) === JSON.stringify(b.agentVariants) &&
-    JSON.stringify(a.activeSessions) === JSON.stringify(b.activeSessions)
+    JSON.stringify(a.activeSessions) === JSON.stringify(b.activeSessions) &&
+    JSON.stringify(a.activityPids) === JSON.stringify(b.activityPids) &&
+    JSON.stringify(a.sessionParents) === JSON.stringify(b.sessionParents)
   );
   );
 }
 }
 
 
@@ -375,14 +415,76 @@ export function recordTuiAgentActivity(
   updateSnapshot(projectDir, (snapshot) => {
   updateSnapshot(projectDir, (snapshot) => {
     if (input.active) {
     if (input.active) {
       snapshot.activeSessions[input.sessionID] = input.agentName;
       snapshot.activeSessions[input.sessionID] = input.agentName;
+      snapshot.activityPids[input.sessionID] = process.pid;
     } else {
     } else {
       delete snapshot.activeSessions[input.sessionID];
       delete snapshot.activeSessions[input.sessionID];
+      delete snapshot.activityPids[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.
 export function clearTuiAgentActivities(projectDir: string): void {
 export function clearTuiAgentActivities(projectDir: string): void {
   updateSnapshot(projectDir, (snapshot) => {
   updateSnapshot(projectDir, (snapshot) => {
-    snapshot.activeSessions = {};
+    for (const sessionID of Object.keys(snapshot.activeSessions)) {
+      const pid = snapshot.activityPids[sessionID];
+      if (pid === undefined || pid === process.pid || !isProcessRunning(pid)) {
+        delete snapshot.activeSessions[sessionID];
+        delete snapshot.activityPids[sessionID];
+      }
+    }
+  });
+}
+
+/**
+ * Record a child→parent link so any process (recorder or TUI) can resolve
+ * a session to its conversation root, surviving restarts and revives
+ * (#1147). Roots are never stored per-activity: render resolves the
+ * visible session and every active session against this same index, so a
+ * late-learned link re-roots everything consistently.
+ */
+export function recordTuiSessionParent(
+  sessionID: string,
+  parentID: string,
+  projectDir: string,
+): void {
+  updateSnapshot(projectDir, (snapshot) => {
+    snapshot.sessionParents[sessionID] = parentID;
   });
   });
 }
 }
+
+/** Resolve a session to its conversation root via the persistent index. */
+export function resolveTuiSessionRoot(
+  sessionID: string,
+  projectDir: string,
+): string {
+  return resolveSnapshotRoot(readTuiSnapshot(projectDir), sessionID);
+}
+
+/**
+ * Root of a session according to an already-loaded snapshot. Used by the
+ * render side: the visible route session (possibly a child) must be
+ * compared against activity roots, not against itself (#1147).
+ */
+export function resolveTuiSnapshotRoot(
+  snapshot: TuiSnapshot,
+  sessionID: string,
+): string {
+  return resolveSnapshotRoot(snapshot, sessionID);
+}
+
+function resolveSnapshotRoot(snapshot: TuiSnapshot, sessionID: string): string {
+  let current = sessionID;
+  const seen = new Set<string>();
+  while (!seen.has(current)) {
+    seen.add(current);
+    const parent = snapshot.sessionParents[current];
+    if (!parent || parent === current) break;
+    current = parent;
+  }
+  return current;
+}

+ 36 - 0
src/tui.test.ts

@@ -36,11 +36,47 @@ function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
     agentModels: {},
     agentModels: {},
     agentVariants: {},
     agentVariants: {},
     activeSessions: {},
     activeSessions: {},
+    activityPids: {},
+    sessionParents: {},
     ...overrides,
     ...overrides,
   };
   };
 }
 }
 
 
 describe('tui sidebar agents', () => {
 describe('tui sidebar agents', () => {
+  test('scopes active agents to the visible conversation (#1147)', () => {
+    const snapshot = createSnapshot({
+      activeSessions: { 'c1-oracle': 'oracle', 'c2-fixer': 'fixer' },
+      sessionParents: { 'c1-oracle': 'conv-1', 'c2-fixer': 'conv-2' },
+    });
+
+    expect(getActiveSidebarAgentNames(snapshot, 'conv-1')).toEqual(
+      new Set(['oracle']),
+    );
+    expect(getActiveSidebarAgentNames(snapshot, 'conv-2')).toEqual(
+      new Set(['fixer']),
+    );
+    // Home route: no visible conversation, keep the union.
+    expect(getActiveSidebarAgentNames(snapshot)).toEqual(
+      new Set(['oracle', 'fixer']),
+    );
+  });
+
+  test('navigating into a child route keeps its own spinner visible', () => {
+    const snapshot = createSnapshot({
+      activeSessions: { 'child-a': 'oracle' },
+      sessionParents: { 'child-a': 'root-a' },
+    });
+
+    // Route points at the child; it must resolve to its root before
+    // filtering, otherwise its own spinner disappears (#1147).
+    expect(getActiveSidebarAgentNames(snapshot, 'child-a')).toEqual(
+      new Set(['oracle']),
+    );
+    expect(getActiveSidebarAgentNames(snapshot, 'root-a')).toEqual(
+      new Set(['oracle']),
+    );
+  });
+
   test('hides disabled agents when models are persisted explicitly', () => {
   test('hides disabled agents when models are persisted explicitly', () => {
     const agentNames = getSidebarAgentNames(
     const agentNames = getSidebarAgentNames(
       createSnapshot({
       createSnapshot({

+ 45 - 4
src/tui.ts

@@ -21,6 +21,7 @@ import { openPresetManager } from './tui-preset';
 import {
 import {
   readTuiSnapshot,
   readTuiSnapshot,
   readTuiSnapshotAsync,
   readTuiSnapshotAsync,
+  resolveTuiSnapshotRoot,
   type TuiSnapshot,
   type TuiSnapshot,
 } from './tui-state';
 } from './tui-state';
 import { isPluginDisabledByEnv } from './utils/env';
 import { isPluginDisabledByEnv } from './utils/env';
@@ -371,8 +372,31 @@ export function isRefreshCurrent(
 
 
 export function getActiveSidebarAgentNames(
 export function getActiveSidebarAgentNames(
   snapshot: TuiSnapshot,
   snapshot: TuiSnapshot,
+  visibleRootID?: string,
 ): ReadonlySet<string> {
 ): ReadonlySet<string> {
-  return new Set(Object.values(snapshot.activeSessions));
+  const names = new Set<string>();
+  // Both sides resolve against the same persistent sessionParents index:
+  // the visible route session (possibly a child) to its root, and every
+  // active session to its root. This keeps spinners scoped to the
+  // conversation this window is viewing (#1147) — shared v2 daemons record
+  // every window's subagents from one process, so only the session tree
+  // can separate them — and a late-learned link re-roots both sides
+  // consistently. Without a visible session (home route) keep the union.
+  const root =
+    visibleRootID === undefined
+      ? undefined
+      : resolveTuiSnapshotRoot(snapshot, visibleRootID);
+  for (const [sessionID, agentName] of Object.entries(
+    snapshot.activeSessions,
+  )) {
+    if (
+      root === undefined ||
+      resolveTuiSnapshotRoot(snapshot, sessionID) === root
+    ) {
+      names.add(agentName);
+    }
+  }
+  return names;
 }
 }
 
 
 export function getSidebarActivityIndicator(
 export function getSidebarActivityIndicator(
@@ -540,9 +564,10 @@ function renderSidebar(
   configInvalid: boolean,
   configInvalid: boolean,
   compactSidebar: boolean,
   compactSidebar: boolean,
   now = Date.now(),
   now = Date.now(),
+  visibleRootID?: string,
 ): JSX.Element {
 ): JSX.Element {
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
-  const activeAgents = getActiveSidebarAgentNames(snapshot);
+  const activeAgents = getActiveSidebarAgentNames(snapshot, visibleRootID);
   return box(
   return box(
     {
     {
       width: '100%',
       width: '100%',
@@ -750,11 +775,18 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
   scheduleRefresh();
   scheduleRefresh();
   const renderTimer = setInterval(scheduleRefresh, 1000);
   const renderTimer = setInterval(scheduleRefresh, 1000);
   const animationTimer = setInterval(() => {
   const animationTimer = setInterval(() => {
-    if (!disposed && Object.keys(snapshot().activeSessions).length > 0) {
+    // Same scoping as the render: hidden foreign-conversation activity
+    // must not keep this window's sidebar rerendering every frame.
+    if (
+      !disposed &&
+      getActiveSidebarAgentNames(snapshot(), visibleSession()).size > 0
+    ) {
       setAnimationNow(Date.now());
       setAnimationNow(Date.now());
     }
     }
   }, ACTIVITY_FRAME_MS);
   }, ACTIVITY_FRAME_MS);
 
 
+  const visibleSession = () => resolveRouteSessionId(ctx.ui.router.current());
+
   const disposeSlot = ctx.ui.slot({
   const disposeSlot = ctx.ui.slot({
     append: 'sidebar.content',
     append: 'sidebar.content',
     render: () =>
     render: () =>
@@ -766,6 +798,7 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
           configInvalid,
           configInvalid,
           compactSidebar,
           compactSidebar,
           animationNow(),
           animationNow(),
+          visibleSession(),
         ),
         ),
       ),
       ),
   });
   });
@@ -855,7 +888,14 @@ const plugin: TuiDualContractModule = {
     scheduleRefresh();
     scheduleRefresh();
     const renderTimer = setInterval(scheduleRefresh, 1000);
     const renderTimer = setInterval(scheduleRefresh, 1000);
     const animationTimer = setInterval(() => {
     const animationTimer = setInterval(() => {
-      if (Object.keys(snapshot().activeSessions).length > 0) {
+      // Same scoping as the render: hidden foreign-conversation activity
+      // must not keep this window's sidebar rerendering every frame.
+      if (
+        getActiveSidebarAgentNames(
+          snapshot(),
+          resolveRouteSessionId(api.route.current),
+        ).size > 0
+      ) {
         setAnimationNow(Date.now());
         setAnimationNow(Date.now());
       }
       }
     }, ACTIVITY_FRAME_MS);
     }, ACTIVITY_FRAME_MS);
@@ -878,6 +918,7 @@ const plugin: TuiDualContractModule = {
               configInvalid,
               configInvalid,
               compactSidebar,
               compactSidebar,
               animationNow(),
               animationNow(),
+              resolveRouteSessionId(api.route.current),
             ),
             ),
           );
           );
         },
         },