diff --git a/docs/ai/design/2026-08-14-feature-console-fast-initial-state.md b/docs/ai/design/2026-08-14-feature-console-fast-initial-state.md new file mode 100644 index 00000000..8537025f --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-fast-initial-state.md @@ -0,0 +1,36 @@ +--- +phase: design +title: Agent Console Fast Initial State Design +description: Synchronous cached identity snapshot with console stale-while-revalidate state +--- + +# Agent Console Fast Initial State Design + +## Data Flow + +```mermaid +flowchart LR + Registry[(AgentRegistry)] -->|sync live-PID identities| Snapshot[AgentManager.getCachedAgentSnapshot] + Snapshot -->|first render: unknown + cached| Console[useAgentList] + Console -->|async| Discovery[AgentManager.listAgents] + Discovery -->|one state update| Console + Console -->|live rows, cache markers cleared| UI[Agent list + footer] +``` + +## API Decision + +Add `AgentManager.getCachedAgentSnapshot(): CachedAgentSnapshot[]`. The snapshot contains identity and registry metadata only: registered name, type, PID, cwd, start time, session ID, and optional session path. It filters out dead PIDs and types without a registered adapter and never invokes adapter discovery. + +The alternative of returning `AgentInfo[]` was rejected because the registry cannot defensibly populate live `status`, `summary`, or `lastActive`. The console adapts snapshots into temporary `AgentInfo` placeholders with `status: unknown`, an empty summary, and explicit cached metadata kept separately in `cachedAgentPids`. + +## Reconciliation and Errors + +- `useState` uses a lazy synchronous initializer, so the first render cannot await discovery. +- The existing effect immediately calls `listAgents({ sortBy: 'status' })` and keeps the 3000 ms interval and in-flight guard unchanged. +- Successful discovery replaces the entire cached array and clears `cachedAgentPids` in one state update, including an empty result. +- A rejected refresh retains cached rows, clears the refreshing flag, and exposes the error. +- Existing name-based selection remains selected when the registered name survives live reconciliation; otherwise the existing selection effect chooses the first live row or clears selection. + +## Merge/Overlap Risk + +The responsiveness work landed in `main` first through the preview memoization, input isolation, and split-context changes (#156–#158). This branch was rebased onto that sequence. The resolution preserves its separate agent/channel contexts and adds only `isRefreshing` and `cachedAgentPids` to the agent context, alongside the lazy cached initializer and atomic cache-marker clearing. For backports or alternate merge orders, preserve the responsiveness scheduling/context structure and never reintroduce adapter discovery on the render path. diff --git a/docs/ai/implementation/2026-08-14-feature-console-fast-initial-state.md b/docs/ai/implementation/2026-08-14-feature-console-fast-initial-state.md new file mode 100644 index 00000000..a12262cd --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-console-fast-initial-state.md @@ -0,0 +1,34 @@ +--- +phase: implementation +title: Agent Console Fast Initial State Implementation +description: Implementation notes for cached first-frame rendering and live reconciliation +--- + +# Agent Console Fast Initial State Implementation + +## Changed Areas + +- `AgentManager.ts` exposes a synchronous, read-only cached identity snapshot filtered by adapter registration and PID liveness. +- `useAgentList.ts` converts snapshots to `unknown` placeholders in its lazy initial state, tracks cached PIDs and initial refreshing state, and clears both atomically on live success. +- `AgentListPane.tsx` marks cached rows, uses cwd rather than a fabricated summary, and keeps refresh errors visible alongside retained cached rows. +- `StatusFooter.tsx` distinguishes cached refresh, cached refresh failure, initial no-cache loading, and normal live update states. +- `PreviewPane.tsx` replaces registry-derived relative time with an explicit cached refresh label until live reconciliation. + +## Compatibility + +`listAgents()`, registry writes/pruning, sorting, adapter error handling, polling cadence, refresh in-flight suppression, and every non-console caller remain unchanged. No schema or dependency change is introduced. + +## Merge Note + +The responsiveness changes landed first in `main`. This branch was rebased onto #156–#158; the conflict resolution keeps the split agent/channel contexts and preview memoization, then adds cached-list metadata only to the agent context. Backports should keep that ordering and retain this branch's synchronous snapshot initializer plus atomic live replacement. + +## Validation Evidence + +- Focused manager contract: 34/34 tests passed. +- Focused console stale-while-revalidate/UI: 19/19 tests passed. +- Full agent-manager: 24 files, 504/504 tests passed (`--maxWorkers=1`; process-identity permission enabled for the existing print integration). +- Full CLI after rebasing onto the split-context changes: 83 files, 974/974 tests passed. +- Agent-manager lint, typecheck, and build passed. +- CLI lint passed with five pre-existing warnings and zero errors; CLI build passed. +- Feature docs lint passed. +- Pull request: https://github.com/codeaholicguy/ai-devkit/pull/162 diff --git a/docs/ai/planning/2026-08-14-feature-console-fast-initial-state.md b/docs/ai/planning/2026-08-14-feature-console-fast-initial-state.md new file mode 100644 index 00000000..86c3fdef --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-fast-initial-state.md @@ -0,0 +1,17 @@ +--- +phase: planning +title: Agent Console Fast Initial State Plan +description: Test-first implementation tasks for cached first-frame rendering +--- + +# Agent Console Fast Initial State Plan + +- [x] Add a failing manager test for a synchronous, filtered cached identity snapshot. +- [x] Add deterministic failing Ink hook tests using an unresolved live-list promise. +- [x] Implement the additive cached snapshot API without changing `listAgents()`. +- [x] Seed `useAgentList` synchronously and reconcile atomically on live success. +- [x] Add failing UI render tests for cached, refreshing, failed-refresh, and visible-error labels. +- [x] Implement cached list/footer representation. +- [x] Document merge-order and overlap risks with `feature-console-main-thread-responsiveness`. +- [x] Run focused and full agent-manager/CLI tests, lint, builds, and docs lint. +- [x] Review, commit, push, and open PR #162 targeting `main`. diff --git a/docs/ai/requirements/2026-08-14-feature-console-fast-initial-state.md b/docs/ai/requirements/2026-08-14-feature-console-fast-initial-state.md new file mode 100644 index 00000000..c4d1139a --- /dev/null +++ b/docs/ai/requirements/2026-08-14-feature-console-fast-initial-state.md @@ -0,0 +1,31 @@ +--- +phase: requirements +title: Agent Console Fast Initial State Requirements +description: Render a defensible cached agent list before asynchronous live discovery +--- + +# Agent Console Fast Initial State Requirements + +## Problem + +`agent console` starts with an empty/loading list until every adapter finishes discovery. The persistent agent registry already contains useful identities, but it does not contain trustworthy live status, summary, or last-active data. + +## Goals + +- Render defensible cached agent identities on the console's first frame without awaiting adapter discovery. +- Revalidate immediately and replace the cached list atomically with sorted live results. +- Show that cached rows are cached/refreshing and never claim a live status. +- Preserve registered names, selection behavior, error and empty states, polling cadence, manual refresh, sorting, rename semantics, and non-console `AgentManager.listAgents()` callers. +- Retain cached rows with a visible error if the initial live refresh rejects; remove stale cached rows when a successful live result omits them. + +## Constraints + +- Cached rows must be limited to registry entries whose PID is currently alive and whose adapter type is registered in this manager. +- No wall-clock freshness threshold is used. +- The change must be additive and focused so it can compose with `feature-console-main-thread-responsiveness`. + +## Success Criteria + +- A deliberately unresolved `listAgents()` promise does not prevent cached rows from rendering. +- Tests cover successful reconciliation, stale removal, refresh errors, and no-cache loading behavior. +- Focused and full agent-manager/CLI tests, lint, and builds pass. diff --git a/docs/ai/testing/2026-08-14-feature-console-fast-initial-state.md b/docs/ai/testing/2026-08-14-feature-console-fast-initial-state.md new file mode 100644 index 00000000..5db98204 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-fast-initial-state.md @@ -0,0 +1,24 @@ +--- +phase: testing +title: Agent Console Fast Initial State Testing +description: Deterministic validation matrix for cached first-frame behavior +--- + +# Agent Console Fast Initial State Testing + +## Automated Matrix + +- [x] Manager snapshot preserves registered names and metadata. +- [x] Manager snapshot excludes dead PIDs and types without registered adapters. +- [x] Manager snapshot does not call adapter discovery. +- [x] Cached agents render while the live-list promise remains deliberately unresolved. +- [x] Live results replace cached rows atomically and remove omitted stale rows. +- [x] Live refresh rejection retains cached rows and exposes the error. +- [x] No-cache startup preserves the existing empty loading state and reconciles to live empty. +- [x] Cached rows and footer explicitly say cached/refreshing or cached/refresh failed. +- [x] Cached preview metadata never presents registry start time as live activity. +- [x] Full agent-manager and CLI suites (504 and 974 tests respectively after rebase). +- [x] Agent-manager and CLI lint/build. +- [x] Repository docs lint. + +Tests coordinate async work through controlled promises and render-state observers. They do not use sleeps, elapsed-time assertions, or freshness thresholds. diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 3220cf90..5ee83b2d 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -22,6 +22,21 @@ export interface ListAgentsOptions { sortBy?: AgentSortKey; } +/** + * Registry-backed identity that is safe to show while live adapter discovery + * is still in progress. It deliberately omits live-only status and activity + * fields. + */ +export interface CachedAgentSnapshot { + name: string; + type: AgentInfo['type']; + pid: number; + projectPath: string; + startedAt: Date; + sessionId: string; + sessionFilePath?: string; +} + /** * Agent Manager Class * @@ -105,6 +120,27 @@ export class AgentManager { return this.adapters.has(type); } + /** + * Read live registry identities synchronously without invoking adapters. + * + * Rows are limited to registered adapter types and PIDs that currently + * exist. Callers must still treat them as cached: this method does not + * claim a live agent status, summary, or last-active timestamp. + */ + getCachedAgentSnapshot(): CachedAgentSnapshot[] { + return this.registry.list() + .filter(entry => this.adapters.has(entry.type) && this.registry.isAlive(entry)) + .map(entry => ({ + name: entry.name, + type: entry.type, + pid: entry.pid, + projectPath: entry.cwd, + startedAt: new Date(entry.startedAt), + sessionId: entry.sessionId, + sessionFilePath: entry.sessionFilePath || undefined, + })); + } + /** * List all running AI agents detected by registered adapters * diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index cf6273ed..42454d35 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -169,6 +169,59 @@ describe('AgentManager', () => { }); }); + describe('getCachedAgentSnapshot', () => { + it('returns only live cached identities for registered adapter types without discovery', () => { + const registry = new AgentRegistry(path.join(tmpDir, 'cached-agents.json')); + const scopedManager = new AgentManager(registry); + const adapter = new MockAdapter('claude'); + const detectSpy = vi.spyOn(adapter, 'detectAgents'); + scopedManager.registerAdapter(adapter); + registry.registerBatch([ + { + name: 'registered-name', + type: 'claude', + pid: process.pid, + tmuxSession: 'registered-name', + cwd: '/repo/cached', + startedAt: '2026-08-14T10:00:00.000Z', + sessionId: 'cached-session', + sessionFilePath: '/sessions/cached.jsonl', + }, + { + name: 'dead-agent', + type: 'claude', + pid: 999999, + tmuxSession: '', + cwd: '/repo/dead', + startedAt: '2026-08-14T09:00:00.000Z', + sessionId: 'dead-session', + sessionFilePath: '', + }, + { + name: 'unsupported-agent', + type: 'codex', + pid: process.pid, + tmuxSession: '', + cwd: '/repo/unsupported', + startedAt: '2026-08-14T08:00:00.000Z', + sessionId: 'unsupported-session', + sessionFilePath: '', + }, + ]); + + expect(scopedManager.getCachedAgentSnapshot()).toEqual([{ + name: 'registered-name', + type: 'claude', + pid: process.pid, + projectPath: '/repo/cached', + startedAt: new Date('2026-08-14T10:00:00.000Z'), + sessionId: 'cached-session', + sessionFilePath: '/sessions/cached.jsonl', + }]); + expect(detectSpy).not.toHaveBeenCalled(); + }); + }); + describe('listAgents', () => { it('should return empty array when no adapters registered', async () => { const agents = await manager.listAgents(); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..c7cc61d0 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -24,7 +24,7 @@ export { TtyWriter } from './terminal/TtyWriter.js'; export { getProcessTty } from './utils/process.js'; export type { AgentSortKey } from './utils/sortAgents.js'; -export type { ListAgentsOptions } from './AgentManager.js'; +export type { CachedAgentSnapshot, ListAgentsOptions } from './AgentManager.js'; export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js'; export type { RegistryEntry } from './utils/AgentRegistry.js'; diff --git a/packages/cli/src/__tests__/tui/console/AgentListPane.test.ts b/packages/cli/src/__tests__/tui/console/AgentListPane.test.ts index e130f112..33263178 100644 --- a/packages/cli/src/__tests__/tui/console/AgentListPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/AgentListPane.test.ts @@ -1,3 +1,5 @@ +import React from 'react'; +import { renderToString } from 'ink'; import { describe, expect, it, vi } from 'vitest'; vi.mock('@ai-devkit/agent-manager', () => ({ @@ -9,7 +11,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ }, })); -import { getAgentChannelMarker } from '../../../tui/console/AgentListPane.js'; +import { AgentListPane, getAgentChannelMarker } from '../../../tui/console/AgentListPane.js'; describe('AgentListPane helpers', () => { it('uses a compact ASCII remote marker for connected agents', () => { @@ -19,4 +21,27 @@ describe('AgentListPane helpers', () => { it('uses blank spacing for disconnected agents', () => { expect(getAgentChannelMarker(undefined)).toBe(' '); }); + + it('marks cached rows and keeps a refresh error visible alongside them', () => { + const output = renderToString(React.createElement(AgentListPane, { + agents: [{ + name: 'cached-agent', + type: 'claude', + status: 'unknown', + summary: '', + pid: 42, + projectPath: '/repo/cached', + sessionId: 'cached-session', + lastActive: new Date('2026-08-14T10:00:00.000Z'), + }], + selectedName: 'cached-agent', + onSelect: vi.fn(), + error: 'adapter unavailable', + cachedAgentPids: new Set([42]), + width: 60, + })); + + expect(output).toContain('cached · /repo/cached'); + expect(output).toContain('adapter unavailable'); + }); }); diff --git a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts index a9b9dc70..854dcd63 100644 --- a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts @@ -115,6 +115,27 @@ describe('PreviewPane helpers', () => { expect(output).not.toContain('assistant │ first answer'); }); + it('labels cached preview metadata without presenting registry time as live activity', () => { + const agent = { + name: 'cached-preview', + type: 'claude', + status: AgentStatus.UNKNOWN, + projectPath: '/tmp/cached', + lastActive: new Date('2026-08-14T10:00:00.000Z'), + } as AgentInfo; + const output = stripVTControlCharacters(renderToString(React.createElement(PreviewPane, { + agent, + messages: [], + error: null, + isLoading: true, + isCached: true, + isRefreshing: true, + }), { columns: 80 })); + + expect(output).toContain('cached · refreshing live state'); + expect(output).not.toContain('10:00'); + }); + it('adjusts positive scroll offsets by newly appended rendered rows', () => { expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 2)).toBe(4); expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 0)).toBe(0); diff --git a/packages/cli/src/__tests__/tui/console/StatusFooter.test.ts b/packages/cli/src/__tests__/tui/console/StatusFooter.test.ts new file mode 100644 index 00000000..02511454 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/StatusFooter.test.ts @@ -0,0 +1,34 @@ +import React from 'react'; +import { renderToString } from 'ink'; +import { describe, expect, it } from 'vitest'; +import { StatusFooter } from '../../../tui/console/StatusFooter.js'; + +describe('StatusFooter cached agent state', () => { + it('describes cached rows as refreshing instead of live', () => { + const output = renderToString(React.createElement(StatusFooter, { + agents: [], + lastUpdated: null, + isLoading: true, + isRefreshing: true, + cachedAgentCount: 2, + narrowNote: null, + transient: null, + })); + + expect(output).toContain('cached · refreshing live state…'); + }); + + it('describes retained cached rows after refresh failure', () => { + const output = renderToString(React.createElement(StatusFooter, { + agents: [], + lastUpdated: null, + isLoading: false, + isRefreshing: false, + cachedAgentCount: 1, + narrowNote: null, + transient: null, + })); + + expect(output).toContain('cached · refresh failed'); + }); +}); diff --git a/packages/cli/src/__tests__/tui/console/hooks/useAgentList.test.ts b/packages/cli/src/__tests__/tui/console/hooks/useAgentList.test.ts new file mode 100644 index 00000000..3e98cc60 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/hooks/useAgentList.test.ts @@ -0,0 +1,225 @@ +import React from 'react'; +import { Writable } from 'node:stream'; +import { Text, render } from 'ink'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AgentStatus, + type AgentInfo, + type AgentManager, +} from '@ai-devkit/agent-manager'; +import { + useAgentList, + type UseAgentListResult, +} from '../../../../tui/console/hooks/useAgentList.js'; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function agent(overrides: Partial = {}): AgentInfo { + return { + name: 'live-agent', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'live summary', + pid: 202, + projectPath: '/repo/live', + sessionId: 'live-session', + lastActive: new Date('2026-08-14T12:00:00.000Z'), + ...overrides, + }; +} + +function createOutput(): { stdout: NodeJS.WriteStream; read(): string } { + let output = ''; + const stdout = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }) as NodeJS.WriteStream; + Object.assign(stdout, { columns: 100, rows: 30, isTTY: true }); + return { stdout, read: () => output }; +} + +function createStateObserver() { + const states: UseAgentListResult[] = []; + const waiters: Array<{ + predicate: (state: UseAgentListResult) => boolean; + resolve: (state: UseAgentListResult) => void; + }> = []; + + return { + observe(state: UseAgentListResult): void { + states.push(state); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + const waiter = waiters[index]; + if (waiter.predicate(state)) { + waiters.splice(index, 1); + waiter.resolve(state); + } + } + }, + waitFor(predicate: (state: UseAgentListResult) => boolean): Promise { + const existing = states.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise(resolve => waiters.push({ predicate, resolve })); + }, + latest(): UseAgentListResult { + const state = states.at(-1); + if (!state) throw new Error('No hook state has rendered.'); + return state; + }, + }; +} + +const mountedApps: Array> = []; + +afterEach(async () => { + for (const app of mountedApps.splice(0)) { + app.unmount(); + await app.waitUntilExit(); + app.cleanup(); + } +}); + +function HookView({ + manager, + observe, +}: { + manager: AgentManager; + observe(state: UseAgentListResult): void; +}) { + const state = useAgentList(manager, 60_000); + observe(state); + const cached = state.cachedAgentPids.size > 0 ? 'cached' : 'live'; + return React.createElement( + Text, + null, + `${state.agents.map(item => item.name).join(',')}|${cached}|${state.isRefreshing ? 'refreshing' : 'settled'}|${state.error ?? ''}`, + ); +} + +function renderHookView(manager: AgentManager, observe: (state: UseAgentListResult) => void) { + const output = createOutput(); + const app = render(React.createElement(HookView, { manager, observe }), { + stdout: output.stdout, + interactive: true, + exitOnCtrlC: false, + patchConsole: false, + }); + mountedApps.push(app); + return { app, output }; +} + +describe('useAgentList stale-while-revalidate', () => { + it('renders cached agents on the first frame, then atomically replaces stale rows with live discovery', async () => { + const liveList = deferred(); + const manager = { + getCachedAgentSnapshot: vi.fn(() => [ + { + name: 'keep-name', + type: 'claude', + pid: 101, + projectPath: '/repo/keep', + startedAt: new Date('2026-08-14T10:00:00.000Z'), + sessionId: 'cached-keep', + sessionFilePath: '/sessions/keep.jsonl', + }, + { + name: 'stale-name', + type: 'claude', + pid: 102, + projectPath: '/repo/stale', + startedAt: new Date('2026-08-14T09:00:00.000Z'), + sessionId: 'cached-stale', + sessionFilePath: '/sessions/stale.jsonl', + }, + ]), + listAgents: vi.fn(() => liveList.promise), + } as unknown as AgentManager; + const observer = createStateObserver(); + const { app, output } = renderHookView(manager, observer.observe); + + await app.waitUntilRenderFlush(); + expect(output.read()).toContain('keep-name,stale-name|cached|refreshing'); + + liveList.resolve([agent({ name: 'keep-name', pid: 101 })]); + const reconciled = await observer.waitFor(state => !state.isRefreshing); + await app.waitUntilRenderFlush(); + + expect(reconciled.agents.map(item => item.name)).toEqual(['keep-name']); + expect(reconciled.cachedAgentPids.size).toBe(0); + expect(output.read()).toContain('keep-name|live|settled'); + }); + + it('keeps cached rows while surfacing a live discovery error', async () => { + const liveList = deferred(); + const retryList = deferred(); + const manager = { + getCachedAgentSnapshot: vi.fn(() => [{ + name: 'cached-agent', + type: 'codex', + pid: 303, + projectPath: '/repo/cached', + startedAt: new Date('2026-08-14T10:00:00.000Z'), + sessionId: 'cached-session', + sessionFilePath: '', + }]), + listAgents: vi.fn() + .mockImplementationOnce(() => liveList.promise) + .mockImplementationOnce(() => retryList.promise), + } as unknown as AgentManager; + const observer = createStateObserver(); + const { app } = renderHookView(manager, observer.observe); + + liveList.reject(new Error('adapter unavailable')); + const failed = await observer.waitFor(state => state.error !== null); + + expect(failed.agents.map(item => item.name)).toEqual(['cached-agent']); + expect(failed.cachedAgentPids).toEqual(new Set([303])); + expect(failed.error).toBe('adapter unavailable'); + expect(failed.isRefreshing).toBe(false); + + const retryPromise = failed.refresh(); + await app.waitUntilRenderFlush(); + const retrying = observer.latest(); + expect(retrying.isRefreshing).toBe(true); + expect(retrying.agents.map(item => item.name)).toEqual(['cached-agent']); + + retryList.resolve([agent({ name: 'cached-agent', pid: 303 })]); + await retryPromise; + const recovered = await observer.waitFor(state => state.error === null && !state.isRefreshing); + expect(recovered.cachedAgentPids.size).toBe(0); + }); + + it('preserves the loading empty state when no cache exists', async () => { + const liveList = deferred(); + const manager = { + getCachedAgentSnapshot: vi.fn(() => []), + listAgents: vi.fn(() => liveList.promise), + } as unknown as AgentManager; + const observer = createStateObserver(); + const { app, output } = renderHookView(manager, observer.observe); + + await app.waitUntilRenderFlush(); + expect(output.read()).toContain('|live|refreshing|'); + + liveList.resolve([]); + const settled = await observer.waitFor(state => !state.isRefreshing); + expect(settled.agents).toEqual([]); + expect(settled.lastUpdated).toBeInstanceOf(Date); + }); +}); diff --git a/packages/cli/src/tui/console/AgentListPane.tsx b/packages/cli/src/tui/console/AgentListPane.tsx index 35529204..09240350 100644 --- a/packages/cli/src/tui/console/AgentListPane.tsx +++ b/packages/cli/src/tui/console/AgentListPane.tsx @@ -14,6 +14,7 @@ interface AgentListPaneProps { height?: number; error?: string | null; channelStatuses?: AgentChannelStatusMap; + cachedAgentPids?: ReadonlySet; } function clip(s: string | undefined, max: number): string { @@ -41,16 +42,19 @@ interface AgentRowProps { isSelected: boolean; innerWidth: number; channelStatus?: AgentChannelStatus; + isCached: boolean; } export function getAgentChannelMarker(channelStatus: AgentChannelStatus | undefined): string { return channelStatus ? CHANNEL_MARKER : CHANNEL_MARKER_EMPTY; } -const AgentRow: React.FC = ({ agent, isSelected, innerWidth, channelStatus }) => { +const AgentRow: React.FC = ({ agent, isSelected, innerWidth, channelStatus, isCached }) => { const nameW = Math.max(4, innerWidth - ROW_CHROME); const summaryW = Math.max(4, innerWidth - MARKER_W); - const rawSummary = agent.summary?.trim() ? agent.summary : shortPath(agent.projectPath); + const rawSummary = isCached + ? `cached · ${shortPath(agent.projectPath)}` + : agent.summary?.trim() ? agent.summary : shortPath(agent.projectPath); const accent = isSelected ? TUI_COLORS.accent : undefined; const typeLabel = AGENT_TYPE_LABEL[agent.type] ?? agent.type; @@ -97,6 +101,7 @@ const AgentListPaneInner: React.FC = ({ height, error, channelStatuses = {}, + cachedAgentPids = new Set(), }) => { const [scrollOffset, setScrollOffset] = useState(0); @@ -153,9 +158,11 @@ const AgentListPaneInner: React.FC = ({ AGENTS ({agents.length}) + {cachedAgentPids.size > 0 && cached} {hasAbove && } {hasMore && } + {error ? {clip(error, innerWidth)} : null} {visibleAgents.map((agent, i) => ( {i > 0 && ( @@ -168,6 +175,7 @@ const AgentListPaneInner: React.FC = ({ isSelected={agent.name === selectedName} innerWidth={innerWidth} channelStatus={channelStatuses[agent.name]} + isCached={cachedAgentPids.has(agent.pid)} /> ))} diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 06a41a90..7132b940 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -112,6 +112,8 @@ const ConsoleAppShell: React.FC<{ error, lastUpdated, isLoading, + isRefreshing, + cachedAgentPids, refresh, } = useConsoleAgentContext(); const { @@ -371,6 +373,7 @@ const ConsoleAppShell: React.FC<{ height={contentHeight - 2} error={error} channelStatuses={channelStatuses} + cachedAgentPids={cachedAgentPids} /> ); @@ -423,6 +426,8 @@ const ConsoleAppShell: React.FC<{ agents={agents} lastUpdated={lastUpdated} isLoading={isLoading} + isRefreshing={isRefreshing} + cachedAgentCount={cachedAgentPids.size} narrowNote={ narrow && !startPaneActive && !renamePaneActive && !channelSelectPaneActive && !memoryListPaneActive && !helpPaneActive ? `resize ≥${NARROW_THRESHOLD_COLS} cols to show preview` diff --git a/packages/cli/src/tui/console/PreviewPane.tsx b/packages/cli/src/tui/console/PreviewPane.tsx index f5fae95f..e404b074 100644 --- a/packages/cli/src/tui/console/PreviewPane.tsx +++ b/packages/cli/src/tui/console/PreviewPane.tsx @@ -13,6 +13,8 @@ interface PreviewPaneProps { messages: ConversationMessage[]; error: ConversationFetchError | null; isLoading: boolean; + isCached?: boolean; + isRefreshing?: boolean; maxLines?: number; channelStatus?: AgentChannelStatus; scrollOffset?: number; @@ -122,7 +124,12 @@ export function getPreviewChannelStatusText(channelStatus: AgentChannelStatus | return channelStatus ? `Connected: ${channelStatus.channelName}` : null; } -const MetadataHeader: React.FC<{ agent: AgentInfo; channelStatus?: AgentChannelStatus }> = ({ agent, channelStatus }) => ( +const MetadataHeader: React.FC<{ + agent: AgentInfo; + channelStatus?: AgentChannelStatus; + isCached: boolean; + isRefreshing: boolean; +}> = ({ agent, channelStatus, isCached, isRefreshing }) => ( PREVIEW · @@ -130,7 +137,11 @@ const MetadataHeader: React.FC<{ agent: AgentInfo; channelStatus?: AgentChannelS · {AGENT_TYPE_LABEL_DISPLAY[agent.type] ?? agent.type} · - {formatRelative(agent.lastActive)} + + {isCached + ? `cached · ${isRefreshing ? 'refreshing live state' : 'refresh failed'}` + : formatRelative(agent.lastActive)} + · {shortPath(agent.projectPath)} {channelStatus ? ( @@ -147,6 +158,8 @@ const PreviewPaneInner: React.FC = ({ messages, error, isLoading, + isCached = false, + isRefreshing = false, maxLines = 22, channelStatus, scrollOffset = 0, @@ -228,7 +241,12 @@ const PreviewPaneInner: React.FC = ({ return ( - + {body} diff --git a/packages/cli/src/tui/console/PreviewSection.tsx b/packages/cli/src/tui/console/PreviewSection.tsx index 425a66db..fa184ff2 100644 --- a/packages/cli/src/tui/console/PreviewSection.tsx +++ b/packages/cli/src/tui/console/PreviewSection.tsx @@ -23,7 +23,13 @@ const PreviewSectionInner: React.FC = ({ scrollOffset = 0, onScrollOffsetClamp, }) => { - const { agents, manager, inputFocused } = useConsoleAgentContext(); + const { + agents, + manager, + inputFocused, + cachedAgentPids, + isRefreshing, + } = useConsoleAgentContext(); const { channelStatuses } = useConsoleChannelContext(); const selectedAgent = useMemo( () => agents.find(a => a.name === selectedName) ?? null, @@ -51,6 +57,8 @@ const PreviewSectionInner: React.FC = ({ messages={messages} error={error} isLoading={isLoading} + isCached={selectedAgent ? cachedAgentPids.has(selectedAgent.pid) : false} + isRefreshing={isRefreshing} maxLines={Math.max(4, height - 2)} channelStatus={channelStatus} scrollOffset={scrollOffset} diff --git a/packages/cli/src/tui/console/StatusFooter.tsx b/packages/cli/src/tui/console/StatusFooter.tsx index a3e7ea94..42e67117 100644 --- a/packages/cli/src/tui/console/StatusFooter.tsx +++ b/packages/cli/src/tui/console/StatusFooter.tsx @@ -9,6 +9,8 @@ interface StatusFooterProps { agents: AgentInfo[]; lastUpdated: Date | null; isLoading: boolean; + isRefreshing: boolean; + cachedAgentCount: number; narrowNote: string | null; transient: { kind: 'info' | 'error'; text: string } | null; } @@ -17,6 +19,8 @@ const StatusFooterInner: React.FC = ({ agents, lastUpdated, isLoading, + isRefreshing, + cachedAgentCount, narrowNote, transient, }) => { @@ -34,9 +38,14 @@ const StatusFooterInner: React.FC = ({ `${counts[AgentStatus.IDLE]} idle`, ].join(' · '); - const updated = isLoading && !lastUpdated - ? 'loading…' - : `updated ${lastUpdated ? formatRelative(lastUpdated) : '—'}`; + let updated: string; + if (cachedAgentCount > 0) { + updated = isRefreshing ? 'cached · refreshing live state…' : 'cached · refresh failed'; + } else { + updated = isLoading && !lastUpdated + ? 'loading…' + : `updated ${lastUpdated ? formatRelative(lastUpdated) : '—'}`; + } return ( diff --git a/packages/cli/src/tui/console/hooks/useAgentList.ts b/packages/cli/src/tui/console/hooks/useAgentList.ts index 22a13786..e005ebbc 100644 --- a/packages/cli/src/tui/console/hooks/useAgentList.ts +++ b/packages/cli/src/tui/console/hooks/useAgentList.ts @@ -1,11 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { AgentInfo, AgentManager } from '@ai-devkit/agent-manager'; +import { + AgentStatus, + type AgentInfo, + type AgentManager, + type CachedAgentSnapshot, +} from '@ai-devkit/agent-manager'; export interface UseAgentListResult { agents: AgentInfo[]; error: string | null; lastUpdated: Date | null; isLoading: boolean; + isRefreshing: boolean; + cachedAgentPids: ReadonlySet; refresh: () => Promise; } @@ -13,6 +20,37 @@ type AgentListState = Omit; export const LIST_POLL_INTERVAL_MS = 3000; +function cachedAgentToPlaceholder(snapshot: CachedAgentSnapshot): AgentInfo { + return { + name: snapshot.name, + type: snapshot.type, + status: AgentStatus.UNKNOWN, + summary: '', + pid: snapshot.pid, + projectPath: snapshot.projectPath, + sessionId: snapshot.sessionId, + lastActive: snapshot.startedAt, + sessionFilePath: snapshot.sessionFilePath, + }; +} + +function createInitialState(manager: AgentManager): AgentListState { + const snapshot = typeof manager.getCachedAgentSnapshot === 'function' + ? manager.getCachedAgentSnapshot() + : []; + const agents = snapshot + .map(cachedAgentToPlaceholder) + .sort((left, right) => left.name.localeCompare(right.name)); + return { + agents, + error: null, + lastUpdated: null, + isLoading: true, + isRefreshing: true, + cachedAgentPids: new Set(snapshot.map(agent => agent.pid)), + }; +} + export function agentsEqual(a: AgentInfo[], b: AgentInfo[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { @@ -39,12 +77,7 @@ export function useAgentList( ): UseAgentListResult { // Single state object so multiple updates within one fetch produce // exactly one render (React 17 doesn't batch async setState). - const [state, setState] = useState({ - agents: [], - error: null, - lastUpdated: null, - isLoading: true, - }); + const [state, setState] = useState(() => createInitialState(manager)); const runTokenRef = useRef(0); const inFlightRef = useRef(false); @@ -53,6 +86,11 @@ export function useAgentList( const refresh = useCallback(async (): Promise => { if (inFlightRef.current) return; inFlightRef.current = true; + if (mountedRef.current) { + setState(prev => prev.cachedAgentPids.size > 0 && !prev.isRefreshing + ? { ...prev, isRefreshing: true } + : prev); + } const token = ++runTokenRef.current; try { const next = await manager.listAgents({ sortBy: 'status' }); @@ -60,24 +98,27 @@ export function useAgentList( setState(prev => { const isFirst = prev.lastUpdated === null; const changed = !agentsEqual(prev.agents, next); + const wasCached = prev.cachedAgentPids.size > 0; // Quiet poll: nothing changed, no error to clear, not first // load. Skip state update entirely → zero re-renders. - if (!changed && prev.error === null && !prev.isLoading && !isFirst) { + if (!changed && !wasCached && prev.error === null && !prev.isLoading && !isFirst) { return prev; } return { - agents: changed ? next : prev.agents, + agents: changed || wasCached ? next : prev.agents, error: null, lastUpdated: new Date(), isLoading: false, + isRefreshing: false, + cachedAgentPids: new Set(), }; }); } catch (err) { if (!mountedRef.current || token !== runTokenRef.current) return; const message = err instanceof Error ? err.message : String(err); - setState(prev => prev.error === message && !prev.isLoading + setState(prev => prev.error === message && !prev.isLoading && !prev.isRefreshing ? prev - : { ...prev, error: message, isLoading: false }); + : { ...prev, error: message, isLoading: false, isRefreshing: false }); } finally { inFlightRef.current = false; } diff --git a/packages/cli/src/tui/console/state/ConsoleContext.tsx b/packages/cli/src/tui/console/state/ConsoleContext.tsx index 350af853..627cd410 100644 --- a/packages/cli/src/tui/console/state/ConsoleContext.tsx +++ b/packages/cli/src/tui/console/state/ConsoleContext.tsx @@ -69,6 +69,8 @@ export const ConsoleProvider: React.FC = ({ error: list.error, lastUpdated: list.lastUpdated, isLoading: list.isLoading, + isRefreshing: list.isRefreshing, + cachedAgentPids: list.cachedAgentPids, refresh: list.refresh, manager, inputFocused, @@ -78,6 +80,8 @@ export const ConsoleProvider: React.FC = ({ list.error, list.lastUpdated, list.isLoading, + list.isRefreshing, + list.cachedAgentPids, list.refresh, manager, inputFocused,