diff --git a/apis/json-schema/ApplicationUpdatePayload.yaml b/apis/json-schema/ApplicationUpdatePayload.yaml new file mode 100644 index 000000000..748d10e68 --- /dev/null +++ b/apis/json-schema/ApplicationUpdatePayload.yaml @@ -0,0 +1,24 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: ApplicationUpdatePayload.yaml +type: object +properties: + applicationId: + type: string + description: The application whose row changed + setupComplete: + type: boolean + description: Current setup_complete flag (after the transition) + status: + $ref: ApplicationStatus.yaml + description: Current application status (after the transition) + gitRemoteUrl: + type: string + description: Current git remote URL, if one is set + cloudDeploymentProvider: + $ref: CloudDeploymentProvider.yaml + description: Selected cloud deployment provider, if one is set +required: + - applicationId + - setupComplete + - status +description: Scoped payload for an ApplicationUpdated notification — carries only the fields the client patches in-place. Deltas for unchanged fields are omitted. diff --git a/apis/json-schema/NotificationEvent.yaml b/apis/json-schema/NotificationEvent.yaml index 07a06e141..d2872312c 100644 --- a/apis/json-schema/NotificationEvent.yaml +++ b/apis/json-schema/NotificationEvent.yaml @@ -27,6 +27,12 @@ properties: type: string format: date-time description: When the event occurred + applicationUpdate: + $ref: ApplicationUpdatePayload.yaml + description: Scoped payload for ApplicationUpdated events — present iff eventType === ApplicationUpdated + operationLogAppend: + $ref: OperationLogAppendPayload.yaml + description: Scoped payload for OperationLogAppended events — present iff eventType === OperationLogAppended required: - eventType - agentRunId diff --git a/apis/json-schema/NotificationEventConfig.yaml b/apis/json-schema/NotificationEventConfig.yaml index a44ff4285..3aa566897 100644 --- a/apis/json-schema/NotificationEventConfig.yaml +++ b/apis/json-schema/NotificationEventConfig.yaml @@ -50,6 +50,14 @@ properties: type: boolean default: true description: Notify when cloud deployment status changes (spec 089) + applicationUpdated: + type: boolean + default: true + description: Notify when application row changes (setupComplete, status, gitRemoteUrl, cloudDeploymentProvider — spec 090) + operationLogAppended: + type: boolean + default: true + description: Notify when a new operation log entry is appended (spec 090) required: - agentStarted - phaseCompleted diff --git a/apis/json-schema/NotificationEventType.yaml b/apis/json-schema/NotificationEventType.yaml index ac18240c8..960d126ac 100644 --- a/apis/json-schema/NotificationEventType.yaml +++ b/apis/json-schema/NotificationEventType.yaml @@ -14,4 +14,6 @@ enum: - pr_blocked - merge_review_ready - cloud_deployment_updated + - application_updated + - operation_log_appended description: Types of agent lifecycle notification events diff --git a/apis/json-schema/OperationLogAppendPayload.yaml b/apis/json-schema/OperationLogAppendPayload.yaml new file mode 100644 index 000000000..432668699 --- /dev/null +++ b/apis/json-schema/OperationLogAppendPayload.yaml @@ -0,0 +1,10 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: OperationLogAppendPayload.yaml +type: object +properties: + entry: + $ref: OperationLogEntry.yaml + description: The newly-appended operation log entry +required: + - entry +description: Scoped payload for an OperationLogAppended notification — carries the newly-appended entry so clients can patch their log list in-place without a refetch. diff --git a/apis/json-schema/OperationLogKind.yaml b/apis/json-schema/OperationLogKind.yaml index 213654be9..670cd9bd3 100644 --- a/apis/json-schema/OperationLogKind.yaml +++ b/apis/json-schema/OperationLogKind.yaml @@ -5,4 +5,5 @@ enum: - CloudDeploy - GitRemoteCreate - RepoSync + - ApplicationSetup description: Kind of long-running operation an OperationLogEntry belongs to diff --git a/package.json b/package.json index 70e5e97be..a250593bc 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "minimatch": "^7.x" } }, - "packageManager": "pnpm@10.30.0", + "packageManager": "pnpm@10.33.0", "dependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.8", diff --git a/packages/core/src/application/ports/output/services/application-scaffolder.interface.ts b/packages/core/src/application/ports/output/services/application-scaffolder.interface.ts index d97ed52ef..cd9feb4fd 100644 --- a/packages/core/src/application/ports/output/services/application-scaffolder.interface.ts +++ b/packages/core/src/application/ports/output/services/application-scaffolder.interface.ts @@ -32,8 +32,44 @@ export interface ScaffoldOptions { * `package.json#name`, folder naming during intermediate steps, etc. */ readonly projectName: string; + + /** + * ID of the Application this scaffold belongs to. Used as the + * `operationId` when the adapter appends progress/error entries to + * the shared operation log so the UI can stream the scaffold's + * stdout/stderr into the same drawer that shows deploy / publish / + * sync activity. + */ + readonly applicationId: string; + + /** + * Progress callback. The adapter emits high-level phase events + * (`Starting shadcn init`, `bun add extras done`) and deduped CLI + * output lines from child processes as they arrive. Must never + * throw — errors inside the callback are swallowed by the adapter + * so a failing sink cannot abort a successful scaffold. + * + * Level mapping: + * - `Info` — phase boundaries + stdout from child processes + * - `Warn` — stderr from child processes (surfaced as warnings so + * the user can see progress chatter without the whole log + * turning red; real failures are emitted as `Error` by the + * adapter itself on non-zero exit) + * - `Error` — phase failure with the exception message + * - `Debug` — low-priority bookkeeping (reserved) + */ + readonly onLog?: ScaffoldLogCallback; } +export type ScaffoldLogLevel = 'Debug' | 'Info' | 'Warn' | 'Error'; + +export type ScaffoldLogCallback = (entry: { + level: ScaffoldLogLevel; + message: string; + /** Optional multi-line block — typically captured stdout/stderr. */ + detail?: string; +}) => void; + export interface ScaffoldResult { /** * Absolute path to the finished project root. Always equal to diff --git a/packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts b/packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts new file mode 100644 index 000000000..fc7978a57 --- /dev/null +++ b/packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts @@ -0,0 +1,25 @@ +import type { OperationLogEntry } from '../../../../domain/generated/output.js'; + +/** + * OperationLog Event Bus (port) + * + * In-process pub/sub for newly-appended operation_log_entries. The SSE + * route (StreamAgentEventsUseCase in spec 090) subscribes and re-emits + * each publish as a notification so the web client can render log + * lines in real time without polling. + * + * DB is source of truth — publishers MUST publish only after a + * successful INSERT. Subscribers MUST NOT assume they see every event + * (SW restart / reconnect may drop a window); the client should + * rehydrate from the DB on first open. + */ +export interface OperationLogEvent { + entry: OperationLogEntry; +} + +export type OperationLogEventListener = (event: OperationLogEvent) => void; + +export interface IOperationLogEventBus { + publish(event: OperationLogEvent): void; + subscribe(listener: OperationLogEventListener): () => void; +} diff --git a/packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts b/packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts index 026a4aca7..63886709b 100644 --- a/packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts +++ b/packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts @@ -27,22 +27,30 @@ import { inject, injectable } from 'tsyringe'; import type { IAgentRunRepository } from '../../ports/output/agents/agent-run-repository.interface.js'; import type { IPhaseTimingRepository } from '../../ports/output/agents/phase-timing-repository.interface.js'; +import type { IApplicationRepository } from '../../ports/output/repositories/application-repository.interface.js'; import type { IInteractiveSessionRepository } from '../../ports/output/repositories/interactive-session-repository.interface.js'; import type { ICloudDeploymentEventBus } from '../../ports/output/services/cloud-deployment-event-bus.interface.js'; import type { ILogger } from '../../ports/output/services/logger.interface.js'; +import type { IOperationLogEventBus } from '../../ports/output/services/operation-log-event-bus.interface.js'; import type { IProcessLivenessProbe } from '../../ports/output/services/process-liveness.interface.js'; import { ListFeaturesUseCase } from '../features/list-features.use-case.js'; import type { AgentRun, Feature } from '../../../domain/generated/output.js'; -import { NotificationEventType, NotificationSeverity } from '../../../domain/generated/output.js'; +import { + NotificationEventType, + NotificationSeverity, + OperationLogLevel, +} from '../../../domain/generated/output.js'; +import { computeApplicationDeltas } from './stream-agent-events/compute-application-deltas.js'; import { computeFeatureDeltas } from './stream-agent-events/compute-feature-deltas.js'; import { computePhaseCompletionDeltas } from './stream-agent-events/compute-phase-completion-deltas.js'; import { computePrDeltas } from './stream-agent-events/compute-pr-deltas.js'; import { computeSessionDeltas } from './stream-agent-events/compute-session-deltas.js'; import { computeStatusDeltas } from './stream-agent-events/compute-status-deltas.js'; import type { + CachedApplicationState, CachedFeatureState, CachedSessionState, StreamedAgentEvent, @@ -83,6 +91,10 @@ export class StreamAgentEventsUseCase { private readonly processLiveness: IProcessLivenessProbe, @inject('ICloudDeploymentEventBus') private readonly cloudEventBus: ICloudDeploymentEventBus, + @inject('IApplicationRepository') + private readonly applicationRepo: IApplicationRepository, + @inject('IOperationLogEventBus') + private readonly operationLogEventBus: IOperationLogEventBus, @inject('ILogger') private readonly logger: ILogger ) {} @@ -102,6 +114,7 @@ export class StreamAgentEventsUseCase { const featureCache = new Map(); const sessionCache = new Map(); + const applicationCache = new Map(); const queue: StreamedAgentEvent[] = []; let notify: (() => void) | null = null; @@ -115,13 +128,20 @@ export class StreamAgentEventsUseCase { }; const unsubscribeCloudDeploy = this.subscribeCloudDeploy(enqueue); + const unsubscribeOperationLog = this.subscribeOperationLog(enqueue); let pollErrorCount = 0; try { while (!signal?.aborted) { try { - await this.pollOnce({ runIdFilter, featureCache, sessionCache, enqueue }); + await this.pollOnce({ + runIdFilter, + featureCache, + sessionCache, + applicationCache, + enqueue, + }); pollErrorCount = 0; } catch (error) { pollErrorCount++; @@ -178,6 +198,11 @@ export class StreamAgentEventsUseCase { } catch { // Listener may already be detached. } + try { + unsubscribeOperationLog(); + } catch { + // Listener may already be detached. + } } } @@ -218,6 +243,43 @@ export class StreamAgentEventsUseCase { }); } + /** + * Subscribe to the in-process operation-log event bus and re-emit each + * publish as a `NotificationEventType.OperationLogAppended` notification. + * Returns the unsubscribe handle. + */ + private subscribeOperationLog(enqueue: (event: StreamedAgentEvent) => void): () => void { + return this.operationLogEventBus.subscribe(({ entry }) => { + const severity = + entry.level === OperationLogLevel.Error + ? NotificationSeverity.Error + : entry.level === OperationLogLevel.Warn + ? NotificationSeverity.Warning + : NotificationSeverity.Info; + + const timestamp = + entry.createdAt instanceof Date + ? entry.createdAt.toISOString() + : typeof entry.createdAt === 'string' + ? entry.createdAt + : String(entry.createdAt); + + enqueue({ + kind: 'notification', + event: { + eventType: NotificationEventType.OperationLogAppended, + agentRunId: entry.operationId, + featureId: entry.operationId, + featureName: entry.operationKind, + message: entry.message, + severity, + timestamp, + operationLogAppend: { entry }, + }, + }); + }); + } + /** * Single poll cycle: walk every feature's latest agent run, diff against * the connection cache, and enqueue notification events for any observed @@ -227,9 +289,10 @@ export class StreamAgentEventsUseCase { runIdFilter?: string; featureCache: Map; sessionCache: Map; + applicationCache: Map; enqueue: (event: StreamedAgentEvent) => void; }): Promise { - const { runIdFilter, featureCache, sessionCache, enqueue } = args; + const { runIdFilter, featureCache, sessionCache, applicationCache, enqueue } = args; const features = await this.listFeatures.execute(); @@ -294,6 +357,27 @@ export class StreamAgentEventsUseCase { } catch { // Ignore interactive session poll errors to not affect main polling. } + + // Application row polling — diff against per-connection cache and emit + // `ApplicationUpdated` on any watched-field change. Seed is silent. + try { + const applications = await this.applicationRepo.list(); + for (const app of applications) { + if (runIdFilter && app.id !== runIdFilter) continue; + const prev = applicationCache.get(app.id); + for (const event of computeApplicationDeltas({ application: app, prev })) { + enqueue(event); + } + applicationCache.set(app.id, { + setupComplete: app.setupComplete, + status: app.status, + gitRemoteUrl: app.gitRemoteUrl, + cloudDeploymentProvider: app.cloudDeploymentProvider, + }); + } + } catch { + // Ignore application-poll failures; same posture as session polling. + } } /** diff --git a/packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts b/packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts new file mode 100644 index 000000000..08c207a74 --- /dev/null +++ b/packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts @@ -0,0 +1,68 @@ +/** + * Pure helper: compute ApplicationUpdated delta for a single application row. + * + * Diffs the current `Application` against the cached snapshot of a watched + * field set. Emits at most ONE notification event carrying the full updated + * payload. Seed case (no prev) returns zero events — the caller is expected + * to populate the cache on first sight. + * + * Pure: no I/O, no timers, no side effects. `prev` is NOT mutated here — the + * use-case owns cache lifecycle so the helper stays trivially testable. + */ + +import type { Application } from '../../../../domain/generated/output.js'; +import { + NotificationEventType, + NotificationSeverity, +} from '../../../../domain/generated/output.js'; + +import type { CachedApplicationState, StreamedAgentEvent } from './stream-agent-events.types.js'; + +const WATCHED_FIELDS = [ + 'setupComplete', + 'status', + 'gitRemoteUrl', + 'cloudDeploymentProvider', +] as const; + +export interface ComputeApplicationDeltasArgs { + application: Application; + prev: CachedApplicationState | undefined; +} + +export function computeApplicationDeltas(args: ComputeApplicationDeltasArgs): StreamedAgentEvent[] { + const { application, prev } = args; + + if (prev === undefined) return []; + + let changed = false; + for (const field of WATCHED_FIELDS) { + if (prev[field] !== application[field]) { + changed = true; + break; + } + } + if (!changed) return []; + + return [ + { + kind: 'notification', + event: { + eventType: NotificationEventType.ApplicationUpdated, + agentRunId: '', + featureId: application.id, + featureName: application.name, + message: `Application "${application.name}" updated`, + severity: NotificationSeverity.Info, + timestamp: new Date().toISOString(), + applicationUpdate: { + applicationId: application.id, + setupComplete: application.setupComplete, + status: application.status, + gitRemoteUrl: application.gitRemoteUrl, + cloudDeploymentProvider: application.cloudDeploymentProvider, + }, + }, + }, + ]; +} diff --git a/packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts b/packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts index 35a807642..e7c26da1b 100644 --- a/packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts +++ b/packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts @@ -7,7 +7,11 @@ * next to it. */ -import type { NotificationEvent } from '../../../../domain/generated/output.js'; +import type { + ApplicationStatus, + CloudDeploymentProvider, + NotificationEvent, +} from '../../../../domain/generated/output.js'; import { AgentRunStatus, InteractiveSessionStatus } from '../../../../domain/generated/output.js'; import { InteractiveSessionEventType, @@ -65,6 +69,14 @@ export interface CachedSessionState { status: InteractiveSessionStatus; } +/** Per-connection cached state for an application row. */ +export interface CachedApplicationState { + setupComplete: boolean; + status: ApplicationStatus; + gitRemoteUrl: string | undefined; + cloudDeploymentProvider: CloudDeploymentProvider | undefined; +} + /** * Mapping table from terminal-ish `AgentRunStatus` values to the notification * they should produce. Exported so helper modules can share one source of diff --git a/packages/core/src/application/use-cases/applications/create-application.use-case.ts b/packages/core/src/application/use-cases/applications/create-application.use-case.ts index 2fd6db7fe..2a3f2ea56 100644 --- a/packages/core/src/application/use-cases/applications/create-application.use-case.ts +++ b/packages/core/src/application/use-cases/applications/create-application.use-case.ts @@ -14,8 +14,14 @@ import { injectable, inject } from 'tsyringe'; import { randomUUID, randomBytes } from 'node:crypto'; import type { Application, InteractiveMessage } from '../../../domain/generated/output.js'; -import { ApplicationStatus, InteractiveMessageRole } from '../../../domain/generated/output.js'; +import { + ApplicationStatus, + InteractiveMessageRole, + OperationLogKind, + OperationLogLevel, +} from '../../../domain/generated/output.js'; import type { IApplicationRepository } from '../../ports/output/repositories/application-repository.interface.js'; +import type { IOperationLogRepository } from '../../ports/output/repositories/operation-log.repository.interface.js'; import type { IApplicationCreationPromptBuilder } from '../../ports/output/services/application-creation-prompt-builder.interface.js'; import type { IApplicationBriefStore } from '../../ports/output/services/application-brief-store.interface.js'; import type { IApplicationScaffolder } from '../../ports/output/services/application-scaffolder.interface.js'; @@ -163,6 +169,8 @@ export class CreateApplicationUseCase { private readonly scaffolder: IApplicationScaffolder, @inject('IInteractiveMessageRepository') private readonly messageRepo: IInteractiveMessageRepository, + @inject('IOperationLogRepository') + private readonly operationLogRepo: IOperationLogRepository, @inject('ILogger') private readonly logger: ILogger ) {} @@ -299,14 +307,46 @@ export class CreateApplicationUseCase { modelOverride: string | undefined; }): Promise { // Phase A — scaffold the project tree. + // + // Wire an `onLog` sink that forwards every scaffolder event into + // the shared operation_log_entries table under the ApplicationSetup + // kind so the Smart Deploy Activity drawer can stream CLI output + // (create-vite, bun install, bun add, template overlay) in real + // time. Append failures are intentionally swallowed — a broken + // log sink must never abort a successful scaffold. + const appId = args.application.id; + const onScaffoldLog = (entry: { + level: 'Debug' | 'Info' | 'Warn' | 'Error'; + message: string; + detail?: string; + }): void => { + void this.operationLogRepo + .append({ + operationKind: OperationLogKind.ApplicationSetup, + operationId: appId, + level: entry.level as OperationLogLevel, + message: entry.message, + detail: entry.detail, + }) + .catch(() => { + // Log-sink errors are non-fatal. + }); + }; + try { await this.scaffolder.scaffold({ repositoryPath: args.projectPath, projectName: args.application.name, + applicationId: args.application.id, + onLog: onScaffoldLog, }); } catch (err) { - this.logger.error('[create-application] scaffold failed', { - err: err instanceof Error ? err.message : String(err), + const message = err instanceof Error ? err.message : String(err); + this.logger.error('[create-application] scaffold failed', { err: message }); + onScaffoldLog({ + level: 'Error', + message: 'Application setup failed', + detail: message, }); try { await this.appRepo.update(args.application.id, { diff --git a/packages/core/src/application/use-cases/interactive/get-chat-turn-groups.use-case.ts b/packages/core/src/application/use-cases/interactive/get-chat-turn-groups.use-case.ts new file mode 100644 index 000000000..d7ad0a607 --- /dev/null +++ b/packages/core/src/application/use-cases/interactive/get-chat-turn-groups.use-case.ts @@ -0,0 +1,196 @@ +/** + * GetChatTurnGroupsUseCase + * + * Server-side derivation of "user turn groups" for the chat thread. + * + * The raw interactive_messages table is a flat, append-only log of + * user/assistant pairs. The chat UI wants to collapse every COMPLETED + * user turn into a single named card ("Working on: …") so the thread + * doesn't become an endless scroll of raw bubbles — but the MOST + * RECENT turn must stay live so the user keeps seeing the reply that + * is actively streaming in response to what they just typed. + * + * Grouping rules: + * 1. Ignore messages that carry a `stepId` — those are setup-workflow + * messages and live inside the StepTracker, not the flat thread. + * 2. Walk the remaining messages in chronological order. Every user + * message opens a new turn; every subsequent assistant message is + * attached to the open turn until the next user message. + * 3. The LAST turn is never grouped (it is the live turn). + * 4. `hiddenMessageIds` lists every message id that a client should + * FILTER OUT of the flat thread render — the client then draws + * a collapsible group card in its place. + * + * This use case is read-only. It speaks exclusively to the + * IInteractiveMessageRepository port, so presentation layers can ask + * for groups without any coupling to the underlying SQLite schema. + * + * Feature: application-chat turn grouping (see spec in CLAUDE memo). + */ + +import { inject, injectable } from 'tsyringe'; +import type { IInteractiveMessageRepository } from '../../ports/output/repositories/interactive-message-repository.interface.js'; +import type { InteractiveMessage } from '../../../domain/generated/output.js'; +import { InteractiveMessageRole } from '../../../domain/generated/output.js'; + +/** One user-turn group, ready for the client to render as a card. */ +export interface ChatTurnGroup { + /** Stable id derived from the first (user) message id in the turn. */ + id: string; + /** User-facing title, e.g. "Working on: Fix login bug". */ + title: string; + /** Trimmed preview of the user message text, capped to PREVIEW_MAX. */ + userMessagePreview: string; + /** Message ids that belong to this turn, in order. */ + messageIds: string[]; + /** Number of assistant replies collected inside the turn. */ + assistantMessageCount: number; + /** When the user message was written (epoch ms). */ + startedAt: number; + /** When the last message in the turn was written (epoch ms). */ + endedAt: number; + /** + * `completed` for everything the client should collapse by default. + * `in-progress` is reserved for `currentTurn` — the most recent + * user turn, which the client renders as an expanded "Working on + * your request…" card with the live streaming indicator inside it. + */ + status: 'completed' | 'in-progress'; +} + +export interface GetChatTurnGroupsInput { + featureId: string; +} + +export interface GetChatTurnGroupsResult { + /** Completed turn groups in chronological order. */ + groups: ChatTurnGroup[]; + /** + * The most recent user-initiated turn, always `status: 'in-progress'` + * when present. The client renders this as an expanded card with + * the streaming indicator pinned inside it, so a new "Working on + * your request…" surface appears the instant the user sends a + * message — no flat "Thinking…" bubble in the thread. Null when + * the feature has no user-initiated turns yet (e.g. only setup + * messages exist). + */ + currentTurn: ChatTurnGroup | null; + /** + * Every message id that belongs to a returned group — completed + * groups AND the current turn. The UI filters these out of the raw + * thread so nothing renders twice. + */ + hiddenMessageIds: string[]; +} + +const PREVIEW_MAX = 120; +const TITLE_MAX = 140; +const FALLBACK_TITLE = 'Working on your request'; + +function toEpochMs(raw: unknown): number { + if (raw instanceof Date) return raw.getTime(); + if (typeof raw === 'number' && Number.isFinite(raw)) return raw; + if (typeof raw === 'string') { + const asNum = Number(raw); + if (Number.isFinite(asNum)) return asNum; + const parsed = new Date(raw).getTime(); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function buildTitle(userText: string): string { + const cleaned = userText.trim().replace(/\s+/g, ' '); + if (cleaned.length === 0) return FALLBACK_TITLE; + const preview = truncate(cleaned, PREVIEW_MAX); + return truncate(`Working on: ${preview}`, TITLE_MAX); +} + +@injectable() +export class GetChatTurnGroupsUseCase { + constructor( + @inject('IInteractiveMessageRepository') + private readonly messages: IInteractiveMessageRepository + ) {} + + async execute(input: GetChatTurnGroupsInput): Promise { + const all = await this.messages.findByFeatureId(input.featureId); + + // Setup-workflow messages live inside the StepTracker — never + // part of the flat-thread turn grouping. + const flat = all.filter((m) => !m.stepId); + if (flat.length === 0) { + return { groups: [], currentTurn: null, hiddenMessageIds: [] }; + } + + // Walk in chronological order and bucket into turns. A turn opens + // on every user message; the assistant replies that immediately + // follow get attached to it until the next user message opens a + // new turn. + interface Turn { + user: InteractiveMessage; + items: InteractiveMessage[]; + } + const turns: Turn[] = []; + let current: Turn | null = null; + for (const m of flat) { + if (m.role === InteractiveMessageRole.user) { + current = { user: m, items: [m] }; + turns.push(current); + } else if (current) { + current.items.push(m); + } + // If we see an assistant message with NO open turn, it is an + // orphan (very rare — only possible from out-of-order writes). + // Drop it silently: it would not belong to any grouped card and + // forcing it into one would be misleading. + } + + if (turns.length === 0) { + return { groups: [], currentTurn: null, hiddenMessageIds: [] }; + } + + // The final turn is the "current" one — the user's most recent + // ask, which the client renders as an expanded in-progress card + // with the live streaming indicator pinned inside it. Everything + // before it is a completed group the client collapses by default. + const completed = turns.slice(0, -1); + const latest = turns[turns.length - 1]; + + const completedGroups: ChatTurnGroup[] = completed.map((t) => buildGroup(t, 'completed')); + const currentTurn: ChatTurnGroup = buildGroup(latest, 'in-progress'); + + const hiddenMessageIds = [ + ...completedGroups.flatMap((g) => g.messageIds), + ...currentTurn.messageIds, + ]; + return { groups: completedGroups, currentTurn, hiddenMessageIds }; + } +} + +function buildGroup( + t: { user: InteractiveMessage; items: InteractiveMessage[] }, + status: 'completed' | 'in-progress' +): ChatTurnGroup { + const first = t.items[0]; + const last = t.items[t.items.length - 1]; + const userText = t.user.content ?? ''; + const assistantMessageCount = t.items.filter( + (m) => m.role === InteractiveMessageRole.assistant + ).length; + return { + id: `turn-${t.user.id}`, + title: buildTitle(userText), + userMessagePreview: truncate(userText.trim().replace(/\s+/g, ' '), PREVIEW_MAX), + messageIds: t.items.map((m) => m.id), + assistantMessageCount, + startedAt: toEpochMs(first.createdAt), + endedAt: toEpochMs(last.createdAt), + status, + }; +} diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index 69aaa3339..ddafc8ac7 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -607,6 +607,14 @@ export type NotificationEventConfig = { * Notify when cloud deployment status changes (spec 089) */ cloudDeploymentUpdated?: boolean; + /** + * Notify when application row changes (setupComplete, status, gitRemoteUrl, cloudDeploymentProvider — spec 090) + */ + applicationUpdated?: boolean; + /** + * Notify when a new operation log entry is appended (spec 090) + */ + operationLogAppended?: boolean; }; /** @@ -1671,6 +1679,92 @@ export type Tool = BaseEntity & { */ installedAt?: any; }; +export enum ApplicationStatus { + Idle = 'Idle', + Active = 'Active', + Error = 'Error', +} +export enum CloudDeploymentProvider { + CloudflarePages = 'CloudflarePages', + Vercel = 'Vercel', + Netlify = 'Netlify', + AwsAmplify = 'AwsAmplify', + GcpCloudRun = 'GcpCloudRun', +} + +/** + * Scoped payload for an ApplicationUpdated notification — carries only the fields the client patches in-place. Deltas for unchanged fields are omitted. + */ +export type ApplicationUpdatePayload = { + /** + * The application whose row changed + */ + applicationId: string; + /** + * Current setup_complete flag (after the transition) + */ + setupComplete: boolean; + /** + * Current application status (after the transition) + */ + status: ApplicationStatus; + /** + * Current git remote URL, if one is set + */ + gitRemoteUrl?: string; + /** + * Selected cloud deployment provider, if one is set + */ + cloudDeploymentProvider?: CloudDeploymentProvider; +}; +export enum OperationLogKind { + CloudDeploy = 'CloudDeploy', + GitRemoteCreate = 'GitRemoteCreate', + RepoSync = 'RepoSync', + ApplicationSetup = 'ApplicationSetup', +} +export enum OperationLogLevel { + Debug = 'Debug', + Info = 'Info', + Warn = 'Warn', + Error = 'Error', +} + +/** + * A single timestamped line of progress for a long-running operation + */ +export type OperationLogEntry = BaseEntity & { + /** + * Kind of operation this entry belongs to + */ + operationKind: OperationLogKind; + /** + * Stable id that scopes the operation — typically the application id + */ + operationId: string; + /** + * Severity / level of this entry + */ + level: OperationLogLevel; + /** + * Human-readable single-line message + */ + message: string; + /** + * Optional structured detail (JSON-serialised) — multi-line stderr, error codes, etc. + */ + detail?: string; +}; + +/** + * Scoped payload for an OperationLogAppended notification — carries the newly-appended entry so clients can patch their log list in-place without a refetch. + */ +export type OperationLogAppendPayload = { + /** + * The newly-appended operation log entry + */ + entry: OperationLogEntry; +}; export enum NotificationEventType { AgentStarted = 'agent_started', PhaseCompleted = 'phase_completed', @@ -1684,6 +1778,8 @@ export enum NotificationEventType { PrBlocked = 'pr_blocked', MergeReviewReady = 'merge_review_ready', CloudDeploymentUpdated = 'cloud_deployment_updated', + ApplicationUpdated = 'application_updated', + OperationLogAppended = 'operation_log_appended', } export enum NotificationSeverity { Info = 'info', @@ -1728,45 +1824,15 @@ export type NotificationEvent = { * When the event occurred */ timestamp: any; -}; - -/** - * A code repository tracked by the Shep platform - */ -export type Repository = SoftDeletableEntity & { - /** - * Human-readable name for the repository (typically the directory name) - */ - name: string; /** - * Absolute file system path to the repository root (unique) - */ - path: string; - /** - * Remote GitHub URL this repository was cloned from (normalized: lowercase, no .git suffix) + * Scoped payload for ApplicationUpdated events — present iff eventType === ApplicationUpdated */ - remoteUrl?: string; + applicationUpdate?: ApplicationUpdatePayload; /** - * Whether this repository was auto-forked by shep because the user lacked push access - */ - isFork?: boolean; - /** - * Original upstream URL when isFork is true (normalized: lowercase, no .git suffix) + * Scoped payload for OperationLogAppended events — present iff eventType === OperationLogAppended */ - upstreamUrl?: string; + operationLogAppend?: OperationLogAppendPayload; }; -export enum ApplicationStatus { - Idle = 'Idle', - Active = 'Active', - Error = 'Error', -} -export enum CloudDeploymentProvider { - CloudflarePages = 'CloudflarePages', - Vercel = 'Vercel', - Netlify = 'Netlify', - AwsAmplify = 'AwsAmplify', - GcpCloudRun = 'GcpCloudRun', -} export enum CloudDeploymentStatus { NotDeployed = 'NotDeployed', Building = 'Building', @@ -1849,6 +1915,32 @@ export type Application = SoftDeletableEntity & { */ lastDeployedAt?: any; }; + +/** + * A code repository tracked by the Shep platform + */ +export type Repository = SoftDeletableEntity & { + /** + * Human-readable name for the repository (typically the directory name) + */ + name: string; + /** + * Absolute file system path to the repository root (unique) + */ + path: string; + /** + * Remote GitHub URL this repository was cloned from (normalized: lowercase, no .git suffix) + */ + remoteUrl?: string; + /** + * Whether this repository was auto-forked by shep because the user lacked push access + */ + isFork?: boolean; + /** + * Original upstream URL when isFork is true (normalized: lowercase, no .git suffix) + */ + upstreamUrl?: string; +}; export enum EstimateType { None = 'None', Category = 'Category', @@ -2531,43 +2623,6 @@ export type PmAuditLog = BaseEntity & { */ ipAddress?: string; }; -export enum OperationLogKind { - CloudDeploy = 'CloudDeploy', - GitRemoteCreate = 'GitRemoteCreate', - RepoSync = 'RepoSync', -} -export enum OperationLogLevel { - Debug = 'Debug', - Info = 'Info', - Warn = 'Warn', - Error = 'Error', -} - -/** - * A single timestamped line of progress for a long-running operation - */ -export type OperationLogEntry = BaseEntity & { - /** - * Kind of operation this entry belongs to - */ - operationKind: OperationLogKind; - /** - * Stable id that scopes the operation — typically the application id - */ - operationId: string; - /** - * Severity / level of this entry - */ - level: OperationLogLevel; - /** - * Human-readable single-line message - */ - message: string; - /** - * Optional structured detail (JSON-serialised) — multi-line stderr, error codes, etc. - */ - detail?: string; -}; /** * Single installation suggestion for a tool diff --git a/packages/core/src/infrastructure/di/modules/register-interactive.ts b/packages/core/src/infrastructure/di/modules/register-interactive.ts index 7d32f7fd3..bdaa92de1 100644 --- a/packages/core/src/infrastructure/di/modules/register-interactive.ts +++ b/packages/core/src/infrastructure/di/modules/register-interactive.ts @@ -4,6 +4,7 @@ import { StartInteractiveSessionUseCase } from '../../../application/use-cases/i import { SendInteractiveMessageUseCase } from '../../../application/use-cases/interactive/send-interactive-message.use-case.js'; import { StopInteractiveSessionUseCase } from '../../../application/use-cases/interactive/stop-interactive-session.use-case.js'; import { GetInteractiveChatStateUseCase } from '../../../application/use-cases/interactive/get-interactive-chat-state.use-case.js'; +import { GetChatTurnGroupsUseCase } from '../../../application/use-cases/interactive/get-chat-turn-groups.use-case.js'; import { RespondToInteractionUseCase } from '../../../application/use-cases/interactive/respond-to-interaction.use-case.js'; import { RunWorkflowUseCase } from '../../../application/use-cases/workflows/run-workflow.use-case.js'; import { ForceStopWorkflowStepUseCase } from '../../../application/use-cases/workflows/force-stop-workflow-step.use-case.js'; @@ -20,6 +21,7 @@ export function registerInteractive(container: DependencyContainer): void { container.registerSingleton(SendInteractiveMessageUseCase); container.registerSingleton(StopInteractiveSessionUseCase); container.registerSingleton(GetInteractiveChatStateUseCase); + container.registerSingleton(GetChatTurnGroupsUseCase); container.registerSingleton(RespondToInteractionUseCase); // String-token aliases for web routes (Turbopack can't resolve .js→.ts @@ -36,6 +38,9 @@ export function registerInteractive(container: DependencyContainer): void { container.register('GetInteractiveChatStateUseCase', { useFactory: (c) => c.resolve(GetInteractiveChatStateUseCase), }); + container.register('GetChatTurnGroupsUseCase', { + useFactory: (c) => c.resolve(GetChatTurnGroupsUseCase), + }); container.register('RespondToInteractionUseCase', { useFactory: (c) => c.resolve(RespondToInteractionUseCase), }); diff --git a/packages/core/src/infrastructure/di/modules/register-repositories.ts b/packages/core/src/infrastructure/di/modules/register-repositories.ts index 447392e44..546a4e9f4 100644 --- a/packages/core/src/infrastructure/di/modules/register-repositories.ts +++ b/packages/core/src/infrastructure/di/modules/register-repositories.ts @@ -21,6 +21,7 @@ import { SQLiteInteractiveMessageRepository } from '../../repositories/sqlite-in import { SQLiteWorkflowStepRepository } from '../../repositories/sqlite-workflow-step.repository.js'; import type { IOperationLogRepository } from '../../../application/ports/output/repositories/operation-log.repository.interface.js'; import { SQLiteOperationLogRepository } from '../../repositories/sqlite-operation-log.repository.js'; +import type { IOperationLogEventBus } from '../../../application/ports/output/services/operation-log-event-bus.interface.js'; // Project management (feature 087) repositories import type { IPmProjectRepository } from '../../../application/ports/output/repositories/pm-project-repository.interface.js'; @@ -140,7 +141,8 @@ export function registerRepositories(container: DependencyContainer): void { container.register('IOperationLogRepository', { useFactory: (c) => { const database = c.resolve('Database'); - return new SQLiteOperationLogRepository(database); + const bus = c.resolve('IOperationLogEventBus'); + return new SQLiteOperationLogRepository(database, bus); }, }); diff --git a/packages/core/src/infrastructure/di/modules/register-services.ts b/packages/core/src/infrastructure/di/modules/register-services.ts index 805bd68d3..4fc3775e4 100644 --- a/packages/core/src/infrastructure/di/modules/register-services.ts +++ b/packages/core/src/infrastructure/di/modules/register-services.ts @@ -64,6 +64,8 @@ import type { IProcessLivenessProbe } from '../../../application/ports/output/se import { ProcessLivenessAdapter } from '../../services/process/process-liveness.adapter.js'; import type { IProjectBuildService } from '../../../application/ports/output/services/project-build-service.interface.js'; import { NodeProjectBuildService } from '../../services/build/node-project-build.service.js'; +import type { IOperationLogEventBus } from '../../../application/ports/output/services/operation-log-event-bus.interface.js'; +import { InMemoryOperationLogEventBus } from '../../services/events/in-memory-operation-log-event-bus.js'; /** * Register core infrastructure services: validators, filesystem, git, notifications, @@ -214,6 +216,13 @@ export function registerServices(container: DependencyContainer): void { // place by the time the first use case asks for the service. container.registerSingleton('IOperationLogService', OperationLogService); + // Operation log event bus — SQLite repo publishes here after each + // successful append; SSE route subscribes to fan out as notifications. + container.registerSingleton( + 'IOperationLogEventBus', + InMemoryOperationLogEventBus + ); + // Process liveness probe — hides `process.kill(pid, 0)` behind a port so // application + presentation layers never import `infrastructure/services/ // process/is-process-alive` directly. diff --git a/packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts index 2cf1506d4..faa6ca586 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts @@ -13,6 +13,7 @@ import type { AppendOperationLogEntryInput, IOperationLogRepository, } from '../../application/ports/output/repositories/operation-log.repository.interface.js'; +import type { IOperationLogEventBus } from '../../application/ports/output/services/operation-log-event-bus.interface.js'; import type { OperationLogEntry, OperationLogKind, @@ -45,7 +46,10 @@ function rowToEntry(row: OperationLogRow): OperationLogEntry { @injectable() export class SQLiteOperationLogRepository implements IOperationLogRepository { - constructor(private readonly db: Database.Database) {} + constructor( + private readonly db: Database.Database, + private readonly bus: IOperationLogEventBus + ) {} async append(input: AppendOperationLogEntryInput): Promise { const id = randomUUID(); @@ -68,7 +72,7 @@ export class SQLiteOperationLogRepository implements IOperationLogRepository { created_at: now, updated_at: now, }); - return { + const entry: OperationLogEntry = { id, operationKind: input.operationKind, operationId: input.operationId, @@ -78,6 +82,8 @@ export class SQLiteOperationLogRepository implements IOperationLogRepository { createdAt: new Date(now), updatedAt: new Date(now), }; + this.bus.publish({ entry }); + return entry; } async listByScope( diff --git a/packages/core/src/infrastructure/services/events/in-memory-operation-log-event-bus.ts b/packages/core/src/infrastructure/services/events/in-memory-operation-log-event-bus.ts new file mode 100644 index 000000000..cd12e1559 --- /dev/null +++ b/packages/core/src/infrastructure/services/events/in-memory-operation-log-event-bus.ts @@ -0,0 +1,51 @@ +/** + * In-memory operation log event bus. + * + * Single-process pub/sub backed by node:events. The SQLite operation-log + * repository publishes here after a successful INSERT; the SSE route + * (StreamAgentEventsUseCase) subscribes and re-emits as notifications. + * + * A throwing subscriber is logged and swallowed so it never prevents + * delivery to the other subscribers — publish() is best-effort fanout. + */ + +import { EventEmitter } from 'node:events'; +import { injectable } from 'tsyringe'; + +import type { + IOperationLogEventBus, + OperationLogEvent, + OperationLogEventListener, +} from '../../../application/ports/output/services/operation-log-event-bus.interface.js'; + +const EVENT_NAME = 'operation-log-appended'; + +@injectable() +export class InMemoryOperationLogEventBus implements IOperationLogEventBus { + private readonly emitter = new EventEmitter(); + + constructor() { + // Prevent MaxListenersExceededWarning when many SSE clients subscribe. + this.emitter.setMaxListeners(0); + } + + publish(event: OperationLogEvent): void { + this.emitter.emit(EVENT_NAME, event); + } + + subscribe(listener: OperationLogEventListener): () => void { + const safeListener: OperationLogEventListener = (event) => { + try { + listener(event); + } catch (error) { + // Low-level bus — no logger injected here. A misbehaving + // subscriber must not poison the fanout; log to stderr and move + // on. The SSE stream and every other subscriber keep working. + // eslint-disable-next-line no-console + console.error('[OperationLogBus] subscriber threw — continuing fanout', error); + } + }; + this.emitter.on(EVENT_NAME, safeListener); + return () => this.emitter.off(EVENT_NAME, safeListener); + } +} diff --git a/packages/core/src/infrastructure/services/notifications/notification.service.ts b/packages/core/src/infrastructure/services/notifications/notification.service.ts index 42870b766..aa37186e2 100644 --- a/packages/core/src/infrastructure/services/notifications/notification.service.ts +++ b/packages/core/src/infrastructure/services/notifications/notification.service.ts @@ -32,6 +32,8 @@ const EVENT_TYPE_TO_CONFIG_KEY: Record void) {} + + write(chunk: string): void { + const combined = this.tail + chunk; + const parts = combined.split(/\r?\n/); + this.tail = parts.pop() ?? ''; + for (const raw of parts) { + this.emit(raw); + } + } + + flush(): void { + if (this.tail.length > 0) { + this.emit(this.tail); + this.tail = ''; + } + } + + /** + * Emit a single line, after stripping ANSI and skipping empties / + * duplicate spinner frames. Collapsing consecutive duplicates is + * what prevents `⠦ Creating a new Vite project…` from spamming the + * drawer with hundreds of rows per second. + */ + private emit(raw: string): void { + const cleaned = stripAnsi(raw).trimEnd(); + if (cleaned.length === 0) return; + if (cleaned === this.lastEmitted) return; + this.lastEmitted = cleaned; + this.onLine(cleaned); + } +} + /** Packages the "components" workflow step expects to import. */ const APP_EXTRA_DEPS = ['react-router-dom', 'react-hook-form', 'zod', 'lucide-react'] as const; @@ -111,10 +171,21 @@ const CACHE_SKIP = new Set(['.git', 'node_modules']); @injectable() export class BunShadcnScaffolder implements IApplicationScaffolder { async scaffold(options: ScaffoldOptions): Promise { - const { repositoryPath } = options; + const { repositoryPath, onLog } = options; + const emit = (level: 'Info' | 'Warn' | 'Error', message: string, detail?: string): void => { + if (!onLog) return; + try { + onLog({ level, message, detail }); + } catch { + // Sink failures must never abort a successful scaffold. + } + }; + + emit('Info', 'Starting application setup', `Project: ${options.projectName}`); // Phase 1 — bootstrap bun on first-ever run. - this.ensureBunOnPath(); + emit('Info', 'Checking bun is available'); + this.ensureBunOnPath(emit); // Phase 2 — base project files. // @@ -130,6 +201,7 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { this.emptyDirectory(repositoryPath); if (this.isCacheValid()) { + emit('Info', 'Using cached scaffold', 'Running `bun install --frozen-lockfile`'); this.copyDirectory(this.getCacheDir(), repositoryPath); await this.runSpawn({ command: 'bun', @@ -137,10 +209,15 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { cwd: repositoryPath, phase: 'bun install (from scaffold cache)', timeoutMs: PHASE_TIMEOUT_MS, + onLog: emit, }); } else { + emit( + 'Info', + 'Scaffolding from scratch', + 'First-run — running `bunx shadcn@latest init` (this can take a few minutes)' + ); // Run shadcn init in an OS-level scratch directory. - // See detailed background comment in git history / original code. const scratchDir = mkdtempSync(join(tmpdir(), 'shep-scaffold-')); try { await this.runSpawn({ @@ -163,6 +240,7 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { phase: 'shadcn init', stdinInput: '\n'.repeat(20), timeoutMs: PHASE_TIMEOUT_MS, + onLog: emit, }); const scaffoldRoot = this.findScaffoldRoot(scratchDir); @@ -185,17 +263,25 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { // Phase 4 — install the app-specific extras the "components" // step will import. Batched into one `bun add` call. + emit('Info', 'Installing app dependencies', APP_EXTRA_DEPS.join(', ')); await this.runSpawn({ command: 'bun', args: ['add', ...APP_EXTRA_DEPS], cwd: repositoryPath, phase: 'bun add extras', timeoutMs: PHASE_TIMEOUT_MS, + onLog: emit, }); // Phase 5 — overlay the fat template on top of the raw scaffold. + emit('Info', 'Applying Shep base template'); const overlay = applyTemplateOverlay(repositoryPath); + emit( + 'Info', + 'Application setup complete', + `${overlay.templateFiles.length} template file(s) applied` + ); return { repositoryPath, templateFiles: overlay.templateFiles, @@ -208,9 +294,12 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { * `npm install -g bun` and verify again. Runs synchronously because * the whole scaffold pipeline must block on a working bun. */ - private ensureBunOnPath(): void { + private ensureBunOnPath( + emit: (level: 'Info' | 'Warn' | 'Error', message: string, detail?: string) => void + ): void { if (this.hasBun()) return; + emit('Warn', 'bun not on PATH — installing via `npm install -g bun`'); // eslint-disable-next-line no-console console.log('[bun-shadcn-scaffolder] bun not on PATH — installing via `npm install -g bun`'); const install = spawnSync('npm', ['install', '-g', 'bun'], { @@ -231,6 +320,7 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { 'The bun binary may not be on PATH for this shell.' ); } + emit('Info', 'bun installed'); } /** @@ -383,12 +473,14 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { } /** - * Run a command to completion. Stdout and stderr inherit so the - * user sees progress in the terminal (for CLI invocations) and the - * Shep log (for web-app invocations). When `stdinInput` is set, a - * piped stdin is attached and the string is written to it up front, - * then closed — used as a safety net for interactive prompts that - * slip past `--yes`. + * Run a command to completion. Pipes stdout/stderr so progress lines + * can be tee'd to both the parent process's stdout/stderr (so the + * dev server log still shows live progress) AND to `onLog` (so the + * UI's operation-log drawer streams the same content). + * + * When `stdinInput` is set, a piped stdin is attached and the string + * is written to it up front, then closed — used as a safety net for + * interactive prompts that slip past `--yes`. * * When `timeoutMs` is set, the child is killed with SIGKILL (or * taskkill on Windows) if it hasn't exited before the deadline. @@ -405,16 +497,23 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { phase: string; stdinInput?: string; timeoutMs?: number; + onLog?: (level: 'Info' | 'Warn' | 'Error', message: string, detail?: string) => void; }): Promise { return new Promise((resolve, reject) => { const pipeStdin = args.stdinInput !== undefined; + const emit = + args.onLog ?? + ((): void => { + /* no-op default when caller does not subscribe to log events */ + }); + emit('Info', `▶ ${args.phase}`, `${args.command} ${args.args.join(' ')}`); + + // Always pipe stdout/stderr — we forward the bytes to the parent + // process (preserving the live terminal experience) AND also + // feed a line buffer that sends each emitted line to the UI log. const child = spawn(args.command, args.args, { cwd: args.cwd, - // When we need to feed stdin, we MUST pipe it — inheriting - // from the parent would tie the child to whatever stdin the - // Shep process has (usually the dev server's TTY or /dev/null - // in production) and the interactive prompt would still hang. - stdio: pipeStdin ? ['pipe', 'inherit', 'inherit'] : 'inherit', + stdio: [pipeStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], // Windows needs `shell: true` to resolve `.cmd` shims for // `bun`, `bunx`, and `npm`. POSIX does not and benefits from // direct exec (no argument escaping). @@ -422,6 +521,21 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { windowsHide: IS_WINDOWS, }); + const stdoutBuf = new LineBuffer((line) => emit('Info', line)); + const stderrBuf = new LineBuffer((line) => emit('Warn', line)); + if (child.stdout) { + child.stdout.on('data', (chunk: Buffer) => { + process.stdout.write(chunk); + stdoutBuf.write(chunk.toString('utf8')); + }); + } + if (child.stderr) { + child.stderr.on('data', (chunk: Buffer) => { + process.stderr.write(chunk); + stderrBuf.write(chunk.toString('utf8')); + }); + } + // Hard timeout — SIGKILL after `timeoutMs` with no exit event. // On Windows `child.kill('SIGKILL')` translates to TerminateProcess // via libuv, which is the equivalent of `taskkill /F`. @@ -455,6 +569,9 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { } child.on('error', (err) => { clearTimer(); + stdoutBuf.flush(); + stderrBuf.flush(); + emit('Error', `${args.phase} failed to start`, err.message); reject( new Error( `${args.phase} failed to start: ${err.message}. ` + @@ -464,7 +581,14 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { }); child.on('exit', (code, signal) => { clearTimer(); + stdoutBuf.flush(); + stderrBuf.flush(); if (timedOut) { + emit( + 'Error', + `${args.phase} timed out`, + `Killed after ${args.timeoutMs}ms. Command: ${args.command} ${args.args.join(' ')}` + ); reject( new Error( `${args.phase} timed out after ${args.timeoutMs}ms and was killed. ` + @@ -474,8 +598,14 @@ export class BunShadcnScaffolder implements IApplicationScaffolder { return; } if (code === 0) { + emit('Info', `✓ ${args.phase} done`); resolve(); } else { + emit( + 'Error', + `${args.phase} exited with ${code ?? `signal ${signal}`}`, + `Command: ${args.command} ${args.args.join(' ')}` + ); reject( new Error( `${args.phase} exited with ${code ?? `signal ${signal}`}. ` + diff --git a/specs/090-application-sse-deltas/contracts/.gitkeep b/specs/090-application-sse-deltas/contracts/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/specs/090-application-sse-deltas/data-model.md b/specs/090-application-sse-deltas/data-model.md new file mode 100644 index 000000000..13a23b939 --- /dev/null +++ b/specs/090-application-sse-deltas/data-model.md @@ -0,0 +1,64 @@ +# Data Model: application-sse-deltas + +> Entity definitions for 090-application-sse-deltas + +## Status + +- **Phase:** Planning +- **Updated:** 2026-04-20 + +## Overview + +{{DATA_MODEL_OVERVIEW}} + +## New Entities + +### {{ENTITY_NAME}} + +**Location:** `tsp/domain/entities/{{entity-name}}.tsp` + +| Property | Type | Required | Description | +| ------------- | ------------- | ------------ | ------------- | +| {{PROP_NAME}} | {{PROP_TYPE}} | {{REQUIRED}} | {{PROP_DESC}} | + +**Relationships:** + +- {{RELATIONSHIP_1}} + +## Modified Entities + +### {{EXISTING_ENTITY}} + +**Changes:** + +- Add: {{NEW_PROPERTY}} +- Modify: {{MODIFIED_PROPERTY}} + +## Value Objects + +### {{VALUE_OBJECT_NAME}} + +**Location:** `tsp/domain/value-objects/{{value-object}}.tsp` + +| Property | Type | Description | +| ----------- | ----------- | ----------- | +| {{VO_PROP}} | {{VO_TYPE}} | {{VO_DESC}} | + +## Enums + +### {{ENUM_NAME}} + +**Location:** `tsp/common/enums/{{enum-name}}.tsp` + +| Value | Description | +| -------------- | ------------- | +| {{ENUM_VALUE}} | {{ENUM_DESC}} | + + + +--- + +_Data model changes for TypeSpec compilation_ diff --git a/specs/090-application-sse-deltas/feature.yaml b/specs/090-application-sse-deltas/feature.yaml new file mode 100644 index 000000000..e0fe74327 --- /dev/null +++ b/specs/090-application-sse-deltas/feature.yaml @@ -0,0 +1,48 @@ +feature: + id: '090-application-sse-deltas' + name: 'application-sse-deltas' + number: 90 + branch: 'feat/chat-timeline-smart-deploy-ux' + lifecycle: 'planning' + createdAt: '2026-04-20T11:22:59Z' + +status: + phase: 'planning' + progress: + completed: 0 + total: 12 + percentage: 0 + currentTask: null + lastUpdated: '2026-04-20T11:22:59Z' + lastUpdatedBy: 'shep-kit:new-feature' + +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] + +tasks: + current: null + blocked: [] + failed: [] + +checkpoints: + - phase: 'feature-created' + completedAt: '2026-04-20T11:22:59Z' + completedBy: 'shep-kit:new-feature' + - phase: 'spec-written' + completedAt: '2026-04-20T11:22:59Z' + completedBy: 'shep-kit:new-feature' + - phase: 'research-written' + completedAt: '2026-04-20T11:22:59Z' + completedBy: 'shep-kit:new-feature' + - phase: 'plan-written' + completedAt: '2026-04-20T11:22:59Z' + completedBy: 'shep-kit:new-feature' + - phase: 'tasks-broken-down' + completedAt: '2026-04-20T11:22:59Z' + completedBy: 'shep-kit:new-feature' + +errors: + current: null + history: [] diff --git a/specs/090-application-sse-deltas/plan.yaml b/specs/090-application-sse-deltas/plan.yaml new file mode 100644 index 000000000..b1d67d8f8 --- /dev/null +++ b/specs/090-application-sse-deltas/plan.yaml @@ -0,0 +1,269 @@ +# Implementation Plan (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: application-sse-deltas +summary: Implementation plan for 090-application-sse-deltas + +# Relationships +relatedFeatures: + - "089-one-click-cloud-deploy" +technologies: + - TypeScript + - TypeSpec + - SSE + - React Query + - tsyringe +relatedLinks: + - title: Cloud deploy event bus (precedent) + url: packages/core/src/application/ports/output/services/cloud-deployment-event-bus.interface.ts + - title: Stream agent events use-case + url: packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts + +# Structured implementation phases +phases: + - id: phase-1 + name: "TypeSpec + Codegen (foundation)" + parallel: false + taskIds: + - task-1 + - task-2 + + - id: phase-2 + name: "OperationLog event bus (port + adapter + repo wiring)" + parallel: false + taskIds: + - task-3 + - task-4 + - task-5 + + - id: phase-3 + name: "Application delta helper + cache" + parallel: false + taskIds: + - task-6 + - task-7 + + - id: phase-4 + name: "StreamAgentEventsUseCase integration" + parallel: false + taskIds: + - task-8 + + - id: phase-5 + name: "Client surgical handlers" + parallel: true + taskIds: + - task-9 + - task-10 + + - id: phase-6 + name: "Verification" + parallel: false + taskIds: + - task-11 + - task-12 + +# File change tracking +filesToCreate: + - packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts + - packages/core/src/infrastructure/services/in-memory-operation-log-event-bus.ts + - packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts + - tests/unit/infrastructure/services/in-memory-operation-log-event-bus.test.ts + - tests/unit/infrastructure/repositories/sqlite-operation-log-repository-publish.test.ts + - tests/unit/application/use-cases/agents/compute-application-deltas.test.ts + - tests/unit/application/use-cases/agents/stream-agent-events-application.test.ts + - tests/unit/application/use-cases/agents/stream-agent-events-operation-log-bus.test.ts + - tests/unit/presentation/web/features/application-page/application-page-loader-patch.test.ts + - tests/unit/presentation/web/features/application-page/smart-deploy-logs-drawer-append.test.ts + +filesToModify: + - tsp/common/enums/notification-event.tsp + - tsp/common/types/notification-event.tsp + - packages/core/src/domain/generated/output.ts + - packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts + - packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts + - packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts + - packages/core/src/infrastructure/di/modules/register-services.ts + - packages/core/src/infrastructure/di/modules/register-repositories.ts + - src/presentation/web/components/features/application-page/application-page-loader.tsx + - src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx + - src/presentation/web/hooks/agent-events-provider.tsx + +# Open questions (all resolved before implementation) +openQuestions: [] + +# Markdown content — architecture, strategy, and risks only. +# Task-level detail lives in tasks.yaml (no duplication). +content: | + ## Status + + - **Phase:** Planning + - **Updated:** 2026-04-20 + + ## Architecture Overview + + ``` + ┌──────────────────────────────────────────────────────────────────┐ + │ WRITE PATH — DB is source of truth │ + │ │ + │ UseCase ── append ──► SqliteOperationLogRepository │ + │ │ │ + │ ▼ (1) INSERT row │ + │ SQLite │ + │ │ │ + │ ▼ (2) publish AFTER insert │ + │ IOperationLogEventBus │ + │ │ + │ CreateApplicationUseCase │ + │ (setup_complete flip) ──► IApplicationRepository.update │ + │ │ │ + │ ▼ INSERT/UPDATE row │ + │ SQLite ◄─ polled below │ + └──────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────────┐ + │ READ PATH — SSE stream (single backend poller) │ + │ │ + │ StreamAgentEventsUseCase.execute() │ + │ ├─ subscribes to ICloudDeploymentEventBus (existing) │ + │ ├─ subscribes to IOperationLogEventBus (NEW) │ + │ └─ pollOnce() every 2s: │ + │ ├─ diff Features / Runs / Sessions / PRs (existing) │ + │ └─ diff Applications vs per-connection cache (NEW) │ + │ │ │ + │ ▼ yields StreamedAgentEvent │ + │ /api/agent-events SSE route │ + │ │ │ + │ ▼ data: │ + │ Service Worker (broadcasts) │ + │ │ │ + │ ▼ │ + │ AgentEventsProvider (context) │ + │ │ │ │ + │ ┌─────────────┘ └─────────────┐ │ + │ ▼ ▼ │ + │ useApplicationUpdates(id) useOperationLogAppends(id) + │ └─ patches React Query cache └─ appends to drawer │ + │ on ApplicationUpdated on OperationLogAppended + └──────────────────────────────────────────────────────────────────┘ + ``` + + ## Implementation Strategy + + **MANDATORY TDD**: Every task that ships executable code follows + RED → GREEN → REFACTOR. Doc / wiring tasks (TypeSpec, DI binding) + have `tdd: null` and are verified by downstream feature tests. + + **Phase ordering** is driven by the dependency graph: + + 1. **TypeSpec + codegen first** — the enum values and payload + shapes anchor everything downstream. Codegen output must be + committed so the rest of the feature compiles. + 2. **OperationLog bus before Application deltas** — the bus is + reusable, self-contained, and easy to test in isolation; landing + it first gives the Application-delta work a known-good reference + pattern (it mirrors the cloud-deploy bus). + 3. **Delta helper before use-case wiring** — the pure helper can + be fully unit-tested without pulling in the StreamAgentEvents + orchestrator; wiring comes once both inputs (bus + helper) are + green. + 4. **Client handlers last** — presentation can only land safely + once the typed events are flowing through SSE with real data. + 5. **Verification** — run the full suite, storybook, and a manual + end-to-end smoke through the fresh-app creation flow. + + **DB-first invariant**: the OperationLogEventBus is published to + ONLY after the repository's INSERT returns success. We never emit + an event for a row that wasn't committed — if the DB write throws, + no event fires. This preserves the "DB is source of truth, SSE is + a hint" guarantee. + + **Connection-scoped cache**: the Application cache lives in + `StreamAgentEventsUseCase.execute()` just like the feature / + session caches. Each SSE connection seeds its own cache on first + poll and emits no seed events. This matches the existing behavior + and is critical for reconnect / Service Worker restart scenarios. + + ## Files to Create/Modify + + ### New Files + + | File | Purpose | + | ---- | ------- | + | `packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts` | Port interface (mirrors `ICloudDeploymentEventBus`) | + | `packages/core/src/infrastructure/services/in-memory-operation-log-event-bus.ts` | In-process pub/sub adapter | + | `packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts` | Pure helper diffing app rows → events | + | `tests/unit/infrastructure/services/in-memory-operation-log-event-bus.test.ts` | Unit tests for the bus | + | `tests/unit/infrastructure/repositories/sqlite-operation-log-repository-publish.test.ts` | Verify publish-after-insert | + | `tests/unit/application/use-cases/agents/compute-application-deltas.test.ts` | Unit tests for the delta helper | + | `tests/unit/application/use-cases/agents/stream-agent-events-application.test.ts` | Use-case integration: app deltas flow through | + | `tests/unit/application/use-cases/agents/stream-agent-events-operation-log-bus.test.ts` | Use-case integration: bus events flow through | + | `tests/unit/presentation/web/features/application-page/application-page-loader-patch.test.ts` | Loader patches query data on event | + | `tests/unit/presentation/web/features/application-page/smart-deploy-logs-drawer-append.test.ts` | Drawer appends on event | + + ### Modified Files + + | File | Changes | + | ---- | ------- | + | `tsp/common/enums/notification-event.tsp` | Add `ApplicationUpdated` + `OperationLogAppended` enum values | + | `tsp/common/types/notification-event.tsp` | Add optional `applicationUpdate` + `operationLogAppend` payload models | + | `packages/core/src/domain/generated/output.ts` | Regenerated via `pnpm tsp:compile` | + | `packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts` | Inject `IApplicationRepository` + `IOperationLogEventBus`; add application cache / subscribe | + | `packages/core/src/application/use-cases/agents/stream-agent-events/stream-agent-events.types.ts` | Add `CachedApplicationState` type | + | `packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts` | Inject bus; publish after insert | + | `packages/core/src/infrastructure/di/modules/register-services.ts` | Register bus singleton | + | `packages/core/src/infrastructure/di/modules/register-repositories.ts` | Inject bus into repo | + | `src/presentation/web/components/features/application-page/application-page-loader.tsx` | Replace coarse invalidate with typed patch handler | + | `src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx` | Replace coarse refresh with append-on-event handler | + | `src/presentation/web/hooks/agent-events-provider.tsx` | Expose typed selectors: `useApplicationUpdates(id)`, `useOperationLogAppends(id)` | + + ## Testing Strategy (TDD: Tests FIRST) + + **CRITICAL:** Every RED phase lands a failing test before the GREEN + implementation. Each task lists explicit RED → GREEN → REFACTOR steps. + + ### Unit Tests (RED → GREEN → REFACTOR) + + - **`InMemoryOperationLogEventBus`** — publish-subscribe; multiple + subscribers; unsubscribe idempotency; publish never throws when + a subscriber throws (we catch + log). + - **`SqliteOperationLogRepository.append`** — with a spy bus, + assert `publish` is called EXACTLY once, AFTER the row is visible + via `listByScope`. Throwing bus must NOT roll back the insert. + - **`computeApplicationDeltas`** — no prev → no event (seed-only); + setup_complete false → true emits `ApplicationUpdated` with correct + payload; status Idle → Error emits; unchanged row emits nothing. + - **`StreamAgentEventsUseCase`** — with a fake bus, publishing an + OperationLog entry mid-stream enqueues a notification event that + the `for await` consumer observes. Application row change between + polls emits `ApplicationUpdated` on the next yield. + - **`ApplicationPageLoader` event handler** — given a queryClient + holding `{application: {setupComplete: false, ...}}` and an + `ApplicationUpdated` event arrives, the cache transitions to + `setupComplete: true` WITHOUT an HTTP request. + - **`SmartDeployLogsDrawer` event handler** — given empty entries + and an `OperationLogAppended` for the drawer's applicationId, + the entry is appended in place at the correct chronological + position; duplicate events (same entryId) are deduped. + + ### Integration Tests + + - Covered by existing integration suites after wiring — no new + bespoke integration files are required. The SQLite repo test + above doubles as an integration test using `:memory:` DB. + + ## Risk Mitigation + + | Risk | Mitigation | + | ---- | ---------- | + | Event bus throws inside SSE generator (e.g., subscriber code bug) poisons the stream | `InMemoryOperationLogEventBus.publish` wraps each subscriber in try/catch; logger.error on throw; main stream never sees the exception | + | Client missed events on SW restart / reconnect leave stale cache | Seed-on-reconnect in the use-case emits no events; client's next read (staleTime expiry or manual open) refetches the full row | + | TypeSpec codegen change breaks unrelated generated types | Commit generated output file alongside tsp change; typecheck gate catches any downstream break before merge | + | Repo's publish-after-insert is skipped if insert throws | Intentional — publish sits AFTER the INSERT return; on throw we never reach it. DB is source of truth. | + | New Application poll adds DB load | `findAll` on `applications` is cheap (tens of rows max in practice), same cadence as feature poll (2s) | + | Two feature branches (this + other pending work) modify TypeSpec concurrently | Regenerate after rebase; `pnpm tsp:compile` is deterministic | + + --- + + _Updated by `/shep-kit:plan` — see tasks.yaml for detailed task breakdown_ diff --git a/specs/090-application-sse-deltas/research.yaml b/specs/090-application-sse-deltas/research.yaml new file mode 100644 index 000000000..f3124f11a --- /dev/null +++ b/specs/090-application-sse-deltas/research.yaml @@ -0,0 +1,200 @@ +# Research Artifact (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: application-sse-deltas +summary: Technical analysis for 090-application-sse-deltas + +# Relationships +relatedFeatures: + - "089-one-click-cloud-deploy" +technologies: + - TypeScript + - TypeSpec + - SSE + - tsyringe + - Better-sqlite3 +relatedLinks: + - title: Cloud deploy event bus precedent + url: packages/core/src/application/ports/output/services/cloud-deployment-event-bus.interface.ts + - title: StreamAgentEventsUseCase + url: packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts + +# Structured technology decisions +decisions: + - title: "Event transport: existing shared SSE vs dedicated per-application channel" + chosen: "Existing shared /api/agent-events SSE" + rejected: + - "New /api/applications/:id/updates/stream route" + - "WebSocket" + - "Long-polling" + rationale: > + The existing channel already runs through the Service Worker + multiplexer (one connection, all tabs), backs off exponentially + on reconnect, and is served by an orchestrator (StreamAgentEventsUseCase) + that already polls the DB. Adding two new NotificationEventType + values costs nothing incremental on the transport layer. A + per-application stream would double connection count and duplicate + the poll / subscribe lifecycle without a concrete benefit. + WebSocket is overkill for one-way server → client signals. Long + polling is strictly worse than SSE here. + + - title: "Operation log delivery: poll-based vs in-process event bus" + chosen: "In-process event bus (publish on INSERT)" + rejected: + - "Add operation_log_entries to the 2s poll loop" + - "Database NOTIFY / LISTEN (not available in SQLite)" + rationale: > + Operation log writes happen synchronously in-process (scaffolder + stdout, cloud deploy lifecycle, git publish). Publishing after a + successful INSERT delivers events immediately — no 2s lag, no + "since-cursor" bookkeeping per connection, no N×scopes DB query + per tick. Matches the cloud-deploy event bus exactly, which is + already working in production. SQLite has no NOTIFY, so the + event bus stands in for it. If we ever fan out to multiple + processes, we swap the in-memory adapter for a Redis / BroadcastChannel + adapter without touching the port. + + - title: "Application row delivery: poll-based vs event bus" + chosen: "Poll-based, integrated into StreamAgentEventsUseCase" + rejected: + - "New IApplicationEventBus" + - "Dual: poll + bus" + rationale: > + Application mutations happen from multiple call sites + (CreateApplicationUseCase, scaffolder completion, status + updaters) and a bus would require plumbing every writer to the + bus. The existing use-case already polls every 2s for features + and sessions; adding applications to the same tick is near-zero + cost (findAll on applications is small and cached by SQLite). + This keeps the write path ergonomics unchanged — any existing + repo caller continues to "just update the row" without needing + to know about events. + + - title: "Client state management: invalidate-and-refetch vs patch-in-place" + chosen: "Patch-in-place (setQueryData / local state merge)" + rejected: + - "queryClient.invalidateQueries" + - "router.refresh()" + rationale: > + The SSE event payload already carries the new field values, so + a refetch is strictly redundant work. Patching React Query + directly with setQueryData flips the observable state in the + same tick the event arrives, and it costs zero network. If the + client ever wants a safety-net refetch (suspicious staleness), + it can still call invalidateQueries on demand — but as a hand + rail, not the happy path. + + - title: "Seed behavior on SSE (re)connect" + chosen: "Connection-scoped cache, zero events on seed" + rejected: + - "Emit a full snapshot on connect" + rationale: > + Matches the existing feature / session seed behavior. Prevents a + burst of 'ApplicationUpdated' events every time a tab opens + that would force the client to re-render every application card. + The client already knows the current state because the route + hydrated it via /api/applications/:id; SSE only needs to + communicate DELTAS. + +# Open questions (all resolved) +openQuestions: [] + +# Markdown content (the full research document) +content: | + ## Status + + - **Phase:** Research + - **Updated:** 2026-04-20 + + ## Technology Decisions + + ### Event transport + + **Decision:** piggyback on the existing `/api/agent-events` SSE. + + **Rationale:** one connection through the Service Worker already + multiplexes to every tab with exponential backoff; the orchestrator + that serves the route (`StreamAgentEventsUseCase`) already polls the + DB on a 2s cadence. Adding two new `NotificationEventType` values + costs nothing on the wire — we'd be dropping a typed message into an + already-flowing stream. A per-application route would double the + connection count and duplicate the whole subscribe / reconnect + lifecycle for no concrete benefit. + + ### Operation log delivery — bus vs poll + + **Decision:** in-process `IOperationLogEventBus`, publishing after a + successful INSERT in `SqliteOperationLogRepository.append`. + + **Rationale:** writes are synchronous in-process (scaffolder stdout, + deploy lifecycle, git publish). Publishing after INSERT delivers + events in the same ms, not on the next 2s tick. No per-connection + "since cursor" bookkeeping, no N × scope SQL per tick. Mirrors the + existing `ICloudDeploymentEventBus` exactly, so the wiring is a + copy-paste shape. If we go multi-process later, the port lets us + swap the adapter (Redis, BroadcastChannel) without touching callers. + + ### Application row delivery — bus vs poll + + **Decision:** add application row diffing to the existing + `StreamAgentEventsUseCase.pollOnce()` loop. + + **Rationale:** application mutations come from several call sites + (`CreateApplicationUseCase`, scaffolder completion, status + updaters). A bus would require plumbing every writer. Piggybacking + on the existing 2s poll costs a single `findAll` on an already-small + table and leaves the write ergonomics untouched. + + ### Client state management + + **Decision:** patch-in-place via `setQueryData` / local state + merge. Refetching on SSE receipt is rejected. + + **Rationale:** the payload already carries the new values. A + refetch is redundant network work and adds a flash of "stale, then + fresh" on slow connections. Invalidate stays available as a + hand-rail for edge cases (suspicious staleness, e2e tests) but is + not the happy path. + + ### Seed on (re)connect + + **Decision:** per-connection cache seeds silently; emits no events + on first poll. + + **Rationale:** matches features / sessions behavior. Prevents an + event storm on every tab open / Service Worker restart. The client + already has authoritative initial state from the `/api/applications/:id` + hydration route; SSE communicates DELTAS only. + + ## Security Considerations + + - Same surface as the existing agent-events stream — no new auth / + origin exposure. + - `OperationLogEntry.detail` may contain user-provided or tool-output + text; the drawer already renders it safely via React text children + inside a whitespace-preserving `
` (no raw-HTML injection
+    paths).
+  - Event bus publishes happen only after authenticated writes; no new
+    trust boundary.
+
+  ## Performance Implications
+
+  - One extra `findAll` on `applications` per 2s poll tick. Applications
+    table is small in practice (O(10s) of rows); the diff is O(n)
+    field compares. Negligible.
+  - Event bus publishes are O(subscribers); web has one subscriber
+    per connected client, CLI / TUI optionally subscribe. No change
+    in write throughput.
+  - Client handlers are O(1) per incoming event — a single
+    `setQueryData` or `setState` call.
+  - Net effect: eliminates one client `setInterval(1500ms)` that was
+    firing three HTTP requests per tick, and one per-app React Query
+    `refetchInterval(2000ms)`. Net request count drops.
+
+  ## Open Questions
+
+  All questions resolved.
+
+  ---
+
+  _Updated by `/shep-kit:research` — proceed with `/shep-kit:plan`_
diff --git a/specs/090-application-sse-deltas/spec.yaml b/specs/090-application-sse-deltas/spec.yaml
new file mode 100644
index 000000000..cb017196c
--- /dev/null
+++ b/specs/090-application-sse-deltas/spec.yaml
@@ -0,0 +1,142 @@
+# Feature Specification (YAML)
+# This is the source of truth. Markdown is auto-generated from this file.
+
+name: application-sse-deltas
+number: 090
+branch: feat/chat-timeline-smart-deploy-ux
+oneLiner: Backend-owned SSE for Application + OperationLog deltas — zero client polling
+summary: >
+  Eliminate the last client-side polling paths in the web UI by emitting
+  dedicated SSE events for Application row changes and OperationLog
+  appends. The backend remains the sole poller (StreamAgentEventsUseCase
+  already runs a 2s poll) and a new in-process event bus publishes
+  OperationLog append events synchronously as rows are inserted. Clients
+  (ApplicationPageLoader, SmartDeployLogsDrawer) stop refetching on
+  coarse "any SSE event" signals and instead patch their local state
+  directly from typed, applicationId-scoped deltas. DB remains the
+  source of truth; SSE is a hint — if the client misses an event it
+  still reads the correct state on next load.
+phase: Requirements
+sizeEstimate: L
+
+# Relationships
+relatedFeatures:
+  - "089-one-click-cloud-deploy"
+
+technologies:
+  - TypeScript
+  - TypeSpec
+  - Server-Sent Events (SSE)
+  - React Query (TanStack Query)
+  - tsyringe DI
+  - Better-sqlite3
+
+relatedLinks:
+  - title: Existing SSE route
+    url: src/presentation/web/app/api/agent-events/route.ts
+  - title: StreamAgentEventsUseCase
+    url: packages/core/src/application/use-cases/agents/stream-agent-events.use-case.ts
+  - title: Cloud deploy event bus pattern (precedent)
+    url: packages/core/src/application/ports/output/services/cloud-deployment-event-bus.interface.ts
+
+# Open questions (must be resolved before implementation)
+openQuestions: []
+
+# Markdown content (the actual spec)
+content: |
+  ## Problem Statement
+
+  The web UI has two remaining client-side polling paths that violate
+  the project's "backend polls, frontend subscribes to SSE" rule:
+
+  1. `ApplicationPageLoader` used a React Query `refetchInterval: 2_000`
+     to poll `/api/applications/:id` until `setup_complete` flipped. This
+     was hastily replaced with a coarse "invalidate on any SSE event"
+     fallback that works today only because unrelated feature / session
+     transitions happen to fire during the scaffold window.
+  2. `SmartDeployLogsDrawer` ran a `setInterval(1500ms)` refetch of
+     `/api/operations/:kind/:id/logs` while any Smart Deploy op was
+     live. It now also piggybacks on the "invalidate on any SSE event"
+     hack, which still costs N HTTP round-trips per backend poll tick.
+
+  Both are brittle: if no ambient SSE event fires during the window of
+  interest, the UI sits stale. Neither has a dedicated, typed backend
+  signal for the state change it cares about. The backend also has no
+  visibility into append deltas for `operation_log_entries` — it only
+  polls features / runs / sessions / PRs.
+
+  This feature closes the gap: the backend becomes the sole poller AND
+  the authoritative event source for every user-visible state change in
+  the Application surface, and clients consume typed deltas with
+  surgical handlers (no invalidate-and-refetch).
+
+  ## Success Criteria
+
+  - [ ] Zero `setInterval` / React Query `refetchInterval` calls in
+        `src/presentation/web/components/features/application-page/`
+        or anywhere they could fire an `/api/…` request on a client
+        timer.
+  - [ ] `NotificationEventType` gains two values: `ApplicationUpdated`
+        and `OperationLogAppended`, defined in TypeSpec and surfaced
+        through `pnpm tsp:compile`.
+  - [ ] `NotificationEvent` payload carries optional `applicationUpdate`
+        and `operationLogAppend` fields with typed, minimal shapes.
+  - [ ] `IOperationLogEventBus` port + in-memory adapter exist and are
+        wired through the DI container, mirroring
+        `ICloudDeploymentEventBus` exactly.
+  - [ ] `SqliteOperationLogRepository.append()` publishes to the bus
+        AFTER a successful DB insert (DB is source of truth — never
+        emit an event for a row that wasn't persisted).
+  - [ ] `StreamAgentEventsUseCase` subscribes to the OperationLog bus
+        (synchronous, same queue as cloud-deploy bus) and its poll loop
+        diffs `applications` rows against a per-connection cache,
+        emitting `ApplicationUpdated` on `setupComplete`, `status`,
+        `gitRemoteUrl`, or `cloudDeploymentProvider` change.
+  - [ ] Per-connection cache seeds on first sight → no burst of
+        "current state" events on reconnect (matches feature / session
+        seed behavior).
+  - [ ] `ApplicationPageLoader` uses a dedicated handler that selects
+        `ApplicationUpdated` events for its applicationId and PATCHES
+        React Query data directly — no HTTP refetch on SSE receipt.
+  - [ ] `SmartDeployLogsDrawer` uses a dedicated handler that appends
+        `OperationLogAppended` entries for its applicationId directly
+        to local state. Initial drawer open still does ONE hydration
+        fetch; after that, SSE is the only driver.
+  - [ ] Robustness: missing an event is never fatal. On reconnect the
+        seed step re-reads authoritative state; on tab return the
+        already-correct state stays correct; stale caches are bounded
+        by `staleTime`.
+  - [ ] Test coverage: new unit tests for the delta helper, the bus
+        publish path, the repo's publish-on-append, and the client
+        handlers. All existing tests continue to pass.
+
+  ## Affected Areas
+
+  | Area | Impact | Reasoning |
+  | ---- | ------ | --------- |
+  | `tsp/` (TypeSpec) | Medium | New `NotificationEventType` values + `NotificationEvent` optional payloads |
+  | `packages/core/src/application/ports/output/services/` | Medium | New `IOperationLogEventBus` port |
+  | `packages/core/src/application/use-cases/agents/stream-agent-events/` | High | New `computeApplicationDeltas` helper + cache; use-case subscribes to new bus |
+  | `packages/core/src/infrastructure/services/` | Medium | New in-memory `OperationLogEventBus` adapter |
+  | `packages/core/src/infrastructure/repositories/sqlite-operation-log.repository.ts` | Medium | Publishes on append |
+  | `packages/core/src/infrastructure/di/` | Low | Bind new port / adapter |
+  | `src/presentation/web/components/features/application-page/application-page-loader.tsx` | Medium | Replace coarse invalidate with typed handler |
+  | `src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx` | Medium | Replace coarse refresh with append-on-event |
+  | `tests/unit/**` | Medium | New test coverage per port / use-case / handler |
+
+  ## Dependencies
+
+  - 089-one-click-cloud-deploy (established the cloud-deploy event bus
+    pattern this feature mirrors; same DI wiring model).
+
+  ## Size Estimate
+
+  **L** — Touches TypeSpec + codegen, adds a new port + adapter,
+  extends a hot-path use case with new cache + delta logic, rewires
+  two client components, and adds ~6 new test files. Bounded by the
+  precedent set by the cloud-deploy event bus, which makes the shape
+  of the bus + DI + SSE wiring very predictable.
+
+  ---
+
+  _Generated by `/shep-kit:new-feature` — proceed with `/shep-kit:research`_
diff --git a/specs/090-application-sse-deltas/tasks.yaml b/specs/090-application-sse-deltas/tasks.yaml
new file mode 100644
index 000000000..e9c29ffa7
--- /dev/null
+++ b/specs/090-application-sse-deltas/tasks.yaml
@@ -0,0 +1,308 @@
+# Task Breakdown (YAML)
+# This is the source of truth. Markdown is auto-generated from this file.
+
+name: application-sse-deltas
+summary: Task breakdown for 090-application-sse-deltas
+
+# Relationships
+relatedFeatures:
+  - "089-one-click-cloud-deploy"
+technologies:
+  - TypeScript
+  - TypeSpec
+  - SSE
+  - React Query
+relatedLinks: []
+
+# Structured task list — this is the single source of truth for tasks.
+# The content field below is a summary only; it does NOT duplicate task details.
+tasks:
+  - id: task-1
+    title: "Extend TypeSpec: NotificationEventType + NotificationEvent payloads"
+    description: >
+      Add two enum values (`ApplicationUpdated`, `OperationLogAppended`)
+      to `NotificationEventType` and add two optional payload models
+      (`applicationUpdate`, `operationLogAppend`) to `NotificationEvent`
+      in `tsp/common/enums/notification-event.tsp` and
+      `tsp/common/types/notification-event.tsp`. Keep fields minimal —
+      only what the client actually renders / patches.
+    state: Todo
+    dependencies: []
+    acceptanceCriteria:
+      - "Enum values land in `packages/core/src/domain/generated/output.ts`"
+      - "`NotificationEvent` type exposes optional `applicationUpdate` and `operationLogAppend` fields with the right shapes"
+      - "`pnpm tsp:compile` succeeds"
+    tdd: null
+    estimatedEffort: S
+
+  - id: task-2
+    title: "Run `pnpm tsp:compile` and commit generated output"
+    description: >
+      Run the codegen, verify the diff only touches generated files,
+      and confirm typecheck still passes across the monorepo.
+    state: Todo
+    dependencies:
+      - task-1
+    acceptanceCriteria:
+      - "`packages/core/src/domain/generated/output.ts` contains the new enum + fields"
+      - "`pnpm typecheck` passes clean"
+    tdd: null
+    estimatedEffort: XS
+
+  - id: task-3
+    title: "Add `IOperationLogEventBus` port"
+    description: >
+      Create `packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts`
+      mirroring `ICloudDeploymentEventBus`: a `subscribe(cb) => unsubscribe`
+      method and a `publish(event)` method. The event shape references
+      the generated `OperationLogEntry` so no duplication.
+    state: Todo
+    dependencies:
+      - task-2
+    acceptanceCriteria:
+      - "Port file exists with JSDoc"
+      - "Event shape uses `OperationLogEntry` from generated output"
+      - "No implementation leaks (interface-only in the application layer)"
+    tdd: null
+    estimatedEffort: XS
+
+  - id: task-4
+    title: "Implement `InMemoryOperationLogEventBus` adapter (TDD)"
+    description: >
+      In-process pub/sub adapter under `packages/core/src/infrastructure/services/`.
+      `publish` must isolate subscriber failures (try/catch + logger.error)
+      so one broken subscriber cannot poison the SSE stream. `subscribe`
+      returns an idempotent unsubscribe function.
+    state: Todo
+    dependencies:
+      - task-3
+    acceptanceCriteria:
+      - "Multiple subscribers all receive each published event"
+      - "Unsubscribe stops delivery; calling unsubscribe twice is a no-op"
+      - "A throwing subscriber is logged but does NOT stop other subscribers from receiving the event"
+    tdd:
+      red:
+        - Write `in-memory-operation-log-event-bus.test.ts` covering basic publish-subscribe, multi-subscriber fanout, unsubscribe idempotency, and throwing-subscriber isolation
+      green:
+        - Implement the adapter with a `Set` and a `publish` loop wrapping each callback in try/catch
+      refactor:
+        - Pull the subscriber shape into a small type alias; ensure `@injectable()` decoration + DI token
+    estimatedEffort: S
+
+  - id: task-5
+    title: "Publish from `SqliteOperationLogRepository.append` (TDD)"
+    description: >
+      Inject `IOperationLogEventBus` into the SQLite operation-log
+      repository and publish AFTER a successful row insert. Must not
+      publish when the insert throws (DB is source of truth).
+    state: Todo
+    dependencies:
+      - task-4
+    acceptanceCriteria:
+      - "After `append`, a spy bus sees exactly one `publish` call with the inserted entry"
+      - "If the INSERT throws, the bus is NOT called"
+      - "Publish happens AFTER insert — verified by ordering assertion (row is visible via listByScope before publish payload observed)"
+    tdd:
+      red:
+        - Write `sqlite-operation-log-repository-publish.test.ts` using an in-memory DB + spy bus; initially the test fails because the repo never publishes
+      green:
+        - Add bus injection + `this.bus.publish(entry)` after the INSERT returns
+      refactor:
+        - Confirm DI wiring in `register-repositories.ts` still satisfies the constructor signature
+    estimatedEffort: S
+
+  - id: task-6
+    title: "Pure `computeApplicationDeltas` helper (TDD)"
+    description: >
+      New pure function under
+      `packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts`.
+      Inputs: current `Application` row + cached prior state (or null).
+      Output: zero or one `StreamedAgentEvent` with kind `notification`
+      and `eventType: ApplicationUpdated`. Emits when any of
+      `setupComplete`, `status`, `gitRemoteUrl`,
+      `cloudDeploymentProvider` differ from the cached snapshot.
+    state: Todo
+    dependencies:
+      - task-2
+    acceptanceCriteria:
+      - "No prev → zero events (seed-only case)"
+      - "Single field change → exactly one event with the correct payload"
+      - "Multiple concurrent changes → a single event carrying the full updated payload"
+      - "Unchanged row → zero events"
+    tdd:
+      red:
+        - Write `compute-application-deltas.test.ts` with a matrix of (prev, next) pairs covering the seed, single-field-change, multi-field-change, and no-change cases
+      green:
+        - Implement the diff as a short pure function returning `StreamedAgentEvent[]`
+      refactor:
+        - Extract field-list constant; ensure no hidden allocations in the no-change path
+    estimatedEffort: S
+
+  - id: task-7
+    title: "Add `CachedApplicationState` to stream-agent-events types"
+    description: >
+      Extend `stream-agent-events.types.ts` with a new `CachedApplicationState`
+      type capturing the fields the delta helper watches. No behavior
+      change — purely a shared type surface.
+    state: Todo
+    dependencies:
+      - task-6
+    acceptanceCriteria:
+      - "`CachedApplicationState` exported and imported by both the helper and the use-case"
+      - "Typecheck clean"
+    tdd: null
+    estimatedEffort: XS
+
+  - id: task-8
+    title: "Wire app-deltas + bus subscription into StreamAgentEventsUseCase"
+    description: >
+      Inject `IApplicationRepository` and `IOperationLogEventBus`.
+      Maintain a per-connection `Map`.
+      On each poll, `appRepo.findAll()` → call `computeApplicationDeltas`
+      per app → enqueue events. Subscribe to the OperationLog bus in
+      `execute()` and re-emit each published event as a notification.
+      Seed behavior must match features / sessions (no events on first
+      sight). Unsubscribe on generator cleanup.
+    state: Todo
+    dependencies:
+      - task-4
+      - task-6
+      - task-7
+    acceptanceCriteria:
+      - "First poll seeds the application cache and emits zero events"
+      - "Second poll with a changed application emits one `ApplicationUpdated` event"
+      - "Publishing to the OperationLog bus enqueues a notification observable by the `for await` consumer within one tick"
+      - "Generator cleanup unsubscribes from BOTH buses"
+    tdd:
+      red:
+        - Write `stream-agent-events-application.test.ts` and `stream-agent-events-operation-log-bus.test.ts` with fake repos / fake buses; assert the event sequence observed from `for await of useCase.execute(...)`
+      green:
+        - Add injections, cache, subscription, and diff loop in `pollOnce` + `execute`
+      refactor:
+        - Keep subscription lifecycle symmetric with the existing cloud-deploy subscription; add a single `unsubscribeAll()` helper if it reduces duplication
+    estimatedEffort: M
+
+  - id: task-9
+    title: "Client: `ApplicationPageLoader` surgical handler (TDD)"
+    description: >
+      Replace the "invalidate on any SSE event" effect with a typed
+      handler that selects `NotificationEventType === 'application_updated'`
+      for this page's applicationId and patches the React Query
+      cache in place — no HTTP round-trip. Add a
+      `useApplicationUpdates(applicationId)` hook in
+      `hooks/agent-events-provider.tsx` that exposes the latest
+      matching event; the loader consumes it in `useEffect`.
+    state: Todo
+    dependencies:
+      - task-8
+    acceptanceCriteria:
+      - "No `refetchInterval`, no `setInterval`, no `invalidateQueries` call in the loader"
+      - "Given a seeded query cache + a matching event, cache transitions to the new state without any network call"
+      - "Events for other applicationIds are ignored"
+    tdd:
+      red:
+        - "Write `application-page-loader-patch.test.ts`: mount the loader with a QueryClient holding `setupComplete: false`; dispatch a mock event through the provider; assert cache reads `setupComplete: true` and no `fetch` spy was called"
+      green:
+        - "Swap the coarse effect for the typed one; add the selector hook to the provider"
+      refactor:
+        - "Confirm Storybook story still works via `useOptionalAgentEventsContext`"
+    estimatedEffort: S
+
+  - id: task-10
+    title: "Client: `SmartDeployLogsDrawer` surgical handler (TDD)"
+    description: >
+      Replace the coarse "refresh on any SSE event" effect with a typed
+      handler that selects `OperationLogAppended` events for this
+      drawer's applicationId and appends them to local state in place.
+      Initial hydration fetch on open remains. Add a
+      `useOperationLogAppends(applicationId)` selector hook alongside
+      the Application one. Dedup by `entry.id` in case the bus and the
+      hydration fetch race.
+    state: Todo
+    dependencies:
+      - task-8
+    acceptanceCriteria:
+      - "No `setInterval` anywhere in the drawer"
+      - "Matching event appends to `entries` in correct chronological position"
+      - "Duplicate event (same `entry.id`) does not double-add"
+      - "Non-matching applicationId is ignored"
+    tdd:
+      red:
+        - Write `smart-deploy-logs-drawer-append.test.ts`: mount the drawer with mocked hydration fetch + provider; dispatch events; assert the rendered list grows and deduplicates correctly
+      green:
+        - Implement the selector hook + drawer effect
+      refactor:
+        - Ensure the existing "auto-scroll while running" logic keeps working
+    estimatedEffort: S
+
+  - id: task-11
+    title: "Full verification: lint / typecheck / tests / storybook"
+    description: >
+      Run the local CI gates in order and fix anything that breaks.
+      No "unrelated failure" hand-waving — every failure is owned.
+    state: Todo
+    dependencies:
+      - task-9
+      - task-10
+    acceptanceCriteria:
+      - "`pnpm lint` passes clean"
+      - "`pnpm typecheck` passes clean"
+      - "`pnpm test:unit` passes clean"
+      - "`pnpm test:int` passes clean"
+      - "`pnpm build:storybook` passes clean"
+    tdd: null
+    estimatedEffort: S
+
+  - id: task-12
+    title: "Manual smoke: fresh app creation end-to-end"
+    description: >
+      Start the dev server, create a fresh application, observe (a)
+      the `ApplicationUpdated` event in devtools / network SSE tab,
+      (b) the "Preparing your project" card transitioning without a
+      client-side poll, (c) the Smart Deploy Activity drawer
+      populating with ApplicationSetup log lines in real time via
+      append events (not full refetches). Confirm no `setInterval` or
+      `refetchInterval` fires by grepping console + network panel.
+    state: Todo
+    dependencies:
+      - task-11
+    acceptanceCriteria:
+      - "Scaffold card transitions the instant setup_complete flips server-side"
+      - "Drawer shows entries in real time, each individual append visible in SSE stream"
+      - "No `/api/operations/.../logs` polling requests in the network panel"
+      - "No `/api/applications/:id` requests on a client timer"
+    tdd: null
+    estimatedEffort: XS
+
+# Total effort estimate
+totalEstimate: M–L (~1 day)
+
+# Open questions
+openQuestions: []
+
+# Markdown content — summary and acceptance checklist only.
+# Individual task details live in the tasks[] array above (no duplication).
+content: |
+  ## Summary
+
+  Backend-owned SSE for Application and OperationLog deltas, replacing
+  the last two client polling paths with typed, surgical handlers —
+  12 tasks across 6 phases. TDD required for every task that ships
+  code; doc / wiring tasks carry `tdd: null`.
+
+  ## Acceptance Checklist
+
+  Before marking feature complete:
+
+  - [ ] All tasks completed
+  - [ ] Tests passing (`pnpm test`)
+  - [ ] Linting clean (`pnpm lint`)
+  - [ ] Types valid (`pnpm typecheck`)
+  - [ ] TypeSpec compiles (`pnpm tsp:compile`)
+  - [ ] Storybook builds (`pnpm build:storybook`)
+  - [ ] Manual smoke pass (task-12)
+  - [ ] PR created and reviewed
+
+  ---
+
+  _Task details are in the tasks[] array of tasks.yaml_
diff --git a/src/presentation/web/app/api/interactive/chat/[featureId]/turn-groups/route.ts b/src/presentation/web/app/api/interactive/chat/[featureId]/turn-groups/route.ts
new file mode 100644
index 000000000..f9b07165b
--- /dev/null
+++ b/src/presentation/web/app/api/interactive/chat/[featureId]/turn-groups/route.ts
@@ -0,0 +1,45 @@
+/**
+ * GET /api/interactive/chat/[featureId]/turn-groups
+ *
+ * Returns server-derived user turn groups for a feature's chat history.
+ *
+ * Every COMPLETED user turn (a user message plus the assistant replies
+ * that followed it) is collapsed into a named card so the thread
+ * stays short. The MOST RECENT turn is left out so the user keeps
+ * seeing their live reply stream in place.
+ *
+ * Thin route — delegates to `GetChatTurnGroupsUseCase` and serialises
+ * the DTO. No grouping logic lives here; it all happens inside the
+ * use case so the CLI / TUI could expose the same data tomorrow.
+ */
+
+import type { NextRequest } from 'next/server';
+import { NextResponse } from 'next/server';
+import { resolve } from '@/lib/server-container';
+import type { GetChatTurnGroupsUseCase } from '@shepai/core/application/use-cases/interactive/get-chat-turn-groups.use-case';
+
+export const dynamic = 'force-dynamic';
+
+interface RouteParams {
+  params: Promise<{ featureId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams): Promise {
+  try {
+    const { featureId } = await params;
+    if (!featureId || featureId.trim().length === 0) {
+      return NextResponse.json({ error: 'Missing featureId' }, { status: 400 });
+    }
+
+    const useCase = resolve('GetChatTurnGroupsUseCase');
+    const result = await useCase.execute({ featureId });
+    return NextResponse.json(result);
+  } catch (error) {
+    // eslint-disable-next-line no-console
+    console.error('[GET /api/interactive/chat/:featureId/turn-groups]', error);
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : 'Internal server error' },
+      { status: 500 }
+    );
+  }
+}
diff --git a/src/presentation/web/components/assistant-ui/thread.tsx b/src/presentation/web/components/assistant-ui/thread.tsx
index 6d4a4b6ff..8d16d8b78 100644
--- a/src/presentation/web/components/assistant-ui/thread.tsx
+++ b/src/presentation/web/components/assistant-ui/thread.tsx
@@ -93,6 +93,7 @@ export function Thread({
   afterMessages,
   composer,
   hideEmpty,
+  hideMessages,
 }: {
   className?: string;
   /** Content rendered inside the scrollable viewport, BEFORE messages. Used by the application chat to pin the step tracker at the top of the scroll area. */
@@ -102,6 +103,18 @@ export function Thread({
   composer?: React.ReactNode;
   /** Suppress the default "empty chat" placeholder — useful when `beforeMessages` already fills the viewport (e.g. a step tracker). */
   hideEmpty?: boolean;
+  /**
+   * Suppress the flat `ThreadPrimitive.Messages` slot entirely.
+   * Required when the host owns the chat surface via a grouping
+   * overlay (turn-group cards + operation bubbles) — even with
+   * `hideAllMessages: true` returning an empty `threadMessages`
+   * array, the assistant-ui runtime still injects a synthetic
+   * pending assistant message while a response is in flight,
+   * which would otherwise render as a stray Bot avatar bubble
+   * below the overlay. Pass `hideMessages` to drop the slot from
+   * the DOM entirely.
+   */
+  hideMessages?: boolean;
 }) {
   return (
     
@@ -114,12 +127,14 @@ export function Thread({
 
         {beforeMessages}
 
-        
+        {hideMessages ? null : (
+          
+        )}
 
         {afterMessages}
       
diff --git a/src/presentation/web/components/common/feature-node/derive-feature-state.ts b/src/presentation/web/components/common/feature-node/derive-feature-state.ts
index b7dd49504..18f1ed30a 100644
--- a/src/presentation/web/components/common/feature-node/derive-feature-state.ts
+++ b/src/presentation/web/components/common/feature-node/derive-feature-state.ts
@@ -139,8 +139,13 @@ export function mapEventTypeToState(eventType: NotificationEventType): FeatureNo
     case NotificationEventType.MergeReviewReady:
       return 'action-required';
     case NotificationEventType.CloudDeploymentUpdated:
-      // Cloud deploy updates do not affect the feature node lifecycle state —
-      // they are consumed by the application-page cloud deploy hook instead.
+    case NotificationEventType.ApplicationUpdated:
+    case NotificationEventType.OperationLogAppended:
+      // Application-scoped events do not affect the feature node lifecycle
+      // state — they are consumed by the application-page hooks (loader
+      // patch + logs-drawer append) instead. We return 'running' as a
+      // harmless no-op because the callers compare this against feature
+      // node state and never match on application events.
       return 'running';
   }
 }
diff --git a/src/presentation/web/components/features/application-page/app-overflow-menu.tsx b/src/presentation/web/components/features/application-page/app-overflow-menu.tsx
index e2530feb6..7317755f7 100644
--- a/src/presentation/web/components/features/application-page/app-overflow-menu.tsx
+++ b/src/presentation/web/components/features/application-page/app-overflow-menu.tsx
@@ -8,6 +8,9 @@
  * info chip (model + session id) that used to live inline in the top bar.
  * Pulling them in here cleans up the action zone for the SmartDeployButton
  * and Preview while still leaving every control one click away.
+ *
+ * Visual: native toolbar icon button. h-8 w-8, rounded-md, borderless,
+ * background appears on hover/open. 150ms transitions.
  */
 
 import { MoreHorizontal } from 'lucide-react';
@@ -36,14 +39,12 @@ export function AppOverflowMenu({ children, className }: AppOverflowMenuProps) {
           aria-label="More options"
           title="More options"
           className={cn(
-            // Mirrors AppViewTabs flat-tab visual so the overflow trigger
-            // reads as part of the same control row, not a competing
-            // affordance. No background pill, no border-radius, no
-            // shadow — just a hover/active background shift.
             'text-muted-foreground hover:bg-muted hover:text-foreground',
-            'data-[state=open]:bg-background data-[state=open]:text-foreground',
-            'data-[state=open]:border-t-primary',
-            'relative inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-none border-t-2 border-t-transparent bg-transparent shadow-none transition-none',
+            'active:bg-muted/80',
+            'data-[state=open]:bg-muted data-[state=open]:text-foreground',
+            'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:outline-none',
+            'inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-transparent',
+            'transition-colors duration-150 ease-out',
             className
           )}
         >
diff --git a/src/presentation/web/components/features/application-page/app-top-bar.tsx b/src/presentation/web/components/features/application-page/app-top-bar.tsx
index 19c1b668d..dfffcdcbd 100644
--- a/src/presentation/web/components/features/application-page/app-top-bar.tsx
+++ b/src/presentation/web/components/features/application-page/app-top-bar.tsx
@@ -71,7 +71,7 @@ export function AppTopBar({
       )}
     >
       {/* ── Group 1: identity ────────────────────────────────── */}
-      
+

{application.name}

@@ -96,8 +96,6 @@ export function AppTopBar({ agentRunning={agentRunning} /> - - {/* ── Group 4: view switcher (folds in local preview state) ─ The Web tab now owns the dev-server lifecycle — the old standalone Preview button has been removed. Clicking the Web @@ -113,8 +111,6 @@ export function AppTopBar({ deploy={deploy} /> - - {/* ── Group 5: overflow ─────────────────────────────── */}
diff --git a/src/presentation/web/components/features/application-page/app-view-tabs.tsx b/src/presentation/web/components/features/application-page/app-view-tabs.tsx index e8fe73917..dce4aebb1 100644 --- a/src/presentation/web/components/features/application-page/app-view-tabs.tsx +++ b/src/presentation/web/components/features/application-page/app-view-tabs.tsx @@ -3,10 +3,12 @@ /** * AppViewTabs — right-pane view switcher for the app top bar. * - * Rebuild of the old pill-style ViewSwitcher to match the visual - * language of FeatureDrawerTabs: VS Code-style flat tabs with a top - * accent border on the active tab, right border between tabs, and - * 13px medium label. + * Segmented-control style: a pill-shaped bg-muted container with each + * view rendered as a rounded-sm button inside. Active tab sits on a + * raised bg-background card with a subtle shadow; inactive tabs sink + * into the container and reveal a soft hover background. Mirrors the + * toolbar tab control in macOS Sonoma / Windows 11 Settings / VS Code + * command palette — compact, rounded, depth-via-shadow not stripes. * * Folds in the old standalone `RunDevButton` ("Preview") functionality: * the Web tab now owns the local dev-server lifecycle. Clicking the Web @@ -79,16 +81,11 @@ function webTooltip(status: WebStatus, deploy: DeployActionState): string { export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: AppViewTabsProps) { const webStatus = deriveWebStatus(deploy); - // Web tab click — switches view AND starts the dev server when idle/error. - // Booting/ready states just switch view (the pane content shows the live - // state). Wrapped in useCallback so identity is stable for Radix Tabs. const handleTabChange = useCallback( (value: string) => { const next = value as AppView; onChange(next); if (next === 'web' && (webStatus === 'idle' || webStatus === 'error')) { - // Fire-and-forget — the WebPreviewTab will render the booting UI - // as the deploy state transitions through its hook. void deploy.deploy(); } }, @@ -98,11 +95,14 @@ export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: App return ( - - {VIEW_TABS.map((view, idx) => { + + {VIEW_TABS.map((view) => { const Icon = VIEW_ICONS[view]; const disabled = disabledTabs.includes(view); - const isLast = idx === VIEW_TABS.length - 1; const tooltip = view === 'web' ? webTooltip(webStatus, deploy) : VIEW_LABELS[view]; const trigger = ( @@ -110,38 +110,28 @@ export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: App value={view} disabled={disabled} className={cn( - 'text-muted-foreground hover:bg-muted hover:text-foreground', - 'data-[state=active]:bg-background data-[state=active]:text-foreground', - 'data-[state=active]:font-semibold', - '[&:not([data-state=active])]:border-r-border', - 'relative h-9 rounded-none border-r border-r-transparent', - 'bg-transparent px-3 text-[12px] font-medium shadow-none transition-none', - 'cursor-pointer data-[state=active]:shadow-none', - isLast && 'last:border-r-transparent', - disabled && 'cursor-not-allowed opacity-40' + 'text-muted-foreground hover:text-foreground inline-flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium whitespace-nowrap', + 'transition-all duration-150 ease-out', + 'data-[state=inactive]:hover:bg-background/60', + 'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs', + 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:outline-none', + disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent', + !disabled && 'cursor-pointer' )} > - {/* State-aware icon for the Web tab; static icon for IDE/Terminal. */} {view === 'web' ? ( ) : ( - + )} {VIEW_LABELS[view]} - {/* Bottom accent bar — rendered as a real div so it - is 100% immune to Tailwind CSS variable cascade - issues. Same 2px primary colour as the smart deploy - button's bottom accent. */} - {active === view ? ( - - ) : null} ); return ( {trigger} - + {tooltip} @@ -154,9 +144,9 @@ export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: App } /** - * Web-tab icon that swaps glyph + adds a status overlay dot based on - * the dev-server state. Kept as its own component so the Tab trigger - * markup stays scannable. + * Web-tab icon — swaps glyph + adds a status overlay dot based on the + * dev-server state. Separate component so the TabsTrigger markup stays + * scannable. */ function WebTabIcon({ status, @@ -166,13 +156,13 @@ function WebTabIcon({ BaseIcon: React.ComponentType<{ className?: string }>; }) { if (status === 'booting') { - return ; + return ; } if (status === 'error') { - return ; + return ; } return ( - + {status === 'ready' ? (