diff --git a/src/server/events/fold-state.ts b/src/server/events/fold-state.ts index 827aca06..d75fe2b2 100644 --- a/src/server/events/fold-state.ts +++ b/src/server/events/fold-state.ts @@ -47,6 +47,108 @@ export function foldCriteria(events: EventLike[]): Criterion[] { return criteria } +function criterionStatusType(status: unknown): string | undefined { + if (typeof status === 'string') return status + if (typeof status === 'object' && status !== null && 'type' in status) { + const type = (status as { type?: unknown }).type + return typeof type === 'string' ? type : undefined + } + return undefined +} + +function criterionEmbeddedTimestamp(status: unknown): number | null { + if (typeof status !== 'object' || status === null) return null + const typedStatus = status as { type?: unknown; completedAt?: unknown; verifiedAt?: unknown } + const timestamp = typedStatus.type === 'completed' ? typedStatus.completedAt : typedStatus.verifiedAt + if (typeof timestamp !== 'string') return null + const parsed = Date.parse(timestamp) + return Number.isNaN(parsed) ? null : parsed +} + +function updateProgressFromStatuses( + statuses: Array<{ id: string; status: unknown }>, + previous: Map, + timestamp: number, +): number | null { + let latest: number | null = null + for (const entry of statuses) { + const next = criterionStatusType(entry.status) + if ((next === 'completed' || next === 'passed') && previous.get(entry.id) !== next) { + latest = Math.max(latest ?? 0, timestamp) + } + if (next !== undefined) previous.set(entry.id, next) + } + return latest +} + +export function foldLastProgressAt(events: EventLike[]): string | null { + let latest: number | null = null + const criteria = new Map() + const metadataCriteria = new Map() + + for (const event of events) { + const timestamp = getTimestamp(event) + let candidate: number | null = null + switch (event.type) { + case 'turn.snapshot': { + const snapshot = event.data as SessionSnapshot + if (snapshot.lastProgressAt !== undefined && snapshot.lastProgressAt !== null) { + const parsed = Date.parse(snapshot.lastProgressAt) + if (!Number.isNaN(parsed)) candidate = parsed + } else { + for (const criterion of snapshot.criteria) { + const type = criterionStatusType(criterion.status) + if (type === 'completed' || type === 'passed') { + const parsed = criterionEmbeddedTimestamp(criterion.status) + if (parsed !== null) candidate = Math.max(candidate ?? 0, parsed) + } + } + } + for (const criterion of snapshot.criteria) { + const type = criterionStatusType(criterion.status) + if (type !== undefined) criteria.set(criterion.id, type) + } + for (const entry of snapshot.metadataEntries?.['criteria'] ?? []) { + const type = criterionStatusType(entry.status) + if (type !== undefined) metadataCriteria.set(entry.id, type) + } + break + } + case 'criteria.set': { + const data = event.data as Extract['data'] + candidate = updateProgressFromStatuses(data.criteria, criteria, timestamp) + break + } + case 'criterion.updated': { + const data = event.data as Extract['data'] + candidate = updateProgressFromStatuses([{ id: data.criterionId, status: data.status }], criteria, timestamp) + break + } + case 'metadata.set': { + const data = event.data as Extract['data'] + if (data.key === 'criteria') candidate = updateProgressFromStatuses(data.entries, metadataCriteria, timestamp) + break + } + case 'chat.done': { + const data = event.data as Extract['data'] + if (data.reason === 'step_done') candidate = timestamp + break + } + case 'workflow.execution_changed': { + const data = event.data as Extract['data'] + if (data.status === 'completed') candidate = timestamp + break + } + case 'task.completed': + candidate = timestamp + break + } + if (candidate !== null) latest = Math.max(latest ?? 0, candidate) + } + + return latest === null ? null : new Date(latest).toISOString() +} + export function foldTodos(events: EventLike[]): Todo[] { let todos: Todo[] = [] for (const event of events) { @@ -225,6 +327,7 @@ export function foldSessionState( let metadataEntries = foldMetadata(events) const contextResult = foldContextState(events, initialWindowId) const pendingConfirmations = foldPendingConfirmations(events) + const lastProgressAt = foldLastProgressAt(events) const baseContextState = contextResult.latestContextState ?? { currentTokens: 0, @@ -370,6 +473,7 @@ export function foldSessionState( contextState, currentContextWindowId: contextResult.currentContextWindowId, readFiles: contextResult.readFiles, + lastProgressAt, ...(cachedSystemPrompt !== undefined && { cachedSystemPrompt }), ...(dynamicContextHash !== undefined && { dynamicContextHash }), pendingConfirmations, @@ -490,6 +594,7 @@ export function buildSnapshot( readFiles: foldedState.readFiles, ...(foldedState.cachedSystemPrompt !== undefined && { cachedSystemPrompt: foldedState.cachedSystemPrompt }), ...(foldedState.dynamicContextHash !== undefined && { dynamicContextHash: foldedState.dynamicContextHash }), + lastProgressAt: foldedState.lastProgressAt, snapshotSeq: latestSeq, snapshotAt, ...(foldedState.sessionInit !== undefined && { sessionInit: foldedState.sessionInit }), @@ -563,6 +668,7 @@ export function buildSnapshotFromSessionState(input: { currentContextWindowId: foldedState.currentContextWindowId, todos: foldedState.todos, readFiles: foldedState.readFiles, + lastProgressAt: foldedState.lastProgressAt, snapshotSeq: latestSeq, snapshotAt, ...(foldedState.sessionInit !== undefined && { sessionInit: foldedState.sessionInit }), diff --git a/src/server/events/fold-types.ts b/src/server/events/fold-types.ts index 1426a86e..b4654818 100644 --- a/src/server/events/fold-types.ts +++ b/src/server/events/fold-types.ts @@ -52,6 +52,7 @@ export interface FoldedSessionState { readFiles: ReadFileEntry[] cachedSystemPrompt?: string dynamicContextHash?: string + lastProgressAt: string | null pendingConfirmations: PendingPathConfirmation[] sessionInit?: { projectId: string diff --git a/src/server/events/folding.test.ts b/src/server/events/folding.test.ts index 65bd478d..69ef07b7 100644 --- a/src/server/events/folding.test.ts +++ b/src/server/events/folding.test.ts @@ -918,6 +918,7 @@ describe('event folding', () => { currentContextWindowId: 'legacy-window-1', // No session.initialized event, uses fallback todos: [], readFiles: [], + lastProgressAt: null, snapshotSeq: 42, snapshotAt: 999, }) diff --git a/src/server/events/folding.ts b/src/server/events/folding.ts index 07167ff5..d7d13752 100644 --- a/src/server/events/folding.ts +++ b/src/server/events/folding.ts @@ -44,6 +44,7 @@ export { foldPhase, foldIsRunning, foldPendingConfirmations, + foldLastProgressAt, foldSessionState, foldWaitingWorkflow, buildSnapshot, diff --git a/src/server/events/index.ts b/src/server/events/index.ts index dcaac231..ea28cfd9 100644 --- a/src/server/events/index.ts +++ b/src/server/events/index.ts @@ -58,6 +58,7 @@ export { foldMode, foldPhase, foldIsRunning, + foldLastProgressAt, foldContextState, buildSnapshot, buildSnapshotFromSessionState, diff --git a/src/server/events/session-progress.test.ts b/src/server/events/session-progress.test.ts new file mode 100644 index 00000000..00adec5c --- /dev/null +++ b/src/server/events/session-progress.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import Database from 'better-sqlite3' +import { buildSnapshot, buildSnapshotFromSessionState, foldLastProgressAt, foldSessionState } from './folding.js' +import type { EventLike } from './folding.js' +import { EventStore } from './store.js' +import type { SessionSnapshot } from './types.js' + +function event(type: EventLike['type'], data: EventLike['data'], timestamp: number): EventLike { + return { type, data, timestamp } +} + +function snapshot(overrides: Partial = {}): SessionSnapshot { + return { + mode: 'builder', + phase: 'build', + isRunning: true, + messages: [], + criteria: [], + metadataEntries: {}, + contextState: { + currentTokens: 0, + maxTokens: 200000, + compactionCount: 0, + dangerZone: false, + canCompact: false, + dynamicContextChanged: false, + }, + currentContextWindowId: 'window-1', + todos: [], + snapshotSeq: 1, + snapshotAt: 1000, + ...overrides, + } +} + +describe('foldLastProgressAt', () => { + it('does not treat ordinary activity as progress', () => { + const events: EventLike[] = [ + event('message.start', { messageId: 'm1', role: 'assistant' }, 1000), + event('message.delta', { messageId: 'm1', content: 'working' }, 2000), + event( + 'tool.call', + { + messageId: 'm1', + toolCall: { id: 't1', name: 'run_command', arguments: { command: 'npm test' } }, + }, + 3000, + ), + event( + 'tool.result', + { + messageId: 'm1', + toolCallId: 't1', + result: { success: true, output: 'ok', durationMs: 1, truncated: false }, + }, + 4000, + ), + event('chat.done', { messageId: 'm1', reason: 'complete' }, 5000), + ] + expect(foldLastProgressAt(events)).toBeNull() + }) + + it('recognizes structured criteria progress', () => { + const events: EventLike[] = [ + event( + 'criteria.set', + { + criteria: [{ id: 'c1', description: 'One', status: { type: 'pending' }, attempts: [] }], + }, + 1000, + ), + event( + 'criterion.updated', + { + criterionId: 'c1', + status: { type: 'completed', completedAt: '2024-01-02T03:04:05.000Z' }, + }, + 2000, + ), + event( + 'criterion.updated', + { + criterionId: 'c1', + status: { type: 'passed', verifiedAt: '2024-01-03T03:04:05.000Z' }, + }, + 3000, + ), + ] + expect(foldLastProgressAt(events)).toBe(new Date(3000).toISOString()) + }) + + it('recognizes criteria.set transitions to completed and passed', () => { + const events: EventLike[] = [ + event( + 'criteria.set', + { + criteria: [ + { id: 'c1', description: 'One', status: { type: 'pending' }, attempts: [] }, + { id: 'c2', description: 'Two', status: { type: 'pending' }, attempts: [] }, + ], + }, + 1000, + ), + event( + 'criteria.set', + { + criteria: [ + { + id: 'c1', + description: 'One', + status: { type: 'completed', completedAt: '2024-01-02T03:04:05.000Z' }, + attempts: [], + }, + { + id: 'c2', + description: 'Two', + status: { type: 'passed', verifiedAt: '2024-01-03T03:04:05.000Z' }, + attempts: [], + }, + ], + }, + 2000, + ), + ] + expect(foldLastProgressAt(events)).toBe(new Date(2000).toISOString()) + }) + + it('recognizes criteria metadata transitions but not unrelated metadata', () => { + const events: EventLike[] = [ + event('metadata.set', { key: 'criteria', entries: [{ id: '0', description: 'One', status: 'pending' }] }, 1000), + event( + 'metadata.set', + { key: 'review_findings', entries: [{ id: '0', description: 'One', status: 'resolved' }] }, + 2000, + ), + event('metadata.set', { key: 'criteria', entries: [{ id: '0', description: 'One', status: 'completed' }] }, 3000), + ] + expect(foldLastProgressAt(events)).toBe(new Date(3000).toISOString()) + }) + + it.each([ + ['chat.done step_done', event('chat.done', { messageId: 'm1', reason: 'step_done' }, 2000)], + [ + 'workflow completed', + event( + 'workflow.execution_changed', + { + executionId: 'e1', + workflowId: 'w1', + workflowName: 'Workflow', + status: 'completed', + }, + 3000, + ), + ], + [ + 'task completed', + event( + 'task.completed', + { + summary: null, + iterations: 1, + totalTimeSeconds: 1, + totalToolCalls: 0, + totalTokensGenerated: 0, + avgGenerationSpeed: 0, + responseCount: 1, + llmCallCount: 1, + criteria: [], + }, + 4000, + ), + ], + ])('recognizes %s', (_name, progressEvent) => { + expect(foldLastProgressAt([progressEvent])).toBe(new Date(progressEvent.timestamp!).toISOString()) + }) + + it('does not treat failed, cancelled, waiting, or blocked signals as progress', () => { + const events: EventLike[] = [ + event('phase.changed', { phase: 'waiting' }, 1000), + event( + 'workflow.execution_changed', + { + executionId: 'e1', + workflowId: 'w1', + workflowName: 'Workflow', + status: 'waiting', + }, + 2000, + ), + event('chat.done', { messageId: 'm1', reason: 'error' }, 2500), + event('phase.changed', { phase: 'blocked' }, 3000), + event( + 'workflow.execution_changed', + { + executionId: 'e1', + workflowId: 'w1', + workflowName: 'Workflow', + status: 'blocked', + }, + 4000, + ), + event( + 'workflow.execution_changed', + { + executionId: 'e1', + workflowId: 'w1', + workflowName: 'Workflow', + status: 'cancelled', + }, + 5000, + ), + event( + 'criterion.updated', + { + criterionId: 'c1', + status: { type: 'failed', reason: 'Verification failed', failedAt: new Date(6000).toISOString() }, + }, + 6000, + ), + ] + expect(foldLastProgressAt(events)).toBeNull() + }) + + it('preserves progress through a snapshot and later activity', () => { + const progress = '2024-01-02T03:04:05.000Z' + const events: EventLike[] = [ + event('turn.snapshot', snapshot({ lastProgressAt: progress }), 5000), + event('message.start', { messageId: 'm1', role: 'assistant' }, 6000), + ] + expect(foldLastProgressAt(events)).toBe(progress) + }) + + it('keeps the newest event timestamp after a snapshot', () => { + const previousProgress = new Date(1000).toISOString() + const events: EventLike[] = [ + event('turn.snapshot', snapshot({ lastProgressAt: previousProgress }), 5000), + event('chat.done', { messageId: 'm1', reason: 'step_done' }, 6000), + event('message.start', { messageId: 'm2', role: 'assistant' }, 7000), + ] + expect(foldLastProgressAt(events)).toBe(new Date(6000).toISOString()) + }) + + it('recovers reliable criterion timestamps from legacy snapshots', () => { + const criterionTimestamp = '2024-01-02T03:04:05.000Z' + const legacy = snapshot({ + criteria: [ + { + id: 'c1', + description: 'One', + status: { type: 'passed', verifiedAt: criterionTimestamp }, + attempts: [], + }, + ], + }) + expect(foldLastProgressAt([event('turn.snapshot', legacy, 5000)])).toBe(criterionTimestamp) + }) + + it('survives EventStore cleanup and reload from the persisted snapshot', () => { + const db = new Database(':memory:') + try { + const store = new EventStore(db) + const sessionId = 'session-1' + store.append(sessionId, { + type: 'session.initialized', + data: { projectId: 'p1', workdir: '/tmp', contextWindowId: 'window-1' }, + }) + store.append(sessionId, { type: 'chat.done', data: { messageId: 'm1', reason: 'step_done' } }) + const sourceEvents = store.getEvents(sessionId) + const progress = foldLastProgressAt(sourceEvents) + const persistedSnapshot = buildSnapshotFromSessionState({ + session: { mode: 'builder', phase: 'build', isRunning: true, criteria: [] }, + events: sourceEvents, + latestSeq: sourceEvents.at(-1)!.seq, + }) + store.append(sessionId, { type: 'turn.snapshot', data: persistedSnapshot }) + store.append(sessionId, { + type: 'message.start', + data: { messageId: 'm2', role: 'assistant' }, + }) + + store.cleanupOldEvents(sessionId) + + const reloaded = new EventStore(db) + const { snapshot: persisted, events } = reloaded.getEventsSinceSnapshot(sessionId) + const foldedEvents: EventLike[] = [ + ...(persisted ? [{ type: 'turn.snapshot' as const, data: persisted, timestamp: persisted.snapshotAt }] : []), + ...events, + ] + expect(foldLastProgressAt(foldedEvents)).toBe(progress) + } finally { + db.close() + } + }) + + it('persists folded progress in newly built snapshots', () => { + const progress = '2024-01-02T03:04:05.000Z' + const state = foldSessionState( + [ + event('session.initialized', { projectId: 'p1', workdir: '/tmp', contextWindowId: 'window-1' }, 1000), + event('chat.done', { messageId: 'm1', reason: 'step_done' }, Date.parse(progress)), + ], + 'window-1', + 200000, + ) + expect(buildSnapshot(state, 2, 3000).lastProgressAt).toBe(progress) + }) +}) diff --git a/src/server/events/types.ts b/src/server/events/types.ts index 59f7ae54..3af2ee3e 100644 --- a/src/server/events/types.ts +++ b/src/server/events/types.ts @@ -429,6 +429,7 @@ export interface SessionSnapshot { readFiles?: ReadFileEntry[] cachedSystemPrompt?: string dynamicContextHash?: string + lastProgressAt?: string | null snapshotSeq: number snapshotAt: number diff --git a/src/server/index.ts b/src/server/index.ts index 5b541710..2abc8be4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -758,7 +758,13 @@ export async function createServerHandle(config: Config): Promise } } - res.json({ sessions: sessionsWithPrompts, hasMore, pendingConfirmationsBySession }) + const { getSessionStatuses } = await import('./routes/session-status-reader.js') + const statuses = getSessionStatuses( + sessionManager, + sessions.map((session) => session.id), + ) + + res.json({ sessions: sessionsWithPrompts, statuses, hasMore, pendingConfirmationsBySession }) }) /** @@ -932,43 +938,19 @@ export async function createServerHandle(config: Config): Promise pendingQuestions, pendingConfirmations, activeWorkflowExecution, + status: (await import('./routes/session-status-reader.js')).getSessionStatus(sessionManager, req.params.id), }) }) // 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 { getSessionStatus } = await import('./routes/session-status-reader.js') const sessionId = req.params['id'] as string - if (!sessionId) { - return res.status(400).json({ error: 'Session id is required' }) - } + 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, - }) + const status = getSessionStatus(sessionManager, sessionId) + if (!status) return res.status(404).json({ error: 'Session not found' }) res.json(status) }) diff --git a/src/server/routes/__snapshots__/session-status.test.ts.snap b/src/server/routes/__snapshots__/session-status.test.ts.snap index 21125522..5c1c3d9c 100644 --- a/src/server/routes/__snapshots__/session-status.test.ts.snap +++ b/src/server/routes/__snapshots__/session-status.test.ts.snap @@ -3,11 +3,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=blocked 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "blocked", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": "blocked", "waitingForUser": false, @@ -18,11 +19,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=blo exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=completed 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "done", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": "completed", "waitingForUser": false, @@ -33,11 +35,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=com exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=null 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "plan", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": null, "waitingForUser": false, @@ -48,11 +51,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=nul exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=running 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "build", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": "running", "waitingForUser": false, @@ -63,11 +67,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=run exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=waiting (pendingQuestions) 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "build", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": "waiting", "waitingForUser": true, @@ -78,11 +83,12 @@ exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=wai exports[`SessionStatus JSON contract (snapshot) > matches snapshot for state=waiting (phase=waiting) 1`] = ` { "lastActivityAt": "2024-01-02T00:00:00.000Z", + "lastProgressAt": null, "links": { "ui": "/?sessionId=session-1", }, "phase": "waiting", - "schemaVersion": 1, + "schemaVersion": 2, "sessionId": "session-1", "state": "waiting", "waitingForUser": false, diff --git a/src/server/routes/session-status-reader.ts b/src/server/routes/session-status-reader.ts new file mode 100644 index 00000000..048e95c5 --- /dev/null +++ b/src/server/routes/session-status-reader.ts @@ -0,0 +1,34 @@ +import type { SessionManager } from '../session/index.js' +import { getEventStore, combineEventsWithSnapshot } from '../events/index.js' +import { foldLastProgressAt, foldPendingConfirmations } from '../events/folding.js' +import { getPendingQuestionsForSession } from '../tools/index.js' +import { projectSessionStatus, type SessionStatus } from './session-status.js' + +export function getSessionStatus(sessionManager: SessionManager, sessionId: string): SessionStatus | null { + const session = sessionManager.getSession(sessionId) + if (!session) return null + + const activeWorkflowExecution = sessionManager.getActiveWorkflowExecution(sessionId) + const { snapshot, events: eventsSinceSnapshot } = getEventStore().getEventsSinceSnapshot(sessionId) + const events = combineEventsWithSnapshot(sessionId, snapshot, eventsSinceSnapshot) + + return projectSessionStatus({ + session, + pendingQuestionsCount: getPendingQuestionsForSession(sessionId).length, + pendingConfirmationsCount: foldPendingConfirmations(events).length, + activeWorkflowStepName: activeWorkflowExecution?.currentStepName ?? null, + lastProgressAt: foldLastProgressAt(events), + }) +} + +export function getSessionStatuses( + sessionManager: SessionManager, + sessionIds: string[], +): Record { + const statuses: Record = {} + for (const sessionId of sessionIds) { + const status = getSessionStatus(sessionManager, sessionId) + if (status) statuses[sessionId] = status + } + return statuses +} diff --git a/src/server/routes/session-status.cache.test.ts b/src/server/routes/session-status.cache.test.ts index 73313cda..beb891bb 100644 --- a/src/server/routes/session-status.cache.test.ts +++ b/src/server/routes/session-status.cache.test.ts @@ -108,6 +108,7 @@ describe('session status projection — KV-cache invariant (Cache Impact: No)', pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: 'Step', + lastProgressAt: null, }) } @@ -136,6 +137,7 @@ describe('session status projection — KV-cache invariant (Cache Impact: No)', pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) } @@ -148,14 +150,47 @@ describe('session status projection — KV-cache invariant (Cache Impact: No)', 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'] + it('does not derive stalled from lastActivityAt because cache writes update sessions.updatedAt', async () => { 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) + const projection = await fs.readFile(fileURLToPath(new URL('./session-status.ts', import.meta.url)), 'utf8') + const sessionsDb = await fs.readFile(fileURLToPath(new URL('../db/sessions.ts', import.meta.url)), 'utf8') + const cacheWriterStart = sessionsDb.indexOf('export function updateSessionCachedPrompt') + const cacheWriterEnd = sessionsDb.indexOf('export function getSessionCachedPrompt', cacheWriterStart) + expect(cacheWriterStart).toBeGreaterThanOrEqual(0) + expect(cacheWriterEnd).toBeGreaterThan(cacheWriterStart) + expect(sessionsDb.slice(cacheWriterStart, cacheWriterEnd)).toContain('updated_at = ?') + expect(projection).not.toContain('stalled') + }) + + it('status projection and GET handler stay outside the LLM request path', async () => { + const forbidden = [ + "from '../llm", + "from './llm", + "import('../llm", + "import('./llm", + "from '../context", + "from './context", + "import('../context", + "import('./context", + "from '../skills", + "from './skills", + "import('../skills", + "import('./skills", + "from '../warmup", + "from './warmup", + "import('../warmup", + "import('./warmup", + ] + const fs = await import('fs/promises') + const projection = await fs.readFile(fileURLToPath(new URL('./session-status.ts', import.meta.url)), 'utf8') + const server = await fs.readFile(fileURLToPath(new URL('../index.ts', import.meta.url)), 'utf8') + const routeStart = server.indexOf("app.get('/api/sessions/:id/status'") + const routeEnd = server.indexOf("app.delete('/api/sessions/:id'", routeStart) + expect(routeStart).toBeGreaterThanOrEqual(0) + expect(routeEnd).toBeGreaterThan(routeStart) + const route = server.slice(routeStart, routeEnd) + for (const source of [projection, route]) { + 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 index 7d21c39d..bf791823 100644 --- a/src/server/routes/session-status.test.ts +++ b/src/server/routes/session-status.test.ts @@ -26,15 +26,16 @@ function buildSession(overrides: Partial = {}): Session { } describe('projectSessionStatus', () => { - it('returns schemaVersion 1', () => { + it('returns schemaVersion 2', () => { const status = projectSessionStatus({ session: buildSession(), pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.schemaVersion).toBe(SESSION_STATUS_SCHEMA_VERSION) - expect(status.schemaVersion).toBe(1) + expect(status.schemaVersion).toBe(2) }) it('returns state "waiting" when phase is "waiting"', () => { @@ -43,6 +44,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('waiting') expect(status.waitingForUser).toBe(false) @@ -54,6 +56,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 2, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('waiting') expect(status.waitingForUser).toBe(true) @@ -65,6 +68,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 1, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('waiting') expect(status.waitingForUser).toBe(true) @@ -76,6 +80,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 1, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('waiting') }) @@ -86,6 +91,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('blocked') }) @@ -96,6 +102,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('blocked') }) @@ -106,6 +113,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('completed') }) @@ -116,6 +124,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('running') }) @@ -126,6 +135,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('completed') }) @@ -136,6 +146,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBe('running') }) @@ -146,6 +157,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.state).toBeNull() }) @@ -156,6 +168,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.phase).toBe('verification') }) @@ -166,6 +179,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: 'Build feature', + lastProgressAt: null, }) expect(status.workflowStep).toBe('Build feature') }) @@ -176,6 +190,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.workflowStep).toBeNull() }) @@ -186,16 +201,41 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.lastActivityAt).toBe('2024-06-15T12:34:56.000Z') }) + it('exposes factual progress independently from activity', () => { + const status = projectSessionStatus({ + session: buildSession({ updatedAt: '2024-06-15T12:34:56.000Z' }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + lastProgressAt: '2024-06-14T10:20:30.000Z', + }) + expect(status.lastActivityAt).toBe('2024-06-15T12:34:56.000Z') + expect(status.lastProgressAt).toBe('2024-06-14T10:20:30.000Z') + }) + + it('returns null when there is no reliable progress evidence', () => { + const status = projectSessionStatus({ + session: buildSession({ phase: 'build', isRunning: true }), + pendingQuestionsCount: 0, + pendingConfirmationsCount: 0, + activeWorkflowStepName: null, + lastProgressAt: null, + }) + expect(status.lastProgressAt).toBeNull() + }) + it('exposes a deep link to the UI', () => { const status = projectSessionStatus({ session: buildSession({ id: 'session/with-special id' }), pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.links.ui).toBe('/?sessionId=session%2Fwith-special%20id') }) @@ -206,6 +246,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(status.sessionId).toBe('abc-123') }) @@ -216,6 +257,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: 'Step 1', + lastProgressAt: null, } const a = projectSessionStatus(inputs) const b = projectSessionStatus(inputs) @@ -230,6 +272,7 @@ describe('projectSessionStatus', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }) expect(JSON.stringify(session)).toBe(snapshot) }) @@ -245,6 +288,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: 'Implement feature', + lastProgressAt: null, }), }, { @@ -255,6 +299,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }), }, { @@ -265,6 +310,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 1, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }), }, { @@ -275,6 +321,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }), }, { @@ -285,6 +332,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }), }, { @@ -295,6 +343,7 @@ describe('SessionStatus JSON contract (snapshot)', () => { pendingQuestionsCount: 0, pendingConfirmationsCount: 0, activeWorkflowStepName: null, + lastProgressAt: null, }), }, ] diff --git a/src/server/routes/session-status.ts b/src/server/routes/session-status.ts index 7cd0325e..3056a1be 100644 --- a/src/server/routes/session-status.ts +++ b/src/server/routes/session-status.ts @@ -1,29 +1,23 @@ -import type { Session, SessionPhase } from '../../shared/types.js' +import type { Session } from '../../shared/types.js' +import { + SESSION_STATUS_SCHEMA_VERSION, + type SessionStatus, + type SessionStatusState, +} from '../../shared/session-status.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 { SESSION_STATUS_SCHEMA_VERSION } +export type { SessionStatus, SessionStatusState } export interface ProjectSessionStatusInputs { session: Session pendingQuestionsCount: number pendingConfirmationsCount: number activeWorkflowStepName: string | null + lastProgressAt: string | null } export function projectSessionStatus(inputs: ProjectSessionStatusInputs): SessionStatus { - const { session, pendingQuestionsCount, pendingConfirmationsCount, activeWorkflowStepName } = inputs + const { session, pendingQuestionsCount, pendingConfirmationsCount, activeWorkflowStepName, lastProgressAt } = inputs let state: SessionStatusState = null if (session.phase === 'waiting' || pendingQuestionsCount > 0 || pendingConfirmationsCount > 0) { @@ -46,6 +40,7 @@ export function projectSessionStatus(inputs: ProjectSessionStatusInputs): Sessio workflowStep: activeWorkflowStepName, waitingForUser, lastActivityAt: session.updatedAt, + lastProgressAt, links: { ui: `/?sessionId=${encodeURIComponent(session.id)}`, }, diff --git a/src/server/ws/server.ts b/src/server/ws/server.ts index ebc78640..9d92e353 100644 --- a/src/server/ws/server.ts +++ b/src/server/ws/server.ts @@ -29,6 +29,7 @@ import { buildMessagesFromStoredEvents, foldPendingConfirmations } from '../even import { getPendingQuestionsForSession } from '../tools/index.js' import { generateSessionNameForSession, needsNameGeneration } from '../session/name-generator.js' import { getSessionMessageCount } from '../utils/session-utils.js' +import { getSessionStatus } from '../routes/session-status-reader.js' // Resolved once initial MCP connections settle — checkDynamic awaits this let resolveMcpReady: (() => void) | null = null @@ -310,9 +311,20 @@ interface ClientConnection { sendQueue: Array<{ data: string; seq: number }> isSending: boolean lastSentSeq: number + lastSessionStatuses: Map } const MAX_SEND_QUEUE_SIZE = 1000 // Maximum messages to queue before dropping +const SESSION_STATUS_EVENT_TYPES = new Set([ + 'running.changed', + 'phase.changed', + 'criteria.set', + 'criterion.updated', + 'metadata.set', + 'chat.done', + 'workflow.execution_changed', + 'task.completed', +]) /** * WebSocket Message Ordering Implementation @@ -723,6 +735,7 @@ export function createWebSocketServer( sendQueue: [], isSending: false, lastSentSeq: 0, + lastSessionStatuses: new Map(), }) // Subscribe to ALL session events (global subscription) @@ -744,6 +757,24 @@ export function createWebSocketServer( storedEvent.seq, ) } + const status = SESSION_STATUS_EVENT_TYPES.has(storedEvent.type) + ? getSessionStatus(sessionManager, storedEvent.sessionId) + : null + if (status) { + const client = clients.get(ws)! + const serializedStatus = JSON.stringify(status) + if (client.lastSessionStatuses.get(storedEvent.sessionId) !== serializedStatus) { + client.lastSessionStatuses.set(storedEvent.sessionId, serializedStatus) + enqueueSend( + client, + serializeServerMessage({ + ...createServerMessage('session.status', status), + sessionId: storedEvent.sessionId, + }), + storedEvent.seq, + ) + } + } } } catch (error) { logger.debug('Global event subscription ended', { error }) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ad18869b..d27f827e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,3 +1,4 @@ +import type { SessionStatus } from './session-status.js' import type { Project, Session, @@ -86,6 +87,7 @@ export type ServerMessageType = | 'session.deleted' | 'session.deletedAll' | 'session.running' // Real-time running state change + | 'session.status' // Canonical factual status projection | 'session.name_generated' // Session name was auto-generated | 'session.confirmation_pending' // Path confirmation waiting in another session (broadcast to all) | 'session.confirmation_resolved' // Path confirmation was answered (broadcast to all) @@ -221,8 +223,11 @@ export interface PendingPathConfirmationPayload { export interface SessionListPayload { sessions: SessionSummary[] + statuses?: Record } +export type SessionStatusPayload = SessionStatus + export interface SessionCreatedPayload { session: SessionSummary } diff --git a/src/shared/session-status.ts b/src/shared/session-status.ts new file mode 100644 index 00000000..6e04b4b3 --- /dev/null +++ b/src/shared/session-status.ts @@ -0,0 +1,17 @@ +import type { SessionPhase } from './types.js' + +export const SESSION_STATUS_SCHEMA_VERSION = 2 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 + lastProgressAt: string | null + links: { ui: string } +} diff --git a/web/src/components/layout/Header.test.tsx b/web/src/components/layout/Header.test.tsx index c379c013..c3ecf92e 100644 --- a/web/src/components/layout/Header.test.tsx +++ b/web/src/components/layout/Header.test.tsx @@ -43,6 +43,7 @@ vi.mock('../../stores/session', () => ({ messages: [], openSessionIds: [], focusedSessionId: null, + sessionStatuses: {}, agentMode: 'planner', planMode: false, status: 'idle', @@ -253,6 +254,76 @@ describe('Header', () => { expect(btn!.getAttribute('title')).toBe(longTitle) }) + it('shows current session factual progress without duplicating Running', async () => { + vi.setSystemTime(new Date('2024-01-01T00:08:00.000Z')) + const { useProjectStore } = await import('../../stores/project') + ;(useProjectStore as unknown as MockStore).setState({ + currentProject: { id: 'p1', name: 'P', workdir: '/tmp' }, + projects: [{ id: 'p1', name: 'P', workdir: '/tmp' }], + }) + const { useSessionStore } = await import('../../stores/session') + ;(useSessionStore as unknown as MockStore).setState({ + currentSession: { id: 's1', projectId: 'p1', isRunning: true, metadata: { title: 'Session' } }, + sessions: [{ id: 's1', projectId: 'p1', title: 'Session', isRunning: true }], + sessionStatuses: { + s1: { + schemaVersion: 2, + sessionId: 's1', + state: 'running', + phase: 'build', + workflowStep: null, + waitingForUser: false, + lastActivityAt: '2024-01-01T00:07:00.000Z', + lastProgressAt: '2024-01-01T00:04:00.000Z', + links: { ui: '/?sessionId=s1' }, + }, + }, + }) + const { useLocation } = await import('wouter') + vi.mocked(useLocation).mockReturnValue(['/p/p1/s/s1', vi.fn()]) + + const { Header } = await import('./Header') + const container = render(
) + const progress = container.querySelector('[data-testid="header-last-progress"]') + expect(progress?.textContent).toBe('Last progress 4m ago') + expect(progress?.textContent).not.toContain('Running') + expect(container.textContent).not.toContain('stalled') + }) + + it('shows a fixed factual progress timestamp for an inactive session', async () => { + const { useProjectStore } = await import('../../stores/project') + ;(useProjectStore as unknown as MockStore).setState({ + currentProject: { id: 'p1', name: 'P', workdir: '/tmp' }, + projects: [{ id: 'p1', name: 'P', workdir: '/tmp' }], + }) + const { useSessionStore } = await import('../../stores/session') + ;(useSessionStore as unknown as MockStore).setState({ + currentSession: { id: 's1', projectId: 'p1', isRunning: false, metadata: { title: 'Session' } }, + sessions: [{ id: 's1', projectId: 'p1', title: 'Session', isRunning: false }], + sessionStatuses: { + s1: { + schemaVersion: 2, + sessionId: 's1', + state: 'completed', + phase: 'done', + workflowStep: null, + waitingForUser: false, + lastActivityAt: '2024-01-01T00:07:00.000Z', + lastProgressAt: '2024-01-01T00:04:00.000Z', + links: { ui: '/?sessionId=s1' }, + }, + }, + }) + const { useLocation } = await import('wouter') + vi.mocked(useLocation).mockReturnValue(['/p/p1/s/s1', vi.fn()]) + + const { Header } = await import('./Header') + const container = render(
) + expect(container.querySelector('[data-testid="header-last-progress"]')?.textContent).toBe( + `Last progress ${new Date('2024-01-01T00:04:00.000Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}`, + ) + }) + it('shows only the running task count in the green badge', async () => { const { useProjectStore } = await import('../../stores/project') ;(useProjectStore as unknown as MockStore).setState({ diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 970d8533..3e06ded3 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -28,6 +28,8 @@ import { TasksModal } from '../tasks/TasksModal' import { useTasksStore } from '../../stores/tasks' import { TasksIcon, ArrowRightIcon } from '../shared/icons' import { useIsSplit } from '../../lib/splitPersistence' +import { formatTime } from '../../lib/format-date' +import { formatRelativeTime, useRelativeTimeNow } from '../../hooks/useRelativeTime' interface HeaderProps { onMenuClick?: () => void @@ -57,6 +59,10 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { const openSessionCount = useSessionStore((state) => state.openSessionIds.length) const session = useSessionStore((state) => state.currentSession) const sessions = useSessionStore((state) => state.sessions) + const sessionStatus = useSessionStore((state) => + state.currentSession ? state.sessionStatuses[state.currentSession.id] : undefined, + ) + const relativeTimeNow = useRelativeTimeNow(sessionStatus?.state === 'running') const project = useProjectStore((state) => state.currentProject) const projects = useProjectStore((state) => state.projects) const startAutoRefresh = useConfigStore((state) => state.startAutoRefresh) @@ -66,6 +72,14 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { const updateAvailable = useUpdateStore((state) => state.status === 'available') const checkForUpdate = useUpdateStore((state) => state.check) + const lastProgressLabel = sessionStatus + ? sessionStatus.lastProgressAt + ? sessionStatus.state === 'running' + ? `Last progress ${formatRelativeTime(sessionStatus.lastProgressAt, relativeTimeNow)}` + : `Last progress ${formatTime(sessionStatus.lastProgressAt)}` + : 'No factual progress yet' + : null + useEffect(() => { if (useUpdateStore.getState().status === 'idle') { checkForUpdate() @@ -153,6 +167,16 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { )} + + {!isSplit && isSessionPage && lastProgressLabel && ( + + {lastProgressLabel} + + )}
diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index a20f46f0..464a3baf 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -52,6 +52,19 @@ const sessionStoreState = { unreadSessionIds: ['session-3'], sessionsWithPendingConfirmations: [], pendingPathConfirmations: [], + sessionStatuses: { + 'session-1': { + schemaVersion: 2 as const, + sessionId: 'session-1', + state: 'running' as const, + phase: 'build' as const, + workflowStep: null, + waitingForUser: false, + lastActivityAt: '2024-01-01T00:05:00.000Z', + lastProgressAt: '2024-01-01T00:04:00.000Z' as string | null, + links: { ui: '/?sessionId=session-1' }, + }, + }, createSession: vi.fn(), deleteSession: vi.fn(), listSessions: vi.fn(), @@ -99,6 +112,29 @@ describe('Sidebar', () => { expect(html).toContain('animate-spin') }) + it('shows factual progress for running sessions', () => { + vi.setSystemTime(new Date('2024-01-01T00:08:00.000Z')) + const html = renderToStaticMarkup() + expect(html).toContain('last progress 4m ago') + }) + + it('shows a discrete empty state when a running session has no factual progress', () => { + sessionStoreStateRef.current = { + ...sessionStoreState, + sessionStatuses: { + 'session-1': { ...sessionStoreState.sessionStatuses['session-1'], lastProgressAt: null }, + }, + } + const html = renderToStaticMarkup() + expect(html).toContain('no factual progress yet') + }) + + it('does not show factual progress for inactive sessions', () => { + const html = renderToStaticMarkup() + expect(html).not.toContain('last progress 4m ago') + expect(html).not.toContain('stalled') + }) + it('groups all indicators on the right with proper alignment', () => { const html = renderToStaticMarkup() diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index fbe78492..1beb26de 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/session' import type { PendingPathConfirmation } from '../../stores/session/types' import { useProjectStore } from '../../stores/project' import type { SessionSummary } from '@shared/types.js' +import type { SessionStatus } from '@shared/session-status.js' import { ProjectSettingsModal } from '../settings/ProjectSettingsModal' import { DropdownMenu } from '../shared/DropdownMenu' import { ScrollArea } from '../shared/ScrollArea' @@ -18,6 +19,7 @@ import { shouldAutofocus } from '../../lib/device' import { useBinding, useKeybindings } from '../../hooks/useKeybindings.js' import { useResizable } from '../../hooks/useResizable' import { ResizeHandle } from '../shared/ResizeHandle' +import { formatRelativeTime, useRelativeTimeNow } from '../../hooks/useRelativeTime' interface SidebarProps { projectId: string @@ -34,6 +36,7 @@ export function Sidebar({ projectId, isOpen = true, onClose }: SidebarProps) { const [showDeleteAll, setShowDeleteAll] = useState(false) const sessions = useSessionStore((state) => state.sessions) + const sessionStatuses = useSessionStore((state) => state.sessionStatuses) const currentSession = useSessionStore((state) => state.currentSession) const unreadSessionIds = useSessionStore((state) => state.unreadSessionIds) const deleteSession = useSessionStore((state) => state.deleteSession) @@ -101,6 +104,7 @@ export function Sidebar({ projectId, isOpen = true, onClose }: SidebarProps) { // Filter sessions to those belonging to the current project by ID const projectSessions = sessions.filter((session) => session.projectId === currentProject?.id) + const relativeTimeNow = useRelativeTimeNow(projectSessions.some((session) => session.isRunning)) const [favoriteSessions, otherSessions] = useMemo(() => { const favs: SessionSummary[] = [] @@ -388,6 +392,8 @@ export function Sidebar({ projectId, isOpen = true, onClose }: SidebarProps) { pendingPathConfirmations, searchQuery, focusedIndex, + sessionStatuses, + relativeTimeNow, )}
{sessionsPaginationLoading && ( @@ -435,6 +441,8 @@ function renderSessionList( pendingPathConfirmations: PendingPathConfirmation[], searchQuery: string, focusedIndex: number, + sessionStatuses: Record, + relativeTimeNow: number, ) { let flatIdx = 0 @@ -444,6 +452,12 @@ function renderSessionList( const isFocused = idx === focusedIndex const hasUnread = unreadSessionIds.includes(session.id) const isRunning = session.isRunning + const status = sessionStatuses?.[session.id] + const progressText = isRunning + ? status?.lastProgressAt + ? `last progress ${formatRelativeTime(status.lastProgressAt, relativeTimeNow)}` + : 'no factual progress yet' + : null const isFavorite = session.isFavorite const hasPendingConfirmation = sessionsWithPendingConfirmations.includes(session.id) || (isActive && pendingPathConfirmations.length > 0) @@ -523,6 +537,11 @@ function renderSessionList( )} {/* Message count in muted style */} {session.messageCount} messages + {progressText && ( + + · {progressText} + + )} diff --git a/web/src/hooks/useRelativeTime.ts b/web/src/hooks/useRelativeTime.ts new file mode 100644 index 00000000..e4b2f4a5 --- /dev/null +++ b/web/src/hooks/useRelativeTime.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' + +const RELATIVE_TIME_REFRESH_MS = 60_000 + +export function formatRelativeTime(timestamp: string, now: number = Date.now()): string { + const elapsedSeconds = Math.max(0, Math.floor((now - Date.parse(timestamp)) / 1000)) + if (elapsedSeconds < 60) return 'just now' + const minutes = Math.floor(elapsedSeconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + +export function useRelativeTimeNow(enabled: boolean): number { + const [now, setNow] = useState(Date.now) + + useEffect(() => { + if (!enabled) return + const timer = window.setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS) + return () => window.clearInterval(timer) + }, [enabled]) + + return now +} diff --git a/web/src/stores/session/messageHandler.test.ts b/web/src/stores/session/messageHandler.test.ts index 0f9b5c44..7a530e13 100644 --- a/web/src/stores/session/messageHandler.test.ts +++ b/web/src/stores/session/messageHandler.test.ts @@ -64,6 +64,31 @@ async function loadSessionStore(): Promise { + it('stores the canonical server projection without deriving progress client-side', async () => { + const useSessionStore = await loadSessionStore() + const status = { + schemaVersion: 2 as const, + sessionId: 'session-1', + state: 'running' as const, + phase: 'build' as const, + workflowStep: 'Build UI', + waitingForUser: false, + lastActivityAt: '2024-01-01T00:05:00.000Z', + lastProgressAt: '2024-01-01T00:04:00.000Z', + links: { ui: '/?sessionId=session-1' }, + } + + useSessionStore.getState().handleServerMessage({ + type: 'session.status', + sessionId: 'session-1', + payload: status, + }) + + expect(useSessionStore.getState().sessionStatuses['session-1']).toEqual(status) + }) +}) + describe('session.name_generated handler', () => { beforeEach(() => { wsSendMock.mockClear() diff --git a/web/src/stores/session/messageHandler.ts b/web/src/stores/session/messageHandler.ts index bbc357e3..411ac23c 100644 --- a/web/src/stores/session/messageHandler.ts +++ b/web/src/stores/session/messageHandler.ts @@ -5,6 +5,7 @@ import type { GitDiffFile, SessionListPayload, SessionRunningPayload, + SessionStatusPayload, ChatAskUserPayload, ChatDeltaPayload, ChatThinkingPayload, @@ -327,6 +328,14 @@ export function handleServerMessage( break } + case 'session.status': { + const payload = message.payload as SessionStatusPayload + set((state) => ({ + sessionStatuses: { ...state.sessionStatuses, [payload.sessionId]: payload }, + })) + break + } + case 'session.running': { const payload = message.payload as SessionRunningPayload updateSessionField(message, set, get, (s) => ({ ...s, isRunning: payload.isRunning })) diff --git a/web/src/stores/session/store.ts b/web/src/stores/session/store.ts index c9454783..e65321fe 100644 --- a/web/src/stores/session/store.ts +++ b/web/src/stores/session/store.ts @@ -4,6 +4,7 @@ import { appUrl } from '../../lib/basePath' import { consumePrefetchedSession } from '../../lib/sessionPrefetch' import type { SessionSummary, Message, Session, ContextState, WorkflowExecution } from '@shared/types.js' import type { QueuedMessage, PendingQuestionPayload } from '@shared/protocol.js' +import type { SessionStatus } from '@shared/session-status.js' import { wsClient } from '../../lib/ws' import { useConfigStore } from '../config' import { useProjectStore } from '../project' @@ -41,6 +42,7 @@ interface SessionLoadData { pendingConfirmations?: PendingPathConfirmation[] pendingQuestions?: PendingQuestionPayload[] activeWorkflowExecution?: WorkflowExecution | null + status?: SessionStatus } function applyToolOutputs( @@ -302,6 +304,7 @@ export const useSessionStore = create((set, get) => { } return { ...replacePane(s, sessionId, nextPane), + ...(data.status ? { sessionStatuses: { ...s.sessionStatuses, [sessionId]: data.status } } : {}), crossSessionConfirmations: crossCleanup2, sessionsWithPendingConfirmations: Object.keys(crossCleanup2), } @@ -331,6 +334,7 @@ export const useSessionStore = create((set, get) => { showPasswordModal: false, passwordModalRetry: false, sessions: [], + sessionStatuses: {}, searchSessions: null, currentSession: null, unreadSessionIds: [], @@ -630,8 +634,10 @@ export const useSessionStore = create((set, get) => { const res = await authFetch(`/api/sessions?${params.toString()}`) const data = await res.json() const incoming = (data.sessions ?? []) as SessionSummary[] + const statuses = (data.statuses ?? {}) as Record set((state) => ({ sessions: mergeSessionSummaries(incoming, state), + sessionStatuses: { ...state.sessionStatuses, ...statuses }, sessionsHasMore: projectId ? (data.hasMore ?? false) : true, })) diff --git a/web/src/stores/session/types.ts b/web/src/stores/session/types.ts index 6901e554..7d819570 100644 --- a/web/src/stores/session/types.ts +++ b/web/src/stores/session/types.ts @@ -11,6 +11,7 @@ import type { WorkflowExecution, } from '@shared/types.js' import type { ServerMessage, QueuedMessage, ChoiceOption } from '@shared/protocol.js' +import type { SessionStatus } from '@shared/session-status.js' import type { ConnectionStatus } from '../../lib/ws' export interface PendingPathConfirmation { @@ -84,6 +85,7 @@ export interface SessionState { showPasswordModal: boolean passwordModalRetry: boolean sessions: SessionSummary[] + sessionStatuses: Record searchSessions: SessionSummary[] | null currentSession: Session | null unreadSessionIds: string[]