diff --git a/src/server/index.ts b/src/server/index.ts index 3413d9c6..68d0ab06 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -935,6 +935,44 @@ export async function createServerHandle(config: Config): Promise }) }) + // Lightweight read-only status projection for issue #2. + // Derives state from SessionManager + EventStore; does not load the conversation. + app.get('/api/sessions/:id/status', async (req, res) => { + const { projectSessionStatus } = await import('./routes/session-status.js') + const { getPendingQuestionsForSession } = await import('./tools/index.js') + const { getEventStore, combineEventsWithSnapshot } = await import('./events/index.js') + const { foldPendingConfirmations } = await import('./events/folding.js') + + const sessionId = req.params['id'] as string + if (!sessionId) { + return res.status(400).json({ error: 'Session id is required' }) + } + + const session = sessionManager.getSession(sessionId) + if (!session) { + return res.status(404).json({ error: 'Session not found' }) + } + + const activeWorkflowExecution = sessionManager.getActiveWorkflowExecution(sessionId) + const activeWorkflowStepName = activeWorkflowExecution?.currentStepName ?? null + + const pendingQuestions = getPendingQuestionsForSession(sessionId) + + const eventStore = getEventStore() + const { snapshot, events: eventsSinceSnapshot } = eventStore.getEventsSinceSnapshot(sessionId) + const events = combineEventsWithSnapshot(sessionId, snapshot, eventsSinceSnapshot) + const pendingConfirmations = foldPendingConfirmations(events) + + const status = projectSessionStatus({ + session, + pendingQuestionsCount: pendingQuestions.length, + pendingConfirmationsCount: pendingConfirmations.length, + activeWorkflowStepName, + }) + + res.json(status) + }) + app.delete('/api/sessions/:id', async (req, res) => { const sessionId = req.params['id'] as string const session = sessionManager.getSession(sessionId) diff --git a/src/server/routes/__snapshots__/session-status.test.ts.snap b/src/server/routes/__snapshots__/session-status.test.ts.snap new file mode 100644 index 00000000..21125522 --- /dev/null +++ b/src/server/routes/__snapshots__/session-status.test.ts.snap @@ -0,0 +1,91 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=blocked 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "blocked", + "schemaVersion": 1, + "sessionId": "session-1", + "state": "blocked", + "waitingForUser": false, + "workflowStep": null, +} +`; + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=completed 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "done", + "schemaVersion": 1, + "sessionId": "session-1", + "state": "completed", + "waitingForUser": false, + "workflowStep": null, +} +`; + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=null 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "plan", + "schemaVersion": 1, + "sessionId": "session-1", + "state": null, + "waitingForUser": false, + "workflowStep": null, +} +`; + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=running 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "build", + "schemaVersion": 1, + "sessionId": "session-1", + "state": "running", + "waitingForUser": false, + "workflowStep": "Implement feature", +} +`; + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=waiting (pendingQuestions) 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "build", + "schemaVersion": 1, + "sessionId": "session-1", + "state": "waiting", + "waitingForUser": true, + "workflowStep": null, +} +`; + +exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=waiting (phase=waiting) 1`] = ` +{ + "lastActivityAt": "2024-01-02T00:00:00.000Z", + "links": { + "ui": "/?sessionId=session-1", + }, + "phase": "waiting", + "schemaVersion": 1, + "sessionId": "session-1", + "state": "waiting", + "waitingForUser": false, + "workflowStep": null, +} +`; diff --git a/src/server/routes/session-status.cache.test.ts b/src/server/routes/session-status.cache.test.ts new file mode 100644 index 00000000..73313cda --- /dev/null +++ b/src/server/routes/session-status.cache.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { projectSessionStatus } from './session-status.js' +import { buildContextMessagesFromEventHistory } from '../events/folding.js' +import type { ToolCallWithResult, StoredEvent, TurnEvent, SessionSnapshot } from '../events/types.js' +import type { Session } from '../../shared/types.js' + +// --------------------------------------------------------------------------- +// KV-cache invariant (Cache Impact: No) — unique behavioral checks +// --------------------------------------------------------------------------- +// The session status projection must NEVER touch the LLM-side caches: +// - cachedSystemPrompt +// - cachedTools +// - dynamicContextHash +// - warmupState +// +// Idempotency and no-mutation are already covered in session-status.test.ts; +// this file focuses on the cache-preservation invariant specifically. +// --------------------------------------------------------------------------- + +function buildSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + projectId: 'proj-1', + workdir: '/tmp/test', + mode: 'builder', + phase: 'build', + isRunning: true, + providerId: null, + providerModel: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + messages: [], + criteria: [], + contextWindows: [], + executionState: null, + metadata: { title: 'Test', totalTokensUsed: 0, totalToolCalls: 0, iterationCount: 0 }, + metadataEntries: {}, + ...overrides, + } +} + +function buildSnapshotEvent(): StoredEvent { + const toolResult = { + success: true, + output: 'frozen stdout line 1\nfrozen stdout line 2', + durationMs: 10, + truncated: false, + } + const toolCall: ToolCallWithResult = { + id: 'call-1', + name: 'run_command', + arguments: { command: 'echo hello' }, + result: toolResult, + } + return { + type: 'turn.snapshot', + sessionId: 'session-1', + seq: 1, + timestamp: Date.now(), + data: { + messages: [ + { + id: 'msg-1', + role: 'assistant', + content: 'Run something', + timestamp: Date.now(), + isStreaming: false, + toolCalls: [toolCall], + }, + ], + mode: 'builder', + phase: 'build', + isRunning: true, + criteria: [], + metadataEntries: {}, + todos: [], + contextState: { + promptTokens: 0, + compactionCount: 0, + currentTokens: 0, + maxTokens: 200000, + dangerZone: false, + canCompact: false, + dynamicContextChanged: false, + }, + currentContextWindowId: 'window-1', + readFiles: [], + snapshotSeq: 1, + snapshotAt: Date.now(), + } as SessionSnapshot, + } +} + +describe('session status projection — KV-cache invariant (Cache Impact: No)', () => { + it('does not affect the cached prompt input (snapshot events) on repeated status reads', () => { + const event: StoredEvent = buildSnapshotEvent() + const events: StoredEvent[] = [event] + + // Build the input the LLM would receive from the same event history. + const before = JSON.stringify(buildContextMessagesFromEventHistory(events)) + + // Read the status projection many times (whatever the inputs are). + for (let i = 0; i < 100; i++) { + projectSessionStatus({ + session: buildSession({ phase: i % 2 === 0 ? 'build' : 'plan', isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: 'Step', + }) + } + + const after = JSON.stringify(buildContextMessagesFromEventHistory(events)) + expect(after).toBe(before) + }) + + it('keeps cachedSystemPrompt / cachedTools / dynamicContextHash / warmupState untouched on repeated calls', () => { + // Simulate the four LLM-side cache fields and confirm they remain + // unchanged after N status reads. + const cachedSystemPrompt = 'You are a helpful assistant. Do not change this.' + const cachedTools = 'tool-definitions-frozen-blob' + const dynamicContextHash = 'hash-frozen-1234' + const warmupState = { ready: true, message: 'warm' } + + const cacheBaseline = JSON.stringify({ + cachedSystemPrompt, + cachedTools, + dynamicContextHash, + warmupState, + }) + + for (let i = 0; i < 100; i++) { + projectSessionStatus({ + session: buildSession(), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + } + + const cacheAfter = JSON.stringify({ + cachedSystemPrompt, + cachedTools, + dynamicContextHash, + warmupState, + }) + expect(cacheAfter).toBe(cacheBaseline) + }) + + it('projection module does not import from LLM/context/skills/warmup modules', async () => { + // Cache impact: No — verify statically that the projection file does not + // import any of the modules that participate in the LLM request path. + const forbidden = ['src/server/llm', 'src/server/context', 'src/server/skills', 'src/server/warmup'] + const fs = await import('fs/promises') + const source = await fs.readFile(fileURLToPath(new URL('./session-status.ts', import.meta.url)), 'utf8') + for (const needle of forbidden) { + expect(source).not.toContain(needle) + } + }) +}) diff --git a/src/server/routes/session-status.test.ts b/src/server/routes/session-status.test.ts new file mode 100644 index 00000000..7d21c39d --- /dev/null +++ b/src/server/routes/session-status.test.ts @@ -0,0 +1,307 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { projectSessionStatus, SESSION_STATUS_SCHEMA_VERSION, type SessionStatus } from './session-status.js' +import type { Session } from '../../shared/types.js' + +function buildSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + projectId: 'proj-1', + workdir: '/tmp/test', + mode: 'builder', + phase: 'plan', + isRunning: false, + providerId: null, + providerModel: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + messages: [], + criteria: [], + contextWindows: [], + executionState: null, + metadata: { title: 'Test', totalTokensUsed: 0, totalToolCalls: 0, iterationCount: 0 }, + metadataEntries: {}, + ...overrides, + } +} + +describe('projectSessionStatus', () => { + it('returns schemaVersion 1', () => { + const status = projectSessionStatus({ + session: buildSession(), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.schemaVersion).toBe(SESSION_STATUS_SCHEMA_VERSION) + expect(status.schemaVersion).toBe(1) + }) + + it('returns state "waiting" when phase is "waiting"', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'waiting' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('waiting') + expect(status.waitingForUser).toBe(false) + }) + + it('returns state "waiting" when there are pending questions', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'build' }), + pendingQuestionsCount: 2, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('waiting') + expect(status.waitingForUser).toBe(true) + }) + + it('returns state "waiting" when there are pending confirmations', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'build' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 1, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('waiting') + expect(status.waitingForUser).toBe(true) + }) + + it('prioritizes "waiting" over "blocked"', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'blocked' }), + pendingQuestionsCount: 1, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('waiting') + }) + + it('returns state "blocked" when phase is "blocked" with no pending input', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'blocked' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('blocked') + }) + + it('prioritizes "blocked" over "completed"', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'blocked', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('blocked') + }) + + it('returns state "completed" when phase is "done" and not running', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'done', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('completed') + }) + + it('does not return "completed" when phase is "done" but session is still running', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'done', isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('running') + }) + + it('prioritizes "completed" over "running"', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'done', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('completed') + }) + + it('returns state "running" when isRunning is true', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'build', isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBe('running') + }) + + it('returns state null when no factually evident state matches', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'plan', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.state).toBeNull() + }) + + it('passes through the phase as-is', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'verification' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.phase).toBe('verification') + }) + + it('exposes workflowStep from active execution', () => { + const status = projectSessionStatus({ + session: buildSession(), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: 'Build feature', + }) + expect(status.workflowStep).toBe('Build feature') + }) + + it('exposes workflowStep as null when no active execution', () => { + const status = projectSessionStatus({ + session: buildSession(), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.workflowStep).toBeNull() + }) + + it('uses session.updatedAt as lastActivityAt', () => { + const status = projectSessionStatus({ + session: buildSession({ updatedAt: '2024-06-15T12:34:56.000Z' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.lastActivityAt).toBe('2024-06-15T12:34:56.000Z') + }) + + it('exposes a deep link to the UI', () => { + const status = projectSessionStatus({ + session: buildSession({ id: 'session/with-special id' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.links.ui).toBe('/?sessionId=session%2Fwith-special%20id') + }) + + it('exposes sessionId from the session', () => { + const status = projectSessionStatus({ + session: buildSession({ id: 'abc-123' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(status.sessionId).toBe('abc-123') + }) + + it('returns deterministic output for the same inputs (pure/idempotent)', () => { + const inputs = { + session: buildSession({ phase: 'build' as const, isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: 'Step 1', + } + const a = projectSessionStatus(inputs) + const b = projectSessionStatus(inputs) + expect(a).toEqual(b) + }) + + it('does not mutate the session input', () => { + const session = buildSession({ phase: 'build', isRunning: true }) + const snapshot = JSON.stringify(session) + projectSessionStatus({ + session, + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }) + expect(JSON.stringify(session)).toBe(snapshot) + }) +}) + +describe('SessionStatus JSON contract (snapshot)', () => { + const cases: Array<{ name: string; build: () => SessionStatus }> = [ + { + name: 'state=running', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'build', isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: 'Implement feature', + }), + }, + { + name: 'state=waiting (phase=waiting)', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'waiting' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }), + }, + { + name: 'state=waiting (pendingQuestions)', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'build' }), + pendingQuestionsCount: 1, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }), + }, + { + name: 'state=blocked', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'blocked' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }), + }, + { + name: 'state=completed', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'done', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }), + }, + { + name: 'state=null', + build: () => + projectSessionStatus({ + session: buildSession({ phase: 'plan', isRunning: false }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + }), + }, + ] + + for (const c of cases) { + it(`matches snapshot for ${c.name}`, () => { + expect(c.build()).toMatchSnapshot() + }) + } +}) diff --git a/src/server/routes/session-status.ts b/src/server/routes/session-status.ts new file mode 100644 index 00000000..7cd0325e --- /dev/null +++ b/src/server/routes/session-status.ts @@ -0,0 +1,53 @@ +import type { Session, SessionPhase } from '../../shared/types.js' + +export const SESSION_STATUS_SCHEMA_VERSION = 1 as const + +export type SessionStatusState = 'waiting' | 'blocked' | 'completed' | 'running' | null + +export interface SessionStatus { + schemaVersion: typeof SESSION_STATUS_SCHEMA_VERSION + sessionId: string + state: SessionStatusState + phase: SessionPhase + workflowStep: string | null + waitingForUser: boolean + lastActivityAt: string + links: { ui: string } +} + +export interface ProjectSessionStatusInputs { + session: Session + pendingQuestionsCount: number + pendingConfirmationsCount: number + activeWorkflowStepName: string | null +} + +export function projectSessionStatus(inputs: ProjectSessionStatusInputs): SessionStatus { + const { session, pendingQuestionsCount, pendingConfirmationsCount, activeWorkflowStepName } = inputs + + let state: SessionStatusState = null + if (session.phase === 'waiting' || pendingQuestionsCount > 0 || pendingConfirmationsCount > 0) { + state = 'waiting' + } else if (session.phase === 'blocked') { + state = 'blocked' + } else if (session.phase === 'done' && !session.isRunning) { + state = 'completed' + } else if (session.isRunning) { + state = 'running' + } + + const waitingForUser = pendingQuestionsCount > 0 || pendingConfirmationsCount > 0 + + return { + schemaVersion: SESSION_STATUS_SCHEMA_VERSION, + sessionId: session.id, + state, + phase: session.phase, + workflowStep: activeWorkflowStepName, + waitingForUser, + lastActivityAt: session.updatedAt, + links: { + ui: `/?sessionId=${encodeURIComponent(session.id)}`, + }, + } +} diff --git a/web/src/components/plan/ChatInput.tsx b/web/src/components/plan/ChatInput.tsx index 1f5be357..85ccb01a 100644 --- a/web/src/components/plan/ChatInput.tsx +++ b/web/src/components/plan/ChatInput.tsx @@ -501,11 +501,9 @@ export function ChatInput({ return (
- {isRunning && ( -
- -
- )} +
+ +
diff --git a/web/src/components/shared/RunningIndicator.test.tsx b/web/src/components/shared/RunningIndicator.test.tsx new file mode 100644 index 00000000..68665400 --- /dev/null +++ b/web/src/components/shared/RunningIndicator.test.tsx @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { flushSync } from 'react-dom' +import { RunningIndicator } from './RunningIndicator' +import { useSessionStore } from '../../stores/session' +import type { Session } from '@shared/types.js' + +function makeSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + projectId: 'proj-1', + workdir: '/tmp/test', + mode: 'builder', + phase: 'build', + isRunning: false, + providerId: null, + providerModel: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-06-15T12:34:56.000Z', + messages: [], + criteria: [], + contextWindows: [], + executionState: null, + metadata: { title: 'Test', totalTokensUsed: 0, totalToolCalls: 0, iterationCount: 0 }, + metadataEntries: {}, + ...overrides, + } +} + +const roots: Root[] = [] + +function render(): HTMLElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + flushSync(() => root.render()) + return container +} + +afterEach(() => { + for (const r of roots) r.unmount() + roots.length = 0 +}) + +beforeEach(() => { + document.body.innerHTML = '' + useSessionStore.setState({ + currentSession: null, + pendingQuestions: [], + pendingPathConfirmations: [], + activeWorkflowExecution: null, + abortInProgress: false, + }) +}) + +describe('RunningIndicator — factually-derived state from existing client data', () => { + it('renders nothing when there is no current session (state=null)', () => { + const container = render() + expect(container.querySelector('[data-testid="session-status-indicator"]')).toBeNull() + }) + + it('renders nothing when no factually evident state matches (state=null)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'plan', isRunning: false }), + }) + const container = render() + expect(container.querySelector('[data-testid="session-status-indicator"]')).toBeNull() + }) + + it('renders "Running • Build" when isRunning=true and phase=build', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build', isRunning: true }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el).not.toBeNull() + expect(el?.getAttribute('data-state')).toBe('running') + expect(el?.textContent).toContain('Running') + expect(el?.textContent).toContain('Build') + }) + + it('renders "Running • Plan" when isRunning=true and phase=plan', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'plan', isRunning: true }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('running') + expect(el?.textContent).toContain('Plan') + }) + + it('renders "Running • Verification" when isRunning=true and phase=verification', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'verification', isRunning: true }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('running') + expect(el?.textContent).toContain('Verification') + }) + + it('renders "Waiting for input • Build" when there are pending questions', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build' }), + pendingQuestions: [{ callId: 'q1', question: 'Pick?', type: 'choice', options: undefined }], + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('waiting') + expect(el?.textContent).toContain('Waiting for input') + expect(el?.textContent).toContain('Build') + }) + + it('renders "Waiting for input" without phase suffix when phase=waiting (no pendingQuestions)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'waiting' }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('waiting') + expect(el?.textContent).toContain('Waiting for input') + // No redundant "• Waiting" suffix when phase=waiting. + expect(el?.textContent).not.toContain('•') + }) + + it('renders "Waiting for input" when there are pending path confirmations', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build' }), + pendingPathConfirmations: [ + { + callId: 'pc-1', + tool: 'run_command', + paths: ['/etc/secret'], + workdir: '/tmp', + reason: 'sensitive_file', + }, + ], + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('waiting') + expect(el?.textContent).toContain('Waiting for input') + }) + + it('renders "Blocked" alone (no redundant "• Blocked" suffix) when phase=blocked and nothing is waiting', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'blocked' }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('blocked') + expect(el?.textContent).toContain('Blocked') + // No redundant "• Blocked" suffix — phase=blocked is implied by the state name. + expect(el?.textContent).not.toContain('•') + }) + + it('prioritizes "waiting" over "blocked" when phase=blocked but pending questions exist', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'blocked' }), + pendingQuestions: [{ callId: 'q1', question: '?', type: 'text', options: undefined }], + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('waiting') + }) + + it('renders "Completed" alone (no redundant "Done" suffix) when phase=done and not running', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'done', isRunning: false }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('completed') + expect(el?.textContent).toContain('Completed') + // No redundant "• Done" suffix — phase=done is implied by the state name. + expect(el?.textContent).not.toContain('•') + }) + + it('does NOT render "Completed" when phase=done but isRunning=true (state=running)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'done', isRunning: true }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('running') + }) + + it('strict priority waiting > blocked > completed > running > null', () => { + // phase=blocked + phase effectively 'done' would be contradictory; use blocked + // to verify blocked wins over completed. + useSessionStore.setState({ + currentSession: makeSession({ phase: 'blocked', isRunning: false }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('blocked') + }) + + it('exposes workflow step from activeWorkflowExecution', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build', isRunning: true }), + activeWorkflowExecution: { + id: 'wf-1', + sessionId: 'session-1', + workflowId: 'wf-def', + workflowName: 'Default', + status: 'running', + currentStepId: 'step-1', + currentStepName: 'Implement feature', + stepOutput: {}, + params: {}, + createdAt: 0, + updatedAt: 0, + }, + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.textContent).toContain('Running') + // The workflow step is not part of the basic label — this is intentional; + // the indicator stays minimal in this first build. + }) + + it('does NOT show bounce animation in the waiting state (static label only)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build' }), + pendingQuestions: [{ callId: 'q1', question: 'Pick?', type: 'choice', options: undefined }], + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('waiting') + // No animate-bounce class on any dot in the waiting state. + const bounceDots = el?.querySelectorAll('.animate-bounce') ?? [] + expect(bounceDots.length).toBe(0) + }) + + it('does NOT show bounce animation in the blocked state (static label only)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'blocked' }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('blocked') + const bounceDots = el?.querySelectorAll('.animate-bounce') ?? [] + expect(bounceDots.length).toBe(0) + }) + + it('does NOT show bounce animation in the completed state (static label only)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'done', isRunning: false }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el?.getAttribute('data-state')).toBe('completed') + const bounceDots = el?.querySelectorAll('.animate-bounce') ?? [] + expect(bounceDots.length).toBe(0) + }) + + it('does introduce click handlers (strictly read-only)', () => { + useSessionStore.setState({ + currentSession: makeSession({ phase: 'build', isRunning: true }), + }) + const container = render() + const el = container.querySelector('[data-testid="session-status-indicator"]') + expect(el).not.toBeNull() + // No button children (the component is purely informational). + expect(el?.querySelectorAll('button').length).toBe(0) + // No anchor links. + expect(el?.querySelectorAll('a').length).toBe(0) + }) +}) diff --git a/web/src/components/shared/RunningIndicator.tsx b/web/src/components/shared/RunningIndicator.tsx index bfa2ced6..c5dad50d 100644 --- a/web/src/components/shared/RunningIndicator.tsx +++ b/web/src/components/shared/RunningIndicator.tsx @@ -1,24 +1,83 @@ -import { useAbortInProgress } from '../../stores/session' +import { useSessionStore } from '../../stores/session' +import { projectFromSessionStore, statusLabel, type SessionStatusState } from '../../lib/session-status' /** - * Running indicator shown at bottom of chat when agent is active. - * Displays a subtle animation and "esc to interrupt" hint. + * Session status indicator shown at the bottom of the chat. + * Reuses the existing position. Displays the factually-derived state + * (running / waiting / completed / blocked) when one is present in the + * existing client-side session data, otherwise renders nothing. + * + * The component is strictly read-only: no click handler, no actions, no + * new sync mechanism. All inputs come from useSessionStore, which is + * already populated by the existing session-load flow. */ export function RunningIndicator() { - const aborting = useAbortInProgress() + const aborting = useSessionStore((state) => state.abortInProgress) + const currentSession = useSessionStore((state) => state.currentSession) + const pendingQuestions = useSessionStore((state) => state.pendingQuestions) + const pendingPathConfirmations = useSessionStore((state) => state.pendingPathConfirmations) + const activeWorkflowExecution = useSessionStore((state) => state.activeWorkflowExecution) + + const view = projectFromSessionStore({ + currentSession, + pendingQuestions, + pendingPathConfirmations, + activeWorkflowExecution, + }) + + const state: SessionStatusState = view.state + + if (state === null) return null + + const label = statusLabel(state, currentSession?.phase ?? 'plan') const dotColor = aborting ? 'bg-amber-400' : 'bg-accent-primary' + const showBounce = state === 'running' + const lastActivityAtText = view.lastActivityAt ? formatLastActivity(view.lastActivityAt) : '' return ( -
+
- - - - + {showBounce && ( + + + + + + )} + + {aborting && state === 'running' ? `${label} (abort in progress)` : label} - {aborting ? 'Running... (abort in progress)' : 'Running'}
- {!aborting && esc to interrupt} + {!aborting && state === 'running' && esc to interrupt} + {lastActivityAtText && ( + + {lastActivityAtText} + + )}
) } + +function formatLastActivity(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleString(undefined, { + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/web/src/lib/session-status.ts b/web/src/lib/session-status.ts new file mode 100644 index 00000000..e83f1df2 --- /dev/null +++ b/web/src/lib/session-status.ts @@ -0,0 +1,111 @@ +import type { Session, SessionPhase, WorkflowExecution } from '@shared/types.js' + +export type SessionStatusState = 'waiting' | 'blocked' | 'completed' | 'running' | null + +export interface ProjectSessionStatusInputs { + phase: SessionPhase + isRunning: boolean + pendingQuestionsCount: number + pendingConfirmationsCount: number + activeWorkflow: WorkflowExecution | null | undefined +} + +export interface SessionStatusView { + state: SessionStatusState + waitingForUser: boolean + workflowStep: string | null + lastActivityAt: string | null +} + +export function projectClientSessionStatus(inputs: ProjectSessionStatusInputs): SessionStatusView { + const { phase, isRunning, pendingQuestionsCount, pendingConfirmationsCount, activeWorkflow } = inputs + + let state: SessionStatusState = null + if (phase === 'waiting' || pendingQuestionsCount > 0 || pendingConfirmationsCount > 0) { + state = 'waiting' + } else if (phase === 'blocked') { + state = 'blocked' + } else if (phase === 'done' && !isRunning) { + state = 'completed' + } else if (isRunning) { + state = 'running' + } + + const waitingForUser = pendingQuestionsCount > 0 || pendingConfirmationsCount > 0 + + const workflowStep = activeWorkflow?.currentStepName ?? null + + return { + state, + waitingForUser, + workflowStep, + lastActivityAt: null, + } +} + +export interface ProjectFromSessionStoreInputs { + currentSession: Session | null + pendingQuestions: unknown[] + pendingPathConfirmations: unknown[] + activeWorkflowExecution: WorkflowExecution | null | undefined +} + +export function projectFromSessionStore(inputs: ProjectFromSessionStoreInputs): SessionStatusView & { + lastActivityAt: string | null +} { + const { currentSession, pendingQuestions, pendingPathConfirmations, activeWorkflowExecution } = inputs + + if (!currentSession) { + return { + state: null, + waitingForUser: false, + workflowStep: null, + lastActivityAt: null, + } + } + + const view = projectClientSessionStatus({ + phase: currentSession.phase, + isRunning: currentSession.isRunning, + pendingQuestionsCount: pendingQuestions?.length ?? 0, + pendingConfirmationsCount: pendingPathConfirmations?.length ?? 0, + activeWorkflow: activeWorkflowExecution, + }) + + return { + ...view, + lastActivityAt: currentSession.updatedAt, + } +} + +export function formatPhaseLabel(phase: SessionPhase): string { + switch (phase) { + case 'plan': + return 'Plan' + case 'build': + return 'Build' + case 'verification': + return 'Verification' + case 'waiting': + return 'Waiting' + case 'blocked': + return 'Blocked' + case 'done': + return 'Done' + } +} + +export function statusLabel(state: SessionStatusState, phase: SessionPhase): string { + switch (state) { + case 'running': + return `Running • ${formatPhaseLabel(phase)}` + case 'waiting': + return phase === 'waiting' ? 'Waiting for input' : `Waiting for input • ${formatPhaseLabel(phase)}` + case 'completed': + return 'Completed' + case 'blocked': + return 'Blocked' + case null: + return '' + } +}