From bd87b8087f6ffc60d937e032d6a9130dc03353f7 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Wed, 15 Apr 2026 03:12:52 +0300 Subject: [PATCH 1/7] fix(web): silence monaco unresolved import squiggles in ide editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disables monaco typescript semantic validation on beforeMount so files opened in the ide tab no longer render every import as a red "cannot find module" squiggle — the in-browser tsserver has no project graph so its semantic diagnostics were pure noise. Syntax errors are still surfaced; only unresolved-symbol noise is hidden. Co-Authored-By: Shep Bot --- .../application-page/ide-tab/editor-pane.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/presentation/web/components/features/application-page/ide-tab/editor-pane.tsx b/src/presentation/web/components/features/application-page/ide-tab/editor-pane.tsx index f8ee0c6cc..04a3573fb 100644 --- a/src/presentation/web/components/features/application-page/ide-tab/editor-pane.tsx +++ b/src/presentation/web/components/features/application-page/ide-tab/editor-pane.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import dynamic from 'next/dynamic'; +import type { Monaco } from '@monaco-editor/react'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Eye, FileCode, File as FileIcon, PanelRight, X } from 'lucide-react'; @@ -130,6 +131,36 @@ export function EditorPane({ [active, onChange] ); + // Monaco's in-browser TS language service has no project graph (no + // tsconfig, no node_modules), so every `import` in a .ts/.tsx file would + // otherwise render as a red "Cannot find module" squiggle. Disable + // semantic validation — we still surface real syntax errors — so the + // preview matches what the file actually looks like on disk. + const handleBeforeMount = useCallback((monaco: Monaco) => { + const ts = monaco.languages.typescript; + const compilerOptions = { + target: ts.ScriptTarget.Latest, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + jsx: ts.JsxEmit.Preserve, + allowJs: true, + allowNonTsExtensions: true, + esModuleInterop: true, + isolatedModules: true, + noEmit: true, + skipLibCheck: true, + }; + ts.typescriptDefaults.setCompilerOptions(compilerOptions); + ts.javascriptDefaults.setCompilerOptions(compilerOptions); + + const diagnostics = { + noSemanticValidation: true, + noSyntaxValidation: false, + }; + ts.typescriptDefaults.setDiagnosticsOptions(diagnostics); + ts.javascriptDefaults.setDiagnosticsOptions(diagnostics); + }, []); + return (
{/* Tab strip */} @@ -244,6 +275,7 @@ export function EditorPane({ language={languageForPath(active.path)} value={active.content} onChange={handleChange} + beforeMount={handleBeforeMount} options={{ fontSize: 13, minimap: { enabled: true }, From bafdcc8ed5004f1ee6a314b48de59926f953a304 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Wed, 15 Apr 2026 03:14:08 +0300 Subject: [PATCH 2/7] feat(web): group chat turns and surface operation activity bubbles Adds server-derived user-turn grouping to the application chat plus static publish/deploy activity bubbles, so the thread stays compact and every long-running operation has a visible home in the conversation. - new GetChatTurnGroupsUseCase walks persisted messages server-side, returns completed turn groups (collapsed by default) and the latest in-flight turn as currentTurn (expanded by default), with hiddenMessageIds so the flat thread skips anything the cards own - unit tests cover empty feed, step-tagged skipping, live turn promotion, chronological ordering, long-message truncation, and orphan assistant messages - new /api/interactive/chat/:featureId/turn-groups thin route delegates to the use case - TurnGroupCard renders completed cards with an emerald check and an in-progress card with a fuchsia gradient spinner, pinned "working on your request..." title, non-collapsible while live - TurnGroupList hydrates cards from the main chat cache and renders the streaming indicator inline inside the in-progress card - useChatRuntime accepts hiddenMessageIds and suppressStreamingIndicator options, exposes rawMessages and streamingState for the host - ChatTab wires everything through a new applicationId prop so application chats get the full experience while repo and global chats are untouched - OperationBubble polls existing operation_log_entries endpoints and renders a static chronological card per publish or deploy, shown after the thread so users see the full history in place Co-Authored-By: Shep Bot --- .../get-chat-turn-groups.use-case.ts | 196 +++++++++++++++ .../di/modules/register-interactive.ts | 5 + .../chat/[featureId]/turn-groups/route.ts | 45 ++++ .../application-page/application-page.tsx | 1 + .../web/components/features/chat/ChatTab.tsx | 94 ++++++-- .../features/chat/operation-bubble.tsx | 223 ++++++++++++++++++ .../features/chat/turn-group-card.tsx | 129 ++++++++++ .../features/chat/turn-group-list.tsx | 215 +++++++++++++++++ .../features/chat/useChatRuntime.ts | 108 ++++++--- .../get-chat-turn-groups.use-case.test.ts | 181 ++++++++++++++ 10 files changed, 1148 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/application/use-cases/interactive/get-chat-turn-groups.use-case.ts create mode 100644 src/presentation/web/app/api/interactive/chat/[featureId]/turn-groups/route.ts create mode 100644 src/presentation/web/components/features/chat/operation-bubble.tsx create mode 100644 src/presentation/web/components/features/chat/turn-group-card.tsx create mode 100644 src/presentation/web/components/features/chat/turn-group-list.tsx create mode 100644 tests/unit/application/use-cases/interactive/get-chat-turn-groups.use-case.test.ts 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/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/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/features/application-page/application-page.tsx b/src/presentation/web/components/features/application-page/application-page.tsx index 655f74d50..4f86c2ee6 100644 --- a/src/presentation/web/components/features/application-page/application-page.tsx +++ b/src/presentation/web/components/features/application-page/application-page.tsx @@ -118,6 +118,7 @@ export function ApplicationPage({ application, initialChatState }: ApplicationPa left={ composeUserInput( @@ -147,6 +171,8 @@ export function ChatTab({ respondToInteraction, stepProgress, initialRequestMessage, + rawMessages, + streamingState, } = useChatRuntime(featureId, worktreePath, { contentTransform, onMessageSent: att.clearAttachments, @@ -160,6 +186,13 @@ export function ChatTab({ // real workflow-step row arrives. Without this, the bubble would // fall through to the flat thread and render below the tracker. pinInitialRequest: (workflowPlaceholder?.length ?? 0) > 0, + // Server-derived completed turns get pulled out of the flat + // thread and rendered as collapsed cards above the messages. + hiddenMessageIds, + // When grouping is on, the in-progress turn card owns the + // streaming indicator — suppress the flat-thread version so it + // doesn't appear twice. + suppressStreamingIndicator: turnGroupsEnabled, }); // Fire the all-steps-complete callback exactly once per mount. @@ -340,31 +373,48 @@ export function ChatTab({ composer={composer} hideEmpty={showTracker} beforeMessages={ - showTracker ? ( - <> - {initialRequestMessage ? ( - - ) : null} - + {showTracker ? ( + <> + {initialRequestMessage ? ( + + ) : null} + + + ) : null} + {turnGroupsEnabled ? ( + - - ) : undefined + ) : null} + } afterMessages={ - pendingInteraction ? ( - - ) : undefined + <> + {pendingInteraction ? ( + + ) : null} + {applicationId ? ( + <> + + + + ) : null} + } /> diff --git a/src/presentation/web/components/features/chat/operation-bubble.tsx b/src/presentation/web/components/features/chat/operation-bubble.tsx new file mode 100644 index 000000000..99f8d8db2 --- /dev/null +++ b/src/presentation/web/components/features/chat/operation-bubble.tsx @@ -0,0 +1,223 @@ +'use client'; + +/** + * OperationBubble + * + * Static, chronological info card rendered in the chat thread for a + * completed long-running operation on the application — currently + * either "Publish to GitHub" (GitRemoteCreate) or "Deploy to cloud" + * (CloudDeploy). + * + * Source of truth: the existing `/api/operations/:kind/:id/logs` + * endpoint which is backed by the `operation_log_entries` table. + * This component is a THIN presentation layer — it polls the logs + * while the operation is still writing entries and renders them in + * order. No grouping or summarization logic lives here; any derived + * state (e.g. "success" vs "failed") is computed from the last log + * level of the raw entries returned by the server. + */ + +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Github, Rocket, CheckCircle2, XCircle, Loader2, AlertTriangle } from 'lucide-react'; +import { OperationLogKind } from '@shepai/core/domain/generated/output'; +import { cn } from '@/lib/utils'; + +interface OperationLogEntryDto { + id: string; + operationKind: string; + operationId: string; + level: string; + message: string; + detail?: string; + createdAt: string; +} + +interface OperationBubbleProps { + applicationId: string; + kind: 'publish' | 'deploy'; +} + +const KIND_TO_OP: Record = { + publish: OperationLogKind.GitRemoteCreate, + deploy: OperationLogKind.CloudDeploy, +}; + +const KIND_META = { + publish: { + title: 'Published to GitHub', + inProgressTitle: 'Publishing to GitHub…', + icon: Github, + accent: 'text-sky-500 bg-sky-500/15', + }, + deploy: { + title: 'Deployed to cloud', + inProgressTitle: 'Deploying to cloud…', + icon: Rocket, + accent: 'text-fuchsia-500 bg-fuchsia-500/15', + }, +} as const; + +async function fetchLogs(applicationId: string, kind: OperationBubbleProps['kind']) { + const opKind = KIND_TO_OP[kind]; + const res = await fetch( + `/api/operations/${encodeURIComponent(opKind)}/${encodeURIComponent(applicationId)}/logs` + ); + if (!res.ok) return { entries: [] as OperationLogEntryDto[] }; + return (await res.json()) as { entries: OperationLogEntryDto[] }; +} + +/** + * Derive a human status from the raw entries. An operation is + * "in-progress" while entries are still being appended (we can't + * tell from logs alone so we treat the last 10 seconds of activity + * as live). Otherwise "success" if the last entry is Info/Debug, + * "failed" if the last entry is Error, "warn" if Warn. + */ +type BubbleStatus = 'in-progress' | 'success' | 'failed' | 'warn' | 'empty'; + +function deriveStatus(entries: OperationLogEntryDto[]): BubbleStatus { + if (entries.length === 0) return 'empty'; + const last = entries[entries.length - 1]; + const lastAtMs = new Date(last.createdAt).getTime(); + const ageMs = Date.now() - lastAtMs; + if (ageMs < 10_000) return 'in-progress'; + switch (last.level) { + case 'Error': + return 'failed'; + case 'Warn': + return 'warn'; + default: + return 'success'; + } +} + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return iso; + } +} + +export function OperationBubble({ applicationId, kind }: OperationBubbleProps) { + const { data } = useQuery({ + queryKey: ['operation-logs', applicationId, kind] as const, + queryFn: () => fetchLogs(applicationId, kind), + refetchInterval: (q) => { + // While still live, poll fast so new entries appear as they + // land. Once the operation has settled the auto-refresh + // relaxes — a mount is enough to hydrate the final state. + const entries = q.state.data?.entries ?? []; + if (entries.length === 0) return false; + const status = deriveStatus(entries); + return status === 'in-progress' ? 1500 : false; + }, + staleTime: 0, + }); + + const entries = useMemo(() => data?.entries ?? [], [data]); + if (entries.length === 0) return null; + + const status = deriveStatus(entries); + const meta = KIND_META[kind]; + const Icon = meta.icon; + const isInFlight = status === 'in-progress'; + + return ( +
+
+ + + +
+
+ {isInFlight ? meta.inProgressTitle : meta.title} +
+
+ {entries.length} event{entries.length === 1 ? '' : 's'} · started{' '} + {formatTime(entries[0].createdAt)} +
+
+ +
+
    + {entries.map((e) => ( +
  1. + + {formatTime(e.createdAt)} + + + + {e.message} + {e.detail ? ( + + {e.detail} + + ) : null} + +
  2. + ))} +
+
+ ); +} + +function StatusBadge({ status }: { status: BubbleStatus }) { + if (status === 'in-progress') { + return ( + + in progress + + ); + } + if (status === 'success') { + return ( + + done + + ); + } + if (status === 'warn') { + return ( + + warnings + + ); + } + if (status === 'failed') { + return ( + + failed + + ); + } + return null; +} + +function LevelDot({ level }: { level: string }) { + const color = + level === 'Error' + ? 'bg-rose-500' + : level === 'Warn' + ? 'bg-amber-500' + : level === 'Debug' + ? 'bg-muted-foreground/50' + : 'bg-emerald-500'; + return ; +} diff --git a/src/presentation/web/components/features/chat/turn-group-card.tsx b/src/presentation/web/components/features/chat/turn-group-card.tsx new file mode 100644 index 000000000..117e2b2a8 --- /dev/null +++ b/src/presentation/web/components/features/chat/turn-group-card.tsx @@ -0,0 +1,129 @@ +'use client'; + +/** + * TurnGroupCard + * + * One user-turn card in the chat thread. Two modes: + * + * - `completed`: collapsed by default, emerald check icon, click to + * reveal the persisted messages inside the turn. Matches the + * StepTracker visual language so "completed setup" and "completed + * user turn" read as siblings in the timeline. + * + * - `in-progress`: EXPANDED by default, non-collapsible, a spinning + * fuchsia indicator, title reads "Working on your request…". The + * parent renders the turn's persisted messages AND the live + * streaming indicator inside `children`, so the moment the user + * sends a message they see a new card pop in with the reply + * building up inside it — no stray "Thinking…" bubble in the + * flat thread. + */ + +import { useState, type ReactNode } from 'react'; +import { ChevronDown, CheckCircle2, MessageSquare, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface TurnGroupCardProps { + /** Stable id for React keys / aria-controls. */ + id: string; + /** Human-readable title, e.g. "Working on: Fix login bug". */ + title: string; + /** Number of assistant replies collected inside the turn. */ + assistantMessageCount: number; + /** Render mode — see file header. */ + status: 'completed' | 'in-progress'; + /** Children rendered when the card is expanded (raw messages + + * the live streaming indicator for in-progress turns). */ + children?: ReactNode; +} + +export function TurnGroupCard({ + id, + title, + assistantMessageCount, + status, + children, +}: TurnGroupCardProps) { + const isInProgress = status === 'in-progress'; + // In-progress cards are always expanded; completed cards collapse + // by default and only open on explicit click. + const [userExpanded, setUserExpanded] = useState(false); + const expanded = isInProgress || userExpanded; + const contentId = `${id}-content`; + + return ( +
+ + {expanded ? ( +
+ {children ??
No content.
} +
+ ) : null} +
+ ); +} diff --git a/src/presentation/web/components/features/chat/turn-group-list.tsx b/src/presentation/web/components/features/chat/turn-group-list.tsx new file mode 100644 index 000000000..7cd34023e --- /dev/null +++ b/src/presentation/web/components/features/chat/turn-group-list.tsx @@ -0,0 +1,215 @@ +'use client'; + +/** + * TurnGroupList + * + * Fetches the server-derived list of user turns for a feature and + * renders a `TurnGroupCard` per completed group PLUS an in-progress + * card for the currently-live turn. The in-progress card is expanded + * by default and receives the live streaming indicator inline, so + * the moment the user sends a message they see a single "Working + * on your request…" surface with the reply building up inside it. + * + * All grouping logic lives in `GetChatTurnGroupsUseCase` — this + * component is a thin renderer. + */ + +import { useQuery } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import type { InteractiveMessage } from '@shepai/core/domain/generated/output'; +import { InteractiveMessageRole } from '@shepai/core/domain/generated/output'; +import { TurnGroupCard } from './turn-group-card'; + +interface TurnGroupDto { + id: string; + title: string; + userMessagePreview: string; + messageIds: string[]; + assistantMessageCount: number; + startedAt: number; + endedAt: number; + status: 'completed' | 'in-progress'; +} + +export interface TurnGroupsResponse { + groups: TurnGroupDto[]; + currentTurn: TurnGroupDto | null; + hiddenMessageIds: string[]; +} + +export function turnGroupsQueryKey(featureId: string): readonly unknown[] { + return ['chat', featureId, 'turn-groups'] as const; +} + +export async function fetchTurnGroups(featureId: string): Promise { + const res = await fetch(`/api/interactive/chat/${encodeURIComponent(featureId)}/turn-groups`); + if (!res.ok) { + return { groups: [], currentTurn: null, hiddenMessageIds: [] }; + } + return (await res.json()) as TurnGroupsResponse; +} + +/** Live streaming state the in-progress card pins at its bottom edge. */ +export interface TurnStreamingState { + /** Persisted streaming text or the live SSE delta, whichever is longer. */ + text: string; + /** Short "Reading file X" style indicator from tool events. */ + statusLog: string | null; + /** True while we're between send and first delta. */ + awaiting: boolean; + /** "booting" / "idle" / etc. */ + sessionStatus: string | null; +} + +/** + * Hook returning both the raw groups data (for filtering the thread + * upstream) and the list of hidden message ids. Kept here alongside + * the component so the single query is shared — ChatTab calls this + * hook too and TanStack dedupes on the shared query key. + */ +export function useTurnGroups(featureId: string): TurnGroupsResponse { + const enabled = featureId.trim().length > 0; + const { data } = useQuery({ + queryKey: turnGroupsQueryKey(featureId), + queryFn: () => fetchTurnGroups(featureId), + // Short stale window so the card appears fast when the user + // sends a message — the chat-state refetch triggered by the + // mutation will bump this in practice, but the low staleTime + // is a belt-and-braces fallback. + staleTime: 1_000, + refetchOnWindowFocus: false, + enabled, + }); + return data ?? { groups: [], currentTurn: null, hiddenMessageIds: [] }; +} + +export interface TurnGroupListProps { + featureId: string; + /** Raw messages from the main chat query — used to hydrate cards. */ + allMessages: readonly InteractiveMessage[]; + /** Live streaming state for the in-progress card. */ + streaming: TurnStreamingState; +} + +export function TurnGroupList({ featureId, allMessages, streaming }: TurnGroupListProps) { + const { groups, currentTurn } = useTurnGroups(featureId); + if (groups.length === 0 && !currentTurn) return null; + + // Index messages by id once so every card's children are O(1) lookups. + const byId = new Map(); + for (const m of allMessages) byId.set(m.id, m); + + return ( +
+ {groups.map((g) => ( + + + + ))} + {currentTurn ? ( + +
+ + +
+
+ ) : null} +
+ ); +} + +function TurnChildMessages({ + messageIds, + byId, +}: { + messageIds: readonly string[]; + byId: Map; +}) { + return ( +
+ {messageIds.map((mid) => { + const msg = byId.get(mid); + if (!msg) { + // Happens briefly after a send: the turn-groups query + // returned before the chat-state query refetched. + return ( +
+ … +
+ ); + } + const isUser = msg.role === InteractiveMessageRole.user; + return ( +
+
+ {isUser ? 'You' : 'Assistant'} +
+ {msg.content} +
+ ); + })} +
+ ); +} + +function StreamingIndicator({ streaming }: { streaming: TurnStreamingState }) { + const hasText = streaming.text.trim().length > 0; + const hasStatus = (streaming.statusLog ?? '').trim().length > 0; + const isBooting = streaming.sessionStatus === 'booting'; + + if (hasText) { + return ( +
+
+ Assistant +
+ {streaming.text} + {hasStatus ? ( +
+ + {streaming.statusLog} +
+ ) : null} +
+ ); + } + + if (hasStatus) { + return ( +
+ + {streaming.statusLog} +
+ ); + } + + if (streaming.awaiting || isBooting) { + return ( +
+ + {isBooting ? 'Agent is waking up…' : 'Thinking…'} +
+ ); + } + + return null; +} diff --git a/src/presentation/web/components/features/chat/useChatRuntime.ts b/src/presentation/web/components/features/chat/useChatRuntime.ts index df557f0d9..64f7bb0f4 100644 --- a/src/presentation/web/components/features/chat/useChatRuntime.ts +++ b/src/presentation/web/components/features/chat/useChatRuntime.ts @@ -204,6 +204,24 @@ export interface ChatRuntimeOptions { * kicked in once `hasPlan` became true. */ pinInitialRequest?: boolean; + /** + * Message ids to filter out of the flat thread. These belong to + * server-derived "turn groups" that the host page renders as + * collapsible cards above the thread — the raw bubbles must not + * also appear in the flat message list, or the user would see the + * same content twice (once inside the card, once flat below). + * See `GetChatTurnGroupsUseCase` for the server-side derivation. + */ + hiddenMessageIds?: readonly string[]; + /** + * When true, the hook stops injecting its synthetic `streaming` + * message (the one that carries the live delta + "Thinking…" / + * "Agent is waking up…" placeholders) into `threadMessages`. The + * same streaming data is exposed separately on the return value + * (`streamingState`) so the host can render it wherever it wants — + * typically inside an in-progress turn card owned by the server. + */ + suppressStreamingIndicator?: boolean; } /** A debug event captured from SSE for display in debug mode. */ @@ -719,6 +737,16 @@ export function useChatRuntime( return first ?? null; }, [stepProgress.hasPlan, pinInitialRequest, messages]); + // Server-derived turn-group filter. The set is rebuilt whenever the + // host passes a new array of hidden ids (typically from + // `useTurnGroups()` keyed off the same featureId). Kept outside the + // threadMessages memo so the expensive memo only re-runs when the + // underlying message list actually changes. + const hiddenMessageIdSet = useMemo( + () => new Set(options?.hiddenMessageIds ?? []), + [options?.hiddenMessageIds] + ); + const threadMessages: ThreadMessageLike[] = useMemo(() => { const hasPlan = stepProgress.hasPlan; @@ -750,11 +778,12 @@ export function useChatRuntime( // placeholder-only case we still want the first user message pulled // out of the flat thread so the host's `` // isn't duplicated below the tracker. - const shouldFilter = hasPlan || pinInitialRequest; + const shouldFilter = hasPlan || pinInitialRequest || hiddenMessageIdSet.size > 0; const sourceMessages = shouldFilter ? messages.filter((m) => { if (m.id === initialRequestMessage?.id) return false; if (m.stepId) return false; // step-tagged messages live inside their card + if (hiddenMessageIdSet.has(m.id)) return false; // hidden inside a completed turn-group card if (workflowRunning && m.role === InteractiveMessageRole.assistant) { // Race-window leftover — see comment above. return false; @@ -817,33 +846,41 @@ export function useChatRuntime( } // Streaming text as the last message — may include a live activity suffix. - if (activeStreamText.trim()) { - const parts: { type: 'text'; text: string }[] = [{ type: 'text', text: activeStreamText }]; - // Append live activity indicator when agent is doing tool work - if (statusLog) { - parts.push({ type: 'text', text: `*⏳ ${statusLog}*` }); + // + // Skipped entirely when the host has asked us to suppress the + // indicator because the server-derived in-progress turn card is + // rendering it inline instead (see `streamingState` below). + const suppressStreaming = options?.suppressStreamingIndicator === true; + + if (!suppressStreaming) { + if (activeStreamText.trim()) { + const parts: { type: 'text'; text: string }[] = [{ type: 'text', text: activeStreamText }]; + // Append live activity indicator when agent is doing tool work + if (statusLog) { + parts.push({ type: 'text', text: `*⏳ ${statusLog}*` }); + } + result.push({ id: 'streaming', role: 'assistant', content: parts }); + } else if (statusLog) { + // No streaming text yet but agent is actively working (tool calls, etc.) + result.push({ + id: 'streaming', + role: 'assistant', + content: [{ type: 'text', text: `*⏳ ${statusLog}*` }], + }); + } else if (awaitingResponse || sessionStatus === 'booting') { + // Note: sendMutation.isPending is NOT included here — the 600ms + // delay via startAwaiting() prevents flash on fast responses. + result.push({ + id: 'streaming', + role: 'assistant', + content: [ + { + type: 'text', + text: sessionStatus === 'booting' ? '*Agent is waking up...*' : '*Thinking...*', + }, + ], + }); } - result.push({ id: 'streaming', role: 'assistant', content: parts }); - } else if (statusLog) { - // No streaming text yet but agent is actively working (tool calls, etc.) - result.push({ - id: 'streaming', - role: 'assistant', - content: [{ type: 'text', text: `*⏳ ${statusLog}*` }], - }); - } else if (awaitingResponse || sessionStatus === 'booting') { - // Note: sendMutation.isPending is NOT included here — the 600ms - // delay via startAwaiting() prevents flash on fast responses. - result.push({ - id: 'streaming', - role: 'assistant', - content: [ - { - type: 'text', - text: sessionStatus === 'booting' ? '*Agent is waking up...*' : '*Thinking...*', - }, - ], - }); } return result; @@ -855,10 +892,12 @@ export function useChatRuntime( sessionStatus, statusLog, options?.debugMode, + options?.suppressStreamingIndicator, debugEvents, stepProgress.hasPlan, stepProgress.allDone, pinInitialRequest, + hiddenMessageIdSet, ]); // ── Status info for typing indicator ────────────────────────────────── @@ -972,5 +1011,20 @@ export function useChatRuntime( respondToInteraction, stepProgress, initialRequestMessage, + /** Raw persisted messages — exposed so the host can hydrate + * collapsed server-derived turn-group cards by id lookup. */ + rawMessages: messages, + /** + * Live streaming state for external renderers (in-progress turn + * card). Always populated whether or not the synthetic streaming + * thread message is injected — `suppressStreamingIndicator` + * only controls the flat-thread injection, not the data exposure. + */ + streamingState: { + text: activeStreamText, + statusLog, + awaiting: awaitingResponse, + sessionStatus, + }, }; } diff --git a/tests/unit/application/use-cases/interactive/get-chat-turn-groups.use-case.test.ts b/tests/unit/application/use-cases/interactive/get-chat-turn-groups.use-case.test.ts new file mode 100644 index 000000000..e8bcf6504 --- /dev/null +++ b/tests/unit/application/use-cases/interactive/get-chat-turn-groups.use-case.test.ts @@ -0,0 +1,181 @@ +/** + * GetChatTurnGroupsUseCase unit tests. + * + * The use case derives "turn groups" from the raw interactive_messages + * history for a feature. A turn group is a user message plus every + * consecutive assistant reply until the next user message. The + * MOST RECENT turn stays live (not grouped) so the user always sees + * the current reply streaming; completed turns get collapsed into a + * single named card on the client. + * + * Messages that already carry a `stepId` belong to the setup workflow + * and are ignored here — those live inside the StepTracker, not the + * flat thread. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach } from 'vitest'; +import type { IInteractiveMessageRepository } from '@/application/ports/output/repositories/interactive-message-repository.interface.js'; +import type { InteractiveMessage } from '@/domain/generated/output.js'; +import { InteractiveMessageRole } from '@/domain/generated/output.js'; +import { GetChatTurnGroupsUseCase } from '@/application/use-cases/interactive/get-chat-turn-groups.use-case.js'; + +function msg( + id: string, + role: InteractiveMessageRole, + content: string, + createdAtMs: number, + stepId?: string +): InteractiveMessage { + return { + id, + featureId: 'feat-1', + role, + content, + createdAt: new Date(createdAtMs) as unknown as InteractiveMessage['createdAt'], + updatedAt: new Date(createdAtMs) as unknown as InteractiveMessage['updatedAt'], + stepId, + }; +} + +class FakeRepo implements IInteractiveMessageRepository { + messages: InteractiveMessage[] = []; + async create(): Promise { + // The use case under test is read-only; writes never fire. + return; + } + async findByFeatureId(): Promise { + return [...this.messages]; + } + async findBySessionId(): Promise { + return []; + } + async deleteByFeatureId(): Promise { + // The use case under test is read-only; deletes never fire. + return; + } +} + +describe('GetChatTurnGroupsUseCase', () => { + let repo: FakeRepo; + let useCase: GetChatTurnGroupsUseCase; + + beforeEach(() => { + repo = new FakeRepo(); + useCase = new GetChatTurnGroupsUseCase(repo); + }); + + it('returns empty when the feature has no messages', async () => { + const result = await useCase.execute({ featureId: 'feat-1' }); + expect(result.groups).toEqual([]); + expect(result.currentTurn).toBeNull(); + expect(result.hiddenMessageIds).toEqual([]); + }); + + it('ignores messages that carry a stepId (those belong to setup)', async () => { + repo.messages = [ + msg('m1', InteractiveMessageRole.user, 'Build it', 1000, 'step-1'), + msg('m2', InteractiveMessageRole.assistant, 'done', 2000, 'step-1'), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + expect(result.groups).toEqual([]); + expect(result.currentTurn).toBeNull(); + expect(result.hiddenMessageIds).toEqual([]); + }); + + it('emits the only live turn as currentTurn (in-progress) and hides its messages', async () => { + repo.messages = [ + msg('m1', InteractiveMessageRole.user, 'Fix bug X', 1000), + msg('m2', InteractiveMessageRole.assistant, 'Working…', 2000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + expect(result.groups).toEqual([]); + expect(result.currentTurn).not.toBeNull(); + expect(result.currentTurn?.status).toBe('in-progress'); + expect(result.currentTurn?.messageIds).toEqual(['m1', 'm2']); + expect(result.hiddenMessageIds).toEqual(['m1', 'm2']); + }); + + it('groups every completed turn and promotes the latest to currentTurn', async () => { + repo.messages = [ + // completed turn + msg('u1', InteractiveMessageRole.user, 'Fix bug X', 1000), + msg('a1', InteractiveMessageRole.assistant, 'fixed', 2000), + msg('a2', InteractiveMessageRole.assistant, 'also cleaned up', 2500), + // live turn + msg('u2', InteractiveMessageRole.user, 'Add feature Y', 3000), + msg('a3', InteractiveMessageRole.assistant, 'adding…', 4000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + + expect(result.groups).toHaveLength(1); + const [g] = result.groups; + expect(g.id).toBe('turn-u1'); + expect(g.status).toBe('completed'); + expect(g.messageIds).toEqual(['u1', 'a1', 'a2']); + + expect(result.currentTurn).not.toBeNull(); + expect(result.currentTurn?.id).toBe('turn-u2'); + expect(result.currentTurn?.status).toBe('in-progress'); + expect(result.currentTurn?.messageIds).toEqual(['u2', 'a3']); + + // BOTH completed and current turn ids are hidden from the flat + // thread — the in-progress card now owns the live bubbles. + expect(result.hiddenMessageIds).toEqual(['u1', 'a1', 'a2', 'u2', 'a3']); + }); + + it('produces one group per completed user turn in chronological order', async () => { + repo.messages = [ + msg('u1', InteractiveMessageRole.user, 'First ask', 1000), + msg('a1', InteractiveMessageRole.assistant, 'first reply', 1500), + msg('u2', InteractiveMessageRole.user, 'Second ask', 2000), + msg('a2', InteractiveMessageRole.assistant, 'second reply', 2500), + msg('u3', InteractiveMessageRole.user, 'Third ask — the live one', 3000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + + expect(result.groups.map((g) => g.id)).toEqual(['turn-u1', 'turn-u2']); + expect(result.groups[0].messageIds).toEqual(['u1', 'a1']); + expect(result.groups[1].messageIds).toEqual(['u2', 'a2']); + expect(result.currentTurn?.id).toBe('turn-u3'); + expect(result.currentTurn?.messageIds).toEqual(['u3']); + expect(result.hiddenMessageIds).toEqual(['u1', 'a1', 'u2', 'a2', 'u3']); + }); + + it('truncates long user messages in the preview and title', async () => { + const longAsk = 'a'.repeat(500); + repo.messages = [ + msg('u1', InteractiveMessageRole.user, longAsk, 1000), + msg('a1', InteractiveMessageRole.assistant, 'ok', 2000), + msg('u2', InteractiveMessageRole.user, 'live', 3000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + const [g] = result.groups; + expect(g.userMessagePreview.length).toBeLessThanOrEqual(120); + expect(g.title.length).toBeLessThanOrEqual(140); + expect(g.title.startsWith('Working on')).toBe(true); + }); + + it('assigns a descriptive fallback title when the user message is empty', async () => { + repo.messages = [ + msg('u1', InteractiveMessageRole.user, ' ', 1000), + msg('a1', InteractiveMessageRole.assistant, 'ok', 2000), + msg('u2', InteractiveMessageRole.user, 'live', 3000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + expect(result.groups[0].title).toBe('Working on your request'); + }); + + it('leaves a trailing assistant orphan (no preceding user message) alone', async () => { + repo.messages = [ + msg('a0', InteractiveMessageRole.assistant, 'hi', 500), + msg('u1', InteractiveMessageRole.user, 'Fix bug', 1000), + msg('a1', InteractiveMessageRole.assistant, 'done', 1500), + msg('u2', InteractiveMessageRole.user, 'live', 2000), + ]; + const result = await useCase.execute({ featureId: 'feat-1' }); + expect(result.groups.map((g) => g.id)).toEqual(['turn-u1']); + expect(result.currentTurn?.id).toBe('turn-u2'); + expect(result.hiddenMessageIds).toEqual(['u1', 'a1', 'u2']); + }); +}); From 865608e9c741385e2aaf7d00f6210f2e14269ca6 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Wed, 15 Apr 2026 03:16:11 +0300 Subject: [PATCH 3/7] feat(web): one-click get online with unified smart deploy activity log Turns the "Get online" state into a fully autonomous pipeline and ships a single chronological log surface for every long-running operation the button can trigger. - new SmartDeployLogsDrawer merges GitRemoteCreate, CloudDeploy, and RepoSync log entries into one timeline sorted by createdAt, each row tagged with a coloured kind chip (github sky, cloud fuchsia, sync emerald) so the source of every line stays obvious - SmartDeployButton gets a persistent scroll-icon segment after the chevron that opens the unified drawer, always visible so users never have to hunt for a per-op log affordance - handleGetOnline in SmartDeployCluster runs the full best-effort pipeline on a single click: ensure owners, create github remote with the default owner plus slugified name, refresh git status, auto-select the first connected cloud provider, initiate deploy - the drawer auto-opens the moment any primary action fires and polls every 1.5s while running so the pipeline is never silent; toasts surface the exact reason when no github owner or no connected provider is available instead of reverting the button - rocket tone plus gradient icon chip on the "Get online" button so it pops as the primary call to action; workingSource gains a getOnline variant that pins the label on "Getting online..." through the entire pipeline to kill intermediate state flicker - oneClickRunning flag in useSmartDeployState dominates every other branch so the button cannot flash through deploy or save while the github create and cloud deploy trade batons - top-bar buttons and tabs switch from h-9 inside an h-12 bar to an explicit h-12 so the row sits flush top-to-bottom with no outer padding band; explicit height avoids h-full-in-flex-items-center resolution quirks Co-Authored-By: Shep Bot --- .../application-page/app-overflow-menu.tsx | 2 +- .../application-page/app-view-tabs.tsx | 4 +- .../application-page/smart-deploy-button.tsx | 91 ++++- .../application-page/smart-deploy-cluster.tsx | 211 ++++++++--- .../smart-deploy-logs-drawer.tsx | 327 ++++++++++++++++++ .../web/hooks/use-smart-deploy-state.ts | 37 +- 6 files changed, 608 insertions(+), 64 deletions(-) create mode 100644 src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx 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..be04c8286 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 @@ -43,7 +43,7 @@ export function AppOverflowMenu({ children, className }: AppOverflowMenuProps) { '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', + 'relative inline-flex h-12 w-9 cursor-pointer items-center justify-center rounded-none border-t-2 border-t-transparent bg-transparent shadow-none transition-none', className )} > 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..314a6b328 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 @@ -98,7 +98,7 @@ export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: App return ( - + {VIEW_TABS.map((view, idx) => { const Icon = VIEW_ICONS[view]; const disabled = disabledTabs.includes(view); @@ -114,7 +114,7 @@ export function AppViewTabs({ active, onChange, disabledTabs = [], deploy }: App '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', + 'relative h-12 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', diff --git a/src/presentation/web/components/features/application-page/smart-deploy-button.tsx b/src/presentation/web/components/features/application-page/smart-deploy-button.tsx index 665ab5c26..1cc3fc3ff 100644 --- a/src/presentation/web/components/features/application-page/smart-deploy-button.tsx +++ b/src/presentation/web/components/features/application-page/smart-deploy-button.tsx @@ -17,11 +17,11 @@ import { AlertTriangle, Check, ChevronDown, - Cloud, Loader2, RefreshCw, Rocket, Save, + ScrollText, Sparkles, } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -40,6 +40,10 @@ export interface SmartDeployButtonProps { * path instead of committing to a specific action. */ panelOpen?: boolean; onPanelOpenChange?(open: boolean): void; + /** Opens the unified Smart Deploy activity log drawer. When omitted + * the log button segment is hidden — kept optional so the + * component still works in storybook without the drawer wired up. */ + onOpenLogs?(): void; className?: string; } @@ -53,8 +57,12 @@ interface LabelSpec { icon: React.ComponentType<{ className?: string }>; label: string; sub?: string; - tone: 'primary' | 'emerald' | 'amber' | 'destructive' | 'muted'; + tone: 'primary' | 'emerald' | 'amber' | 'destructive' | 'muted' | 'rocket'; spinning?: boolean; + /** When true, the icon is rendered inside a vibrant gradient chip + * instead of inheriting the tone color — reserved for the + * one-click "Get online" surface that should visually pop. */ + iconChip?: boolean; } function labelFor(state: SmartDeployState): LabelSpec { @@ -64,8 +72,20 @@ function labelFor(state: SmartDeployState): LabelSpec { case 'working': { // Specific-per-source label instead of the generic "Working…". // Sync: we're doing the git commit+push pipeline. Deploy: the - // cloud provider is shipping the build. Fallback stays as - // "Working…" for any edge case where source isn't set. + // cloud provider is shipping the build. GetOnline: the + // one-click create-repo + auto-deploy pipeline (single label + // covering the whole sequence so the button can't flicker + // between intermediate states). Fallback stays as "Working…" + // for any edge case where source isn't set. + if (state.workingSource === 'getOnline') { + return { + icon: Loader2, + label: 'Getting online…', + tone: 'rocket', + spinning: true, + iconChip: true, + }; + } if (state.workingSource === 'sync') { return { icon: Loader2, label: 'Syncing code', tone: 'primary', spinning: true }; } @@ -119,9 +139,9 @@ function labelFor(state: SmartDeployState): LabelSpec { tone: 'emerald', }; case 'getOnline': - return { icon: Cloud, label: 'Get online', tone: 'primary' }; + return { icon: Rocket, label: 'Get online', tone: 'rocket', iconChip: true }; default: - return { icon: Cloud, label: 'Get online', tone: 'primary' }; + return { icon: Rocket, label: 'Get online', tone: 'rocket', iconChip: true }; } } @@ -158,6 +178,15 @@ const TONE_CLASSES: Record = { 'border-border border-b-2 border-b-destructive bg-destructive/5 text-destructive hover:bg-destructive/10 dark:bg-destructive/10 dark:hover:bg-destructive/15', muted: 'border-border border-b-2 border-b-transparent bg-background text-muted-foreground hover:bg-accent', + // One-click "Get online" call to action. Mirrors the structure of + // the `primary` tone (solid subtle tint, no body gradient) so the + // Tailwind build can't mis-render a multi-stop gradient with + // opacity modifiers as a full-saturation fuchsia block. The + // "cool colored" accent lives entirely in the icon chip — see + // `iconChip` rendering below — so the button reads as a pop of + // colour without drowning the label in magenta. + rocket: + 'border-border border-b-2 border-b-fuchsia-500 bg-fuchsia-500/5 text-fuchsia-700 hover:bg-fuchsia-500/10 dark:text-fuchsia-300 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15', }; export function SmartDeployButton({ @@ -166,6 +195,7 @@ export function SmartDeployButton({ panel, panelOpen: controlledPanelOpen, onPanelOpenChange, + onOpenLogs, className, }: SmartDeployButtonProps) { const spec = labelFor(state); @@ -194,17 +224,31 @@ export function SmartDeployButton({ onClick={onPrimaryClick} disabled={!isInteractive} className={cn( - // Square, flat, VS-Code-tab language. The 2px top accent - // comes from the tone class; the rest of the frame stays - // neutral so "green everywhere" can't happen anymore. - 'inline-flex h-9 items-center gap-2 rounded-none border border-r-0 px-3 text-xs font-medium transition-colors', + // Square, flat, VS-Code-tab language. Fixed h-12 matches + // the top bar (TOP_BAR_HEIGHT_CLASS = 'h-12') so the + // button sits flush top-to-bottom with no outer padding + // band. Using h-full here is unreliable inside a flex + // items-center parent — the explicit height always wins. + 'inline-flex h-12 items-center gap-2 rounded-none border border-r-0 px-3 text-xs font-medium transition-colors', TONE_CLASSES[spec.tone], isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-70' )} title={spec.label} > - - + + {/* Tiny dirty-dot overlay on the icon when there are pending changes and we're NOT already showing them in the label. Hidden for states whose label already communicates the state so we don't @@ -239,7 +283,7 @@ export function SmartDeployButton({ // Square chevron half, same top accent as the main // button so the split reads as one rectangle with a // single vertical divider between the two halves. - 'inline-flex h-9 items-center justify-center rounded-none border px-1.5 transition-colors', + 'inline-flex h-12 items-center justify-center rounded-none border px-1.5 transition-colors', TONE_CLASSES[spec.tone], 'cursor-pointer' )} @@ -251,6 +295,27 @@ export function SmartDeployButton({ {panel} + + {/* ── Right-most: unified activity log trigger ────────────── + Persistent, always visible next to the Smart Deploy surface + so the user can read the full cross-operation timeline + (GitHub + Cloud + Sync) in one place without hunting for a + context-specific button inside the panel. */} + {onOpenLogs ? ( + + ) : null}
); } diff --git a/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx b/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx index 0ba98c0b1..8ca4507c4 100644 --- a/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx +++ b/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx @@ -10,18 +10,15 @@ */ import { useCallback, useEffect, useState } from 'react'; -import { - CloudDeploymentProvider, - CloudDeploymentStatus, - type Application, -} from '@shepai/core/domain/generated/output'; +import { toast } from 'sonner'; +import { CloudDeploymentProvider, type Application } from '@shepai/core/domain/generated/output'; import type { useCloudDeployAction } from '@/hooks/use-cloud-deploy-action'; import { useGitStatus } from '@/hooks/use-git-status'; import { useSyncAction } from '@/hooks/use-sync-action'; import { useSmartDeployState } from '@/hooks/use-smart-deploy-state'; import { SmartDeployButton } from './smart-deploy-button'; import { DeployPanel } from './deploy-panel'; -import { OperationLogsDrawer } from './operation-logs-drawer'; +import { SmartDeployLogsDrawer } from './smart-deploy-logs-drawer'; import { ConnectProviderModal } from './connect-provider-modal'; import { PublishToGitHubModal, type PublishOwner } from './publish-to-github-modal'; @@ -49,8 +46,6 @@ export interface SmartDeployClusterProps { agentRunning: boolean; } -type LogsTarget = { kind: 'CloudDeploy' | 'GitRemoteCreate' | 'RepoSync'; title: string } | null; - export function SmartDeployCluster({ application, cloudDeploy, @@ -109,8 +104,13 @@ export function SmartDeployCluster({ ); const [connectMode, setConnectMode] = useState<'connect' | 'update'>('connect'); - // Operation logs drawer — opens on the right side, scoped per op kind. - const [logsTarget, setLogsTarget] = useState(null); + // Unified Smart Deploy activity log drawer. Replaces the per-op-kind + // drawer — now a single timeline that merges GitRemoteCreate, + // CloudDeploy, and RepoSync entries sorted by createdAt so the user + // reads the whole story in one place. The log button on the right + // edge of the SmartDeployButton toggles this, and every primary + // action auto-opens it so the pipeline is never invisible. + const [logsOpen, setLogsOpen] = useState(false); // Popover panel open state — lifted here (instead of inside // SmartDeployButton) so the primary-click handler for the `getOnline` @@ -119,6 +119,13 @@ export function SmartDeployCluster({ // rather than forcing them through the GitHub modal. const [panelOpen, setPanelOpen] = useState(false); + // One-click "Get online" pipeline guard. Set to true for the entire + // duration of the create-repo + auto-deploy sequence so + // `useSmartDeployState` can lock the label on "Getting online…" from + // click to success — no flickering through intermediate `deploy` / + // `save` states between API calls. + const [oneClickRunning, setOneClickRunning] = useState(false); + // Friendly cloud-provider display name. Computed up here (rather than // near the render) so `useSmartDeployState` can use it to produce a // specific "Deploying to Cloudflare Pages" label instead of a generic @@ -143,6 +150,7 @@ export function SmartDeployCluster({ syncAction: sync.state, hasConnectedCloudProvider, cloudProviderName, + oneClickRunning, }); // ── Action dispatch ──────────────────────────────────────────────── @@ -155,35 +163,28 @@ export function SmartDeployCluster({ // still click "Activity log" inside the panel to read the entries // post-hoc. + // Every primary action opens the unified activity drawer so the + // pipeline is never invisible — the user sees the same timeline + // regardless of which action kicked it off. const handleSaveChanges = useCallback(async () => { + setLogsOpen(true); await sync.sync(); await refreshGitStatus(); - if (sync.state.kind === 'failed') { - setLogsTarget({ kind: 'RepoSync', title: 'Save & backup' }); - } }, [sync, refreshGitStatus]); const handlePublishToWeb = useCallback(async () => { + setLogsOpen(true); await cloudDeploy.initiate(); - if (cloudDeploy.state.status === CloudDeploymentStatus.Failed) { - setLogsTarget({ kind: 'CloudDeploy', title: 'Publish to web' }); - } }, [cloudDeploy]); const handleRedeploy = handlePublishToWeb; const handleSaveAndPublish = useCallback(async () => { + setLogsOpen(true); await sync.sync(); await refreshGitStatus(); - if (sync.state.kind === 'failed') { - // Sync side blew up — open the drawer and stop here. - setLogsTarget({ kind: 'RepoSync', title: 'Save & publish everything' }); - return; - } + if (sync.state.kind === 'failed') return; await cloudDeploy.initiate(); - if (cloudDeploy.state.status === CloudDeploymentStatus.Failed) { - setLogsTarget({ kind: 'CloudDeploy', title: 'Save & publish everything' }); - } }, [sync, refreshGitStatus, cloudDeploy]); const handleSetUpCodeStorage = useCallback(async () => { @@ -198,11 +199,9 @@ export function SmartDeployCluster({ // user doesn't have to click Publish to web as a separate step. const handleSelectProvider = useCallback( async (provider: CloudDeploymentProvider) => { + setLogsOpen(true); await cloudDeploy.selectProvider(provider); await cloudDeploy.initiate(); - if (cloudDeploy.state.status === CloudDeploymentStatus.Failed) { - setLogsTarget({ kind: 'CloudDeploy', title: 'Publish to web' }); - } }, [cloudDeploy] ); @@ -223,7 +222,7 @@ export function SmartDeployCluster({ }, []); const handleOpenLogs = useCallback(() => { - setLogsTarget({ kind: 'CloudDeploy', title: 'Activity log' }); + setLogsOpen(true); }, []); const handleOpenInGitHub = useCallback(() => { @@ -235,6 +234,125 @@ export function SmartDeployCluster({ } }, [gitStatus]); + // One-click "Get online" pipeline. Best-effort end-to-end: + // 1. If GitHub owners haven't been loaded yet, fetch them. + // 2. If the repo has no git remote yet, create one against the + // user's default owner using the slugified application name. + // Any failure here (HTTP 409 on name collision, network blip) + // is surfaced via the logs drawer but does NOT abort the + // pipeline — the cloud deploy can still run off a local-only + // repo in some provider configurations. + // 3. If at least one cloud provider is connected, pick the first + // one (or the already-selected one) and fire a deploy. + // 4. If NO cloud provider is connected, we've done half the work + // (the remote now exists) — open the panel so the user can + // connect a provider in one more click. + // + // Throughout, `oneClickRunning` stays true so the button label is + // pinned on "Getting online…" and the transition to `live` is a + // single, clean flip at the end. + const handleGetOnline = useCallback(async () => { + if (oneClickRunning || agentRunning) return; + setOneClickRunning(true); + // Open the unified activity drawer IMMEDIATELY so the user + // never stares at a silently-reverting button. Every server-side + // log entry that the pipeline writes streams into the drawer + // live via its 1.5s poll. + setLogsOpen(true); + let didAnyWork = false; + try { + // 1. Ensure owners loaded — needed to pick a default owner + // for the create-remote call below. + await ensureOwners(); + + // 2. Create the GitHub remote if we don't have one yet. We + // re-read `gitStatus` defensively in case it loaded between + // user intent and click. + const hasRemoteNow = gitStatus?.hasRemote === true || Boolean(application.gitRemoteUrl); + if (!hasRemoteNow) { + const ownersSnapshot = owners; + const firstOwner = ownersSnapshot && ownersSnapshot.length > 0 ? ownersSnapshot[0] : null; + if (!firstOwner) { + // No GitHub connection at all — we cannot create a remote + // automatically. Surface the gap with a toast so the user + // knows WHY nothing happened, and open the panel so they + // can hit "Connect GitHub" in one click. + toast.error('Connect GitHub first', { + description: + 'One-click Get online needs a GitHub connection to create the remote. Open the panel and connect GitHub.', + }); + setPanelOpen(true); + } else { + const defaultRepoName = application.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + try { + const res = await fetch(`/api/applications/${application.id}/git/create-remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ownerLogin: firstOwner.login, + repoName: defaultRepoName, + visibility: 'private', + }), + }); + didAnyWork = true; + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + toast.error('GitHub publish failed', { + description: body.error ?? `HTTP ${res.status}`, + }); + } + } catch (err) { + toast.error('GitHub publish failed', { + description: err instanceof Error ? err.message : 'Network error', + }); + } + await refreshGitStatus(); + } + } + + // 3. Auto-deploy to the first connected cloud provider. + const connected = providers.find((p) => p.enabled && p.connected); + if (connected) { + if (!cloudDeploy.state.provider || cloudDeploy.state.provider !== connected.id) { + await cloudDeploy.selectProvider(connected.id); + } + await cloudDeploy.initiate(); + didAnyWork = true; + } else { + // No cloud provider connected — surface the gap with a toast + // and open the panel. The repo half may have already run. + toast.error('Connect a hosting provider', { + description: + 'One-click Get online needs a connected cloud provider to deploy. Pick one from the panel.', + }); + setPanelOpen(true); + } + + if (!didAnyWork) { + // Nothing actually ran — close the drawer again so the user + // isn't staring at an empty "No activity yet" state. + setLogsOpen(false); + } + } finally { + setOneClickRunning(false); + } + }, [ + oneClickRunning, + agentRunning, + ensureOwners, + gitStatus, + owners, + application.id, + application.name, + application.gitRemoteUrl, + refreshGitStatus, + providers, + cloudDeploy, + ]); + // Primary-click dispatch table — drives the left-half of the split button. const handlePrimaryClick = useCallback(() => { if (agentRunning) return; @@ -260,12 +378,13 @@ export function SmartDeployCluster({ } return; case 'getOnline': - // Don't force the user through the GitHub flow — opening the - // panel lets them pick Save & backup OR Publish to web (or - // Connect hosting) independently. Deploying doesn't actually - // require a git remote; the old "Get online → GitHub modal" - // shortcut was biased toward one path and blocked the other. - setPanelOpen(true); + // One-click "Get online" — create repo + auto-deploy as a + // single pipeline, best effort. The button label is pinned + // on "Getting online…" throughout via `oneClickRunning` so + // the user sees a single smooth transition from click to + // live, not a sequence of intermediate state flashes. The + // chevron still opens the full panel for advanced control. + void handleGetOnline(); return; case 'failed': // Retry whichever side failed. @@ -283,6 +402,7 @@ export function SmartDeployCluster({ handleSaveAndPublish, handleSaveChanges, handlePublishToWeb, + handleGetOnline, ]); // First-time publish flow — wraps the existing PublishToGitHubModal. @@ -299,7 +419,7 @@ export function SmartDeployCluster({ } setPublishModalOpen(false); await refreshGitStatus(); - setLogsTarget({ kind: 'GitRemoteCreate', title: 'Set up code backup' }); + setLogsOpen(true); }, [application.id, refreshGitStatus] ); @@ -324,6 +444,7 @@ export function SmartDeployCluster({ onPrimaryClick={handlePrimaryClick} panelOpen={panelOpen} onPanelOpenChange={setPanelOpen} + onOpenLogs={handleOpenLogs} panel={ - {/* Logs drawer — single instance, opened with the right scope by - whichever action fired last. */} - { - if (!open) setLogsTarget(null); - }} - kind={(logsTarget?.kind ?? 'CloudDeploy') as 'CloudDeploy' | 'GitRemoteCreate'} - operationId={application.id} - title={logsTarget?.title ?? 'Activity log'} + {/* Unified Smart Deploy activity drawer — a single chronological + timeline merging GitRemoteCreate + CloudDeploy + RepoSync + logs. Replaces the old per-kind drawer so the user never + has to guess which scope the currently-open log belongs to. */} + {/* First-time publish modal */} diff --git a/src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx b/src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx new file mode 100644 index 000000000..4c5850552 --- /dev/null +++ b/src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx @@ -0,0 +1,327 @@ +'use client'; + +/** + * SmartDeployLogsDrawer — unified activity log for the Smart Deploy + * cluster. Every long-running operation that the button can trigger + * writes to its own `operation_log_entries` scope on the server: + * + * - GitRemoteCreate — "Publish to GitHub" / "Get online" repo half + * - CloudDeploy — "Publish to web" / "Get online" deploy half + * - RepoSync — "Save & backup" commit+push pipeline + * + * This drawer fetches all three in parallel, merges them by + * `createdAt`, and renders one chronologically-sorted stream so the + * user sees a single unified timeline for the whole Smart Deploy + * surface — no more guessing which operation the visible log drawer + * is scoped to. + * + * Each row is tagged with a small colored "kind" chip so the source + * of every entry stays obvious. A "Show debug" toggle hides Debug-level + * entries by default; "Copy all" produces a plaintext dump suitable + * for pasting into a bug report. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AlertTriangle, + Bug, + CircleCheck, + Cloud, + Copy, + GitBranch, + Github, + Info, + Loader2, + TriangleAlert, +} from 'lucide-react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { cn } from '@/lib/utils'; + +export type SmartOpKind = 'CloudDeploy' | 'GitRemoteCreate' | 'RepoSync'; +type LogLevel = 'Debug' | 'Info' | 'Warn' | 'Error'; + +interface OperationLogEntryDto { + id: string; + operationKind: SmartOpKind; + operationId: string; + level: LogLevel; + message: string; + detail?: string; + createdAt: string; +} + +const OP_KINDS: readonly SmartOpKind[] = ['GitRemoteCreate', 'CloudDeploy', 'RepoSync']; + +const KIND_META: Record< + SmartOpKind, + { label: string; icon: typeof Info; chipClass: string; iconClass: string } +> = { + GitRemoteCreate: { + label: 'GitHub', + icon: Github, + chipClass: 'bg-sky-500/15 text-sky-700 dark:text-sky-300', + iconClass: 'text-sky-500', + }, + CloudDeploy: { + label: 'Cloud', + icon: Cloud, + chipClass: 'bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300', + iconClass: 'text-fuchsia-500', + }, + RepoSync: { + label: 'Sync', + icon: GitBranch, + chipClass: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', + iconClass: 'text-emerald-500', + }, +}; + +const LEVEL_ICON: Record = { + Debug: { icon: Bug, className: 'text-muted-foreground' }, + Info: { icon: Info, className: 'text-sky-500' }, + Warn: { icon: TriangleAlert, className: 'text-amber-500' }, + Error: { icon: AlertTriangle, className: 'text-destructive' }, +}; + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return iso; + } +} + +export interface SmartDeployLogsDrawerProps { + open: boolean; + onOpenChange(open: boolean): void; + applicationId: string; + /** When true, the drawer polls for new entries every 1.5s. */ + isRunning: boolean; + /** Friendly subtitle, e.g. cloud provider name. */ + subtitle?: string; +} + +export function SmartDeployLogsDrawer({ + open, + onOpenChange, + applicationId, + isRunning, + subtitle, +}: SmartDeployLogsDrawerProps) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showDebug, setShowDebug] = useState(false); + const bodyRef = useRef(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + // Fetch all three scopes in parallel, then merge. Any single + // request failing doesn't poison the drawer — we still show + // what we have and surface a soft error banner. + const responses = await Promise.all( + OP_KINDS.map(async (kind) => { + try { + const res = await fetch( + `/api/operations/${encodeURIComponent(kind)}/${encodeURIComponent(applicationId)}/logs` + ); + if (!res.ok) return { kind, entries: [] as OperationLogEntryDto[] }; + const body = (await res.json()) as { entries?: OperationLogEntryDto[] }; + return { kind, entries: body.entries ?? [] }; + } catch { + return { kind, entries: [] as OperationLogEntryDto[] }; + } + }) + ); + const merged = responses + .flatMap((r) => r.entries) + .sort((a, b) => { + const ta = new Date(a.createdAt).getTime(); + const tb = new Date(b.createdAt).getTime(); + return ta - tb; + }); + setEntries(merged); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load logs'); + } finally { + setLoading(false); + } + }, [applicationId]); + + // Fetch on open + poll while running. + useEffect(() => { + if (!open) return; + void refresh(); + if (!isRunning) return; + const timer = setInterval(() => { + void refresh(); + }, 1500); + return () => clearInterval(timer); + }, [open, isRunning, refresh]); + + // Auto-scroll to bottom while running so new entries are visible + // without the user chasing them. Paused otherwise so historical + // browsing isn't yanked. + useEffect(() => { + if (!isRunning || !open) return; + const el = bodyRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [entries, isRunning, open]); + + const visible = useMemo( + () => (showDebug ? entries : entries.filter((e) => e.level !== 'Debug')), + [entries, showDebug] + ); + + const copyAll = useCallback(async () => { + const text = entries + .map((e) => { + const base = `[${formatTime(e.createdAt)}] ${KIND_META[e.operationKind].label} · ${e.level.toUpperCase()} — ${e.message}`; + return e.detail ? `${base}\n${e.detail}` : base; + }) + .join('\n'); + try { + await navigator.clipboard.writeText(text); + } catch { + // Ignore — secure context may be unavailable in some dev envs. + } + }, [entries]); + + return ( + + + + + + Smart Deploy · Activity + + {subtitle ? {subtitle} : null} +
+ + {entries.length} {entries.length === 1 ? 'entry' : 'entries'} + + + +
+
+ +
+ {loading && entries.length === 0 ? ( +
+ Loading logs… +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + {visible.length === 0 && !loading && !error ? ( +
+ No activity yet. + {isRunning ? ' The operation just started — entries will appear here.' : ''} +
+ ) : null} + +
    + {visible.map((entry) => { + const { icon: LevelIcon, className: levelClass } = LEVEL_ICON[entry.level]; + const meta = KIND_META[entry.operationKind]; + return ( +
  1. +
    + +
    +
    + + {formatTime(entry.createdAt)} + + + + {meta.label} + + + {entry.message} + +
    + {entry.detail ? ( +
    + + Details + +
    +                            {entry.detail}
    +                          
    +
    + ) : null} +
    +
    +
  2. + ); + })} +
+
+
+
+ ); +} + +function HeaderStatusIcon({ + isRunning, + entries, +}: { + isRunning: boolean; + entries: readonly OperationLogEntryDto[]; +}) { + if (isRunning) { + return ; + } + for (let i = entries.length - 1; i >= 0; i--) { + const level = entries[i].level; + if (level === 'Debug') continue; + if (level === 'Error') return ; + if (level === 'Warn') return ; + return ; + } + return ; +} diff --git a/src/presentation/web/hooks/use-smart-deploy-state.ts b/src/presentation/web/hooks/use-smart-deploy-state.ts index 9b8a04af5..d6901f700 100644 --- a/src/presentation/web/hooks/use-smart-deploy-state.ts +++ b/src/presentation/web/hooks/use-smart-deploy-state.ts @@ -75,8 +75,10 @@ export interface SmartDeployState { failedSource: 'sync' | 'deploy' | null; /** Which side is actively running when `kind === 'working'`. Used by * the top-bar button to swap the generic "Working…" label for a - * specific one: "Syncing code" or "Deploying to Cloudflare Pages". */ - workingSource: 'sync' | 'deploy' | null; + * specific one: "Syncing code", "Deploying to Cloudflare Pages", or + * the one-click "Getting online…" umbrella that covers the whole + * create-repo + auto-deploy pipeline. */ + workingSource: 'sync' | 'deploy' | 'getOnline' | null; /** Friendly display name of the cloud provider the deploy targets, * e.g. "Cloudflare Pages". Threaded in from the parent so the * hook doesn't have to own the enum→name mapping. */ @@ -104,6 +106,15 @@ export interface UseSmartDeployStateInput { * enum→string mapping. Falls through to null when no provider is * picked yet (`Working…` fallback). */ cloudProviderName: string | null; + /** True while the one-click "Get online" pipeline is running + * (create GitHub remote → refresh git status → auto-deploy to the + * connected cloud provider). Forces the state machine to `working` + * with `workingSource: 'getOnline'` for the ENTIRE pipeline so the + * button label can't flicker between intermediate truths — without + * it, the brief window between create-remote completing and + * cloudDeploy.initiate() being called would bounce the label + * through `deploy`/`save` before landing on `working` again. */ + oneClickRunning?: boolean; } export function useSmartDeployState({ @@ -114,6 +125,7 @@ export function useSmartDeployState({ syncAction, hasConnectedCloudProvider, cloudProviderName, + oneClickRunning, }: UseSmartDeployStateInput): SmartDeployState { return useMemo(() => { // Effective git status — merge live read with persisted remote URL. @@ -138,6 +150,26 @@ export function useSmartDeployState({ remoteUrl: null, }); + // One-click "Get online" pipeline override. The caller lifts this + // flag for the full duration of the create-repo → deploy pipeline + // so the label stays on "Getting online…" end-to-end. Without this + // dominance, the moment create-remote completes and git status + // refreshes we'd fall through to (e.g.) `deploy` until the cloud + // call fires a beat later, producing a visible flicker on the + // user's primary call-to-action. + if (oneClickRunning) { + const cloudUrl = cloudDeploy.state.url; + return baseState({ + kind: 'working', + hasCloud: hasConnectedCloudProvider, + hasRemote: gitStatus?.hasRemote ?? persistedRemoteUrl !== null, + changeCount: (gitStatus?.uncommittedCount ?? 0) + (gitStatus?.unpushedCount ?? 0), + workingSource: 'getOnline', + cloudProviderName, + liveUrl: cloudUrl ?? null, + }); + } + // Loading is ONLY the brief window before the first /git/status fetch // completes, AND only when we have nothing else to show. Once the // first attempt finishes (success OR fail) the button transitions to @@ -275,6 +307,7 @@ export function useSmartDeployState({ syncAction, hasConnectedCloudProvider, cloudProviderName, + oneClickRunning, ]); } From 210ca2e854f08eac0fa1511720dfe142553c46f8 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Wed, 15 Apr 2026 03:16:39 +0300 Subject: [PATCH 4/7] feat(web): sharpen application card corners Drops the cards from rounded-2xl (16px) and rounded-xl (14px) to rounded-sm (2px) on both the live ApplicationCard and the new application placeholder so every tile on the dashboard has the same sharp vs-code-tab edge. Fixes a visual mismatch where the placeholder stayed rounded after the main card was tightened. Co-Authored-By: Shep Bot --- .../components/features/applications/application-card.tsx | 6 ++++-- .../features/applications/applications-page-client.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/presentation/web/components/features/applications/application-card.tsx b/src/presentation/web/components/features/applications/application-card.tsx index 291d87cca..00a6e00ed 100644 --- a/src/presentation/web/components/features/applications/application-card.tsx +++ b/src/presentation/web/components/features/applications/application-card.tsx @@ -239,8 +239,10 @@ export function ApplicationCard({ application, className }: ApplicationCardProps // has no contrast against the page. Lift the dark-mode surface // one step (`neutral-900` = `#171717`) and soften the border so // the card reads as an elevated tile instead of a hole in the - // page. Light mode is unchanged. - 'group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl', + // page. Light mode is unchanged. Corners are deliberately sharp + // (`rounded-sm`) per the dashboard tile family — the placeholder + // and new-application tiles both match. + 'group relative flex cursor-pointer flex-col overflow-hidden rounded-sm', 'bg-card dark:bg-neutral-900', 'border-border/60 border shadow-sm dark:border-white/10', 'hover:border-border transition-all duration-200 hover:shadow-lg dark:hover:border-white/20 dark:hover:shadow-black/40', diff --git a/src/presentation/web/components/features/applications/applications-page-client.tsx b/src/presentation/web/components/features/applications/applications-page-client.tsx index 18e9e15e3..8c2b90e4f 100644 --- a/src/presentation/web/components/features/applications/applications-page-client.tsx +++ b/src/presentation/web/components/features/applications/applications-page-client.tsx @@ -166,7 +166,7 @@ function NewApplicationCard({ return (
Date: Wed, 15 Apr 2026 18:10:20 +0300 Subject: [PATCH 5/7] feat(web): three-layer chat timeline overlay with no raw bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifies the application chat around a single chronological timeline driven by the three-layer model documented in features/chat/CLAUDE.md: raw events (Layer 1) flow through a pure synchronous grouping layer (Layer 2) that produces high-level friendly cards (Layer 3). Default view shows only the user request plus a high-level activity indicator — every raw tool event is hidden behind a progressive-disclosure chevron. - new CLAUDE.md in features/chat/ codifies the three-layer rules with explicit do/don't guidance, file responsibilities, and anti-patterns so future edits cannot break the flow - client-side computeTurnGroupsFromMessages replaces the stale server query for the web view, runs synchronously on every render via useTurnGroupsView so the overlay updates in the same tick as SSE deltas arrive - in-progress card is demoted to completed when status.isRunning goes false, so finished conversations never leave a permanent "Working on your request..." card pretending work is still in flight - useChatRuntime gains hideAllMessages option — when true the flat assistant-ui thread returns an empty message list and the overlay owns the entire visible surface, closing the last gap where raw bubbles could leak - TurnGroupCard splits into a condensed slot (user request plus friendly streaming indicator, default visible) and a details slot (raw thinking / read / output bubbles, revealed on chevron click); the user message never renders twice - new CondensedStreamingIndicator shows only a spinner plus a high-level activity line (tool status, "Thinking...", "Working ...") — never the raw assistant stream text, so the layer rule holds through the whole pipeline - ErrorRecoveryBanner surfaces effectiveStatus failures at the top of the chat pane with a prominent Try again button wired to onResumeWorkflow, replacing the silent red pill dead end - old InitialRequestBubble removed; the CurrentTurnCard is now the single anchor for every user request, pinned in its chronological position rather than at the top - chronological ordering enforced: error banner -> StepTracker -> completed turn cards (oldest first) -> in-progress turn -> operation bubbles -> pending interaction Co-Authored-By: Shep Bot --- .../application-page/application-page.tsx | 24 +- .../web/components/features/chat/CLAUDE.md | 174 ++++++++++ .../web/components/features/chat/ChatTab.tsx | 230 +++++++++---- .../features/chat/turn-group-card.tsx | 86 +++-- .../features/chat/turn-group-list.tsx | 307 ++++++++++++++---- .../features/chat/useChatRuntime.ts | 22 ++ 6 files changed, 679 insertions(+), 164 deletions(-) create mode 100644 src/presentation/web/components/features/chat/CLAUDE.md diff --git a/src/presentation/web/components/features/application-page/application-page.tsx b/src/presentation/web/components/features/application-page/application-page.tsx index 4f86c2ee6..cb66ad054 100644 --- a/src/presentation/web/components/features/application-page/application-page.tsx +++ b/src/presentation/web/components/features/application-page/application-page.tsx @@ -6,7 +6,10 @@ import type { ChatState } from '@shepai/core/application/ports/output/services/i import { featureIdForApplication } from '@shepai/core/domain/shared/feature-id'; import { ChatTab } from '@/components/features/chat/ChatTab'; -import type { ScaffoldingState } from '@/components/features/chat/ChatTab'; +import type { + ApplicationErrorState, + ScaffoldingState, +} from '@/components/features/chat/ChatTab'; import { APPLICATION_CREATION_PLACEHOLDER_STEPS } from '@/components/features/chat/workflow-placeholder'; import { useCloudDeployAction } from '@/hooks/use-cloud-deploy-action'; import { useDeployAction } from '@/hooks/use-deploy-action'; @@ -103,6 +106,24 @@ export function ApplicationPage({ application, initialChatState }: ApplicationPa startedAt: new Date(application.createdAt).getTime(), }; + // Derive a recovery-banner payload for ChatTab when the application + // is in a broken state. The server-side + // `application.status === Error` flag is the authoritative signal: + // the setup / build pipeline crashed, was logged, and the row was + // stamped. Without a visible banner the user would otherwise only + // see a red "ERROR" pill in the top bar with no explanation. The + // `/resume` endpoint re-runs the last failed step, so the banner + // is always retryable when we render it. + const applicationError: ApplicationErrorState | null = + application.status === ApplicationStatus.Error + ? { + kind: 'Setup failed', + message: + 'The last setup run errored out before it could finish. Click Try again to re-run the failed step, or open the Smart Deploy activity log from the top bar to see what went wrong.', + retryable: true, + } + : null; + return (
{ void fetch(`/api/applications/${application.id}/resume`, { method: 'POST' }); }} + applicationError={applicationError} onAllStepsComplete={() => { // CRITICAL: only auto-deploy on the VERY FIRST completion // of the setup workflow. `application.setupComplete` is diff --git a/src/presentation/web/components/features/chat/CLAUDE.md b/src/presentation/web/components/features/chat/CLAUDE.md new file mode 100644 index 000000000..35d6ffce0 --- /dev/null +++ b/src/presentation/web/components/features/chat/CLAUDE.md @@ -0,0 +1,174 @@ +# Chat Component Architecture (STRICT) + +These rules govern the `features/chat/` directory. They exist because +the chat UI previously devolved into a chaotic mix of flat bubbles, +competing rendering layers, and stale-query races. Do not break them +without explicit user sign-off. + +## The Three Layers + +The chat ALWAYS flows through three strictly-ordered layers. Each +layer has one responsibility and does not leak into the others. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 1 — RAW EVENTS (single chronological stream) │ +│ │ +│ The source of truth. Every persisted chat message, tool call, │ +│ system event, and debug event lives here in chronological │ +│ order. Think "operation log" — a single append-only feed. │ +│ │ +│ Primary feed: `rawMessages` exposed by `useChatRuntime` from │ +│ the `interactive_messages` table via the chat-state cache. │ +│ Secondary feeds may extend this: system events, debug bubbles, │ +│ tool-call traces. All are tagged and remain chronological. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 2 — OVERLAY GROUPING LAYER (pure, derivable) │ +│ │ +│ A pure function over the raw event stream that produces a │ +│ list of named groups. Groups are chronological. Each group │ +│ has: id, title, status ('completed' | 'in-progress'), the │ +│ list of raw event ids it owns, and a friendly high-level │ +│ summary. │ +│ │ +│ Primary implementation: `computeTurnGroupsFromMessages()` in │ +│ `turn-group-list.tsx`. Must run SYNCHRONOUSLY on every render │ +│ (via `useTurnGroupsView`) — no async queries, no stale │ +│ windows, no server round-trips for the view. The server-side │ +│ `GetChatTurnGroupsUseCase` mirrors this logic for external │ +│ API consumers (CLI, TUI) but the web MUST compute locally. │ +│ │ +│ Other grouping families (setup workflow, operation bubbles, │ +│ interactions) each have their own pure derivation. They are │ +│ siblings of turn groups in Layer 2, not alternatives. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER 3 — HIGH-LEVEL UI (default view, collapsed, friendly) │ +│ │ +│ Groups render as collapsible cards in a single chronological │ +│ column. DEFAULT state is the friendly high-level summary with │ +│ raw events HIDDEN. The user-facing label is non-technical │ +│ ("Working on: Fix login bug", "Initial setup complete"). │ +│ │ +│ Expanding a card reveals its owned raw events inline, in the │ +│ same chronological order the raw stream had them. Nothing │ +│ else changes position — expansion is purely local to the card. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Mandatory Rules + +### 1. Single Chronological Timeline +- The chat is ONE vertical column. No pinned-at-top, no pinned-at-bottom + sections, no split between `beforeMessages` and `afterMessages` for + chat content. Groups render in strict chronological order of their + `startedAt` timestamp, top to bottom. +- The composer is the only exception — it lives outside the timeline + because it's an input, not a historical event. + +### 2. Layer 1 is Never Rendered Directly +- The flat thread (assistant-ui `Thread` message list) is DEAD when + turn groups are enabled. `useChatRuntime` is called with + `hideAllMessages: true` and returns `threadMessages: []`. +- Raw bubbles only appear as children of a Layer 3 card. +- Never render a raw message via `` / `` + directly in the chat pane. + +### 3. Layer 2 is Pure and Synchronous +- Grouping must be a pure function over `rawMessages`. No fetch, no + `useQuery`, no setTimeout, no event listeners. Use `useMemo`. +- Stale-query races are forbidden. If you feel tempted to add a + server query to drive the view, you are breaking this rule. +- The server-side grouping use case stays for CLI/API consumers but + the web UI computes locally — always. + +### 4. Default is Collapsed High-Level, Raw Events are Progressive Disclosure +- Completed groups render with an emerald check icon and a friendly + title. Children (the raw events) are hidden behind a click. +- In-progress groups render expanded with the live streaming + indicator inside them — the user sees progress as it happens but + the outer frame is still the high-level card, not a flat list. +- Titles must be non-technical. "Working on: " is good. "Assistant generating response to message id X" is + not. + +### 5. Optimistic Rendering +- Sending a message must produce a visible in-progress group in the + same render tick. That means the grouping layer reads from the + chat-state cache which is mutated optimistically by the send + mutation — no await, no round-trip. +- The current-turn card updates IN PLACE as new raw events arrive; + it does not unmount and remount per-event. + +### 6. Raw Events May Be Extended (System / Debug / Tool) +- Layer 1 is not restricted to `interactive_messages` rows. System + announcements, debug events, tool-call metadata, and workflow-step + events all belong to the same chronological stream. +- When adding a new event source, plumb it into the raw feed in a + way that preserves chronological order (merge-by-timestamp). Do + NOT render it as a flat bubble — it must flow through a Layer 2 + grouping and appear inside a Layer 3 card like everything else. + +### 7. No Competing Rendering Paths +- One chat pane, one timeline, one composer. If you find yourself + adding a second rendering surface for chat content (another + drawer, a secondary list, a parallel component), STOP — it is a + Layer 3 card that belongs inside the existing timeline. +- `OperationBubble`, `InteractionBubble`, `StepTracker`, and + `TurnGroupCard` are all Layer 3 card types. Their order in the + column is chronological. + +### 8. File Responsibilities +- `useChatRuntime.ts` — Layer 1 facade. Exposes `rawMessages` and + `streamingState`. Returns `threadMessages: []` when the host + enables `hideAllMessages`. Never grows UI concerns. +- `turn-group-list.tsx` — Layer 2 grouping logic + Layer 3 renderers + for user-turn cards. Pure `computeTurnGroupsFromMessages` is the + single source of truth for turn derivation. +- `turn-group-card.tsx` — Layer 3 card primitive. Knows nothing + about messages; takes pre-baked props (`title`, `status`, children). +- `StepTracker.tsx` — Layer 3 card for the setup workflow. +- `operation-bubble.tsx` — Layer 3 card for publish/deploy operations. +- `ChatTab.tsx` — Composes the timeline. Calls `useChatRuntime` once + with `hideAllMessages: turnGroupsEnabled`, calls `useTurnGroupsView` + over `rawMessages`, and renders all Layer 3 cards in chronological + order inside `beforeMessages`. `afterMessages` is only used by + non-grouping (repo / global) chats. + +## What Breaks These Rules (Anti-Patterns) + +- Rendering a raw message directly in the chat pane "just for this + one case" +- Adding a `useQuery` for grouping view data in the web UI +- Pinning a group at the top or bottom regardless of its timestamp +- Letting the flat thread coexist with the grouping overlay — they + must not both be visible +- Hiding only SOME raw messages from the flat thread (half-open + overlay) — it's all-or-nothing via `hideAllMessages` +- Introducing a parallel "chat timeline" component that bypasses + the three-layer flow +- Surfacing technical jargon ("tool call", "step_id", "token count") + in the default collapsed card title. Raw events may contain it; + high-level labels must not. + +## Extending the Chat + +When adding a new feature that touches the chat surface, walk the +layers in order: + +1. **Does it add a new kind of raw event?** Plumb it into Layer 1 + via the chat-state cache or a sibling event stream. Ensure + chronological merging. +2. **Does it need grouping?** Add a pure derivation in Layer 2. No + async, no query, no stale-prone fetching. +3. **Does it need a new UI card type?** Add a Layer 3 card and + render it in the `ChatTab` timeline at its chronological + position. + +Never skip a layer. Never render Layer 1 directly. Never let Layer 2 +escape into a query. Never let Layer 3 own derivation logic. diff --git a/src/presentation/web/components/features/chat/ChatTab.tsx b/src/presentation/web/components/features/chat/ChatTab.tsx index 818a2cf5b..4cd0e111d 100644 --- a/src/presentation/web/components/features/chat/ChatTab.tsx +++ b/src/presentation/web/components/features/chat/ChatTab.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AssistantRuntimeProvider } from '@assistant-ui/react'; -import { Trash2, Cpu, User } from 'lucide-react'; +import { Trash2, Cpu, AlertTriangle, RefreshCw } from 'lucide-react'; import type { ChatState } from '@shepai/core/application/ports/output/services/interactive-session-service.interface'; import { cn } from '@/lib/utils'; import { Thread } from '@/components/assistant-ui/thread'; @@ -14,7 +14,7 @@ import { useChatRuntime } from './useChatRuntime'; import { ChatComposer } from './ChatComposer'; import { InteractionBubble } from './InteractionBubble'; import { StepTracker } from './StepTracker'; -import { TurnGroupList, useTurnGroups } from './turn-group-list'; +import { CurrentTurnCard, CompletedTurnGroupsList, useTurnGroupsView } from './turn-group-list'; import { OperationBubble } from './operation-bubble'; import type { PlaceholderStep } from './workflow-placeholder'; import { SCAFFOLD_STEP_KEY } from './workflow-placeholder'; @@ -82,6 +82,14 @@ export interface ChatTabProps { * broader context. */ scaffoldingState?: ScaffoldingState; + /** + * When truthy, renders a prominent error recovery banner above the + * turn card + step tracker. Used to surface `effectiveStatus` + * failures that would otherwise leave the user staring at a chat + * with a red status pill and no explanation. The `onRetry` prop + * wires a "Try again" button — typically `onResumeWorkflow`. + */ + applicationError?: ApplicationErrorState | null; } /** @@ -101,6 +109,15 @@ export interface ScaffoldingState { error?: string; } +export interface ApplicationErrorState { + /** Short, human-readable kind: "Setup failed", "Interrupted", etc. */ + kind: string; + /** Longer explanation shown below the headline. */ + message: string; + /** True if the backend can re-run the failed pipeline. */ + retryable: boolean; +} + const IS_DEV = process.env.NODE_ENV === 'development'; /** @@ -138,19 +155,19 @@ export function ChatTab({ workflowPlaceholder, onResumeWorkflow, scaffoldingState, + applicationError, }: ChatTabProps) { const [overrideAgent, setOverrideAgent] = useState(initialAgent); const [overrideModel, setOverrideModel] = useState(initialModel); const [debugMode, setDebugMode] = useState(false); const att = useAttachments(); - // Server-derived turn grouping (Feature B). Only applicable to - // application-scoped chats — repo / global threads keep the flat - // layout. `hiddenMessageIds` is handed to useChatRuntime so the - // raw bubbles that now live inside a collapsed group card are - // removed from the thread. + // Turn-group overlay is enabled for application-scoped chats. + // Groups are computed CLIENT-SIDE from `rawMessages` (see the + // `useTurnGroupsView` call below) so the view is always + // optimistic against the chat-state cache — no stale-query race + // window where the flat thread would briefly leak raw bubbles. const turnGroupsEnabled = Boolean(applicationId); - const { hiddenMessageIds } = useTurnGroups(turnGroupsEnabled ? featureId : ''); const contentTransform = useCallback( (content: string) => @@ -170,7 +187,6 @@ export function ChatTab({ pendingInteraction, respondToInteraction, stepProgress, - initialRequestMessage, rawMessages, streamingState, } = useChatRuntime(featureId, worktreePath, { @@ -180,21 +196,27 @@ export function ChatTab({ agentType: overrideAgent, debugMode, initialChatState, - // When the host provides a workflow placeholder we KNOW the tracker - // will be the primary surface and the first user message must be - // pinned ABOVE it — even during the early window before the first - // real workflow-step row arrives. Without this, the bubble would - // fall through to the flat thread and render below the tracker. + // Legacy pinInitialRequest flag is only relevant for the + // non-grouping path (repo/global chats with a workflow + // placeholder). When turn groups are on we take the full + // overlay path via `hideAllMessages` instead. pinInitialRequest: (workflowPlaceholder?.length ?? 0) > 0, - // Server-derived completed turns get pulled out of the flat - // thread and rendered as collapsed cards above the messages. - hiddenMessageIds, - // When grouping is on, the in-progress turn card owns the - // streaming indicator — suppress the flat-thread version so it - // doesn't appear twice. + // Full overlay path: hide ALL persisted bubbles from the flat + // thread and let the grouping layer own the entire visible + // conversation. The streaming indicator is pinned inside the + // in-progress turn card so we also suppress its flat version. + hideAllMessages: turnGroupsEnabled, suppressStreamingIndicator: turnGroupsEnabled, }); + // Client-side turn grouping — re-runs synchronously on every + // `rawMessages` change, so a new user bubble or assistant reply + // is reflected in the overlay in the same render tick. + // Only mark the latest turn as in-progress while the agent is + // actually running — otherwise a finished conversation would + // leave a permanent "Working on your request…" fuchsia card. + const turnGroupsView = useTurnGroupsView(rawMessages, status.isRunning); + // Fire the all-steps-complete callback exactly once per mount. // Using a ref (not a dependency) prevents re-firing if the parent // passes a new callback identity on every render. @@ -374,47 +396,69 @@ export function ChatTab({ hideEmpty={showTracker} beforeMessages={ <> + {/* Chronological timeline — top to bottom: + 1. Error recovery banner (when broken) + 2. Setup workflow (StepTracker) + 3. Completed user turns, oldest first + 4. In-progress turn (always chronologically last) + 5. Operation bubbles (publish / deploy) + 6. Pending interaction (awaiting user input) + All rendered in `beforeMessages` because the flat + thread below is dead — `hideAllMessages` zeroes + the persisted bubble list when turn groups are on, + so the overlay owns the entire visible surface + and there's no split between before/after. */} + {applicationError ? ( + + ) : null} {showTracker ? ( + + ) : null} + {turnGroupsEnabled ? ( <> - {initialRequestMessage ? ( - - ) : null} - + + {applicationId ? ( + <> + + + + ) : null} + {pendingInteraction ? ( + + ) : null} ) : null} - {turnGroupsEnabled ? ( - - ) : null} } afterMessages={ - <> - {pendingInteraction ? ( - - ) : null} - {applicationId ? ( - <> - - - - ) : null} - + // Non-grouping chats (repo/global) keep the legacy + // InteractionBubble surface in `afterMessages`. + // When turn groups are on, everything moved into + // the unified `beforeMessages` timeline above and + // this slot stays empty. + !turnGroupsEnabled && pendingInteraction ? ( + + ) : null } /> @@ -424,23 +468,73 @@ export function ChatTab({ ); } -// ── Initial request bubble ────────────────────────────────────────────────── +// ── Error recovery banner ─────────────────────────────────────────────────── // -// A lightweight user-message lookalike that the host pane renders -// above the step tracker. We don't go through the full assistant-ui -// `UserMessage` component because this bubble lives OUTSIDE the -// Thread's message iteration — it's a purely presentational anchor -// for the original ask that kicked off the workflow. Visuals match -// the real user bubble in `thread.tsx` so the swap is invisible. -function InitialRequestBubble({ text }: { text: string }) { +// Rendered at the very top of the chat pane when the application is +// in a broken state (setup failed, interrupted, etc.). Replaces +// silent failure where the only signal was a red "ERROR" pill in the +// top bar. Gives the user a clear headline, an explanation, and — if +// the backend says the operation is retryable — a prominent +// "Try again" button wired to `onResumeWorkflow`. +function ErrorRecoveryBanner({ + state, + onRetry, +}: { + state: ApplicationErrorState; + onRetry?: () => void; +}) { + const [retrying, setRetrying] = useState(false); + const handleRetry = useCallback(() => { + if (!onRetry || retrying) return; + setRetrying(true); + try { + onRetry(); + } finally { + // `onRetry` is fire-and-forget (POSTs the resume endpoint). + // Clear the local spinner after a short window so the button + // doesn't stay locked if the parent forgot to refresh state. + setTimeout(() => setRetrying(false), 4000); + } + }, [onRetry, retrying]); + return ( -
-
- -
-
-
- {text} +
+
+ + + +
+
+ {state.kind} +
+
+ {state.message} +
+ {state.retryable && onRetry ? ( +
+ + + Re-runs the last failed step + +
+ ) : null}
diff --git a/src/presentation/web/components/features/chat/turn-group-card.tsx b/src/presentation/web/components/features/chat/turn-group-card.tsx index 117e2b2a8..7d42324ea 100644 --- a/src/presentation/web/components/features/chat/turn-group-card.tsx +++ b/src/presentation/web/components/features/chat/turn-group-card.tsx @@ -3,20 +3,21 @@ /** * TurnGroupCard * - * One user-turn card in the chat thread. Two modes: + * One user-turn card in the chat timeline. Two modes: * - * - `completed`: collapsed by default, emerald check icon, click to - * reveal the persisted messages inside the turn. Matches the - * StepTracker visual language so "completed setup" and "completed - * user turn" read as siblings in the timeline. + * - `completed`: collapsed by default, emerald check icon, click + * the chevron to reveal raw bubbles. * - * - `in-progress`: EXPANDED by default, non-collapsible, a spinning - * fuchsia indicator, title reads "Working on your request…". The - * parent renders the turn's persisted messages AND the live - * streaming indicator inside `children`, so the moment the user - * sends a message they see a new card pop in with the reply - * building up inside it — no stray "Thinking…" bubble in the - * flat thread. + * - `in-progress`: the card's DEFAULT surface is the `condensed` + * slot — typically the user's request plus a friendly streaming + * indicator ("Working on…"), never raw tool events. The chevron + * progressively discloses the `details` slot containing every + * raw bubble (thinking / read / output / assistant text). + * + * This preserves the layered rule from `CLAUDE.md` in this + * directory: by default the chat shows a high-level friendly + * surface with the user request visible and nothing else raw. + * Raw events are hidden behind a single click. */ import { useState, type ReactNode } from 'react'; @@ -32,8 +33,14 @@ export interface TurnGroupCardProps { assistantMessageCount: number; /** Render mode — see file header. */ status: 'completed' | 'in-progress'; - /** Children rendered when the card is expanded (raw messages + - * the live streaming indicator for in-progress turns). */ + /** Default visible content for in-progress cards (user message + * + friendly streaming indicator). Ignored for completed cards. */ + condensed?: ReactNode; + /** Progressive-disclosure body revealed when the chevron is + * toggled — raw bubbles, tool events, full history. Falls back + * to `children` if not provided. */ + details?: ReactNode; + /** Legacy slot used by completed cards — equivalent to `details`. */ children?: ReactNode; } @@ -42,14 +49,14 @@ export function TurnGroupCard({ title, assistantMessageCount, status, + condensed, + details, children, }: TurnGroupCardProps) { const isInProgress = status === 'in-progress'; - // In-progress cards are always expanded; completed cards collapse - // by default and only open on explicit click. const [userExpanded, setUserExpanded] = useState(false); - const expanded = isInProgress || userExpanded; const contentId = `${id}-content`; + const disclosureBody = details ?? children ?? null; return (
- {expanded ? ( + + {/* Default surface for in-progress cards — user request + + friendly streaming indicator. Never raw tool events. + Hidden while the chevron is expanded so the `details` + body below becomes the single source of content and the + user message doesn't render twice. */} + {isInProgress && condensed && !userExpanded ? ( +
{condensed}
+ ) : null} + + {/* Progressive disclosure: raw bubbles hidden until the + chevron is clicked. */} + {userExpanded ? (
- {children ??
No content.
} + {disclosureBody ?? ( +
No content.
+ )}
) : null}
diff --git a/src/presentation/web/components/features/chat/turn-group-list.tsx b/src/presentation/web/components/features/chat/turn-group-list.tsx index 7cd34023e..1a33ac46b 100644 --- a/src/presentation/web/components/features/chat/turn-group-list.tsx +++ b/src/presentation/web/components/features/chat/turn-group-list.tsx @@ -3,24 +3,26 @@ /** * TurnGroupList * - * Fetches the server-derived list of user turns for a feature and - * renders a `TurnGroupCard` per completed group PLUS an in-progress - * card for the currently-live turn. The in-progress card is expanded - * by default and receives the live streaming indicator inline, so - * the moment the user sends a message they see a single "Working - * on your request…" surface with the reply building up inside it. + * Renders user-turn groups as collapsible cards in chronological + * order. Grouping is computed CLIENT-SIDE from the raw messages + * exposed by `useChatRuntime` so the view is always optimistic: + * the moment a new message hits the chat-state cache via SSE, the + * useMemo re-runs and the card appears — no stale-query race + * window where the flat thread briefly shows raw bubbles. * - * All grouping logic lives in `GetChatTurnGroupsUseCase` — this - * component is a thin renderer. + * The server-side `GetChatTurnGroupsUseCase` still owns the + * canonical grouping shape and is used by other clients (CLI, TUI, + * external API consumers); the web keeps a second implementation + * here only to avoid the network round-trip for its own render. */ -import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { Loader2 } from 'lucide-react'; import type { InteractiveMessage } from '@shepai/core/domain/generated/output'; import { InteractiveMessageRole } from '@shepai/core/domain/generated/output'; import { TurnGroupCard } from './turn-group-card'; -interface TurnGroupDto { +interface TurnGroupView { id: string; title: string; userMessagePreview: string; @@ -31,22 +33,115 @@ interface TurnGroupDto { status: 'completed' | 'in-progress'; } -export interface TurnGroupsResponse { - groups: TurnGroupDto[]; - currentTurn: TurnGroupDto | null; +export interface TurnGroupsView { + groups: TurnGroupView[]; + currentTurn: TurnGroupView | null; hiddenMessageIds: string[]; } -export function turnGroupsQueryKey(featureId: string): readonly unknown[] { - return ['chat', featureId, 'turn-groups'] as const; +const PREVIEW_MAX = 120; +const TITLE_MAX = 140; +const FALLBACK_TITLE = 'Working on your request'; + +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); } -export async function fetchTurnGroups(featureId: string): Promise { - const res = await fetch(`/api/interactive/chat/${encodeURIComponent(featureId)}/turn-groups`); - if (!res.ok) { +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; +} + +/** + * Client-side turn grouping — mirrors + * `GetChatTurnGroupsUseCase` so the web view has no stale-query + * window. Step-tagged messages are ignored (they live inside the + * StepTracker). Every non-step-tagged user message opens a new + * turn; the subsequent non-step assistant messages attach to it + * until the next user message. + * + * The LATEST turn becomes `currentTurn` (status `in-progress`) + * ONLY when `isAgentBusy` is true — i.e. the chat runtime reports + * live streaming, tool activity, or a pending-response awaiting + * state. Otherwise the latest turn is demoted to `completed` so a + * finished conversation doesn't leave a permanent "Working on + * your request…" card pretending work is still happening. + */ +export function computeTurnGroupsFromMessages( + messages: readonly InteractiveMessage[], + isAgentBusy = true +): TurnGroupsView { + const flat = messages.filter((m) => !m.stepId); + if (flat.length === 0) { return { groups: [], currentTurn: null, hiddenMessageIds: [] }; } - return (await res.json()) as TurnGroupsResponse; + + 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 (turns.length === 0) { + return { groups: [], currentTurn: null, hiddenMessageIds: [] }; + } + + const toView = (t: Turn, status: 'completed' | 'in-progress'): TurnGroupView => { + const first = t.items[0]; + const last = t.items[t.items.length - 1]; + const userText = t.user.content ?? ''; + 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: t.items.filter((m) => m.role === InteractiveMessageRole.assistant) + .length, + startedAt: toEpochMs(first.createdAt), + endedAt: toEpochMs(last.createdAt), + status, + }; + }; + + const latest = turns[turns.length - 1]; + if (isAgentBusy) { + // Agent is actively working — the latest turn is the live one + // and owns the in-progress fuchsia card. + const completed = turns.slice(0, -1).map((t) => toView(t, 'completed')); + const currentTurn = toView(latest, 'in-progress'); + const hiddenMessageIds = [...completed.flatMap((g) => g.messageIds), ...currentTurn.messageIds]; + return { groups: completed, currentTurn, hiddenMessageIds }; + } + + // Agent is idle — every turn (including the most recent) is + // completed. No in-progress card; the timeline stays chronological + // with the latest turn at the tail as a collapsible emerald card. + const completed = turns.map((t) => toView(t, 'completed')); + const hiddenMessageIds = completed.flatMap((g) => g.messageIds); + return { groups: completed, currentTurn: null, hiddenMessageIds }; } /** Live streaming state the in-progress card pins at its bottom edge. */ @@ -62,46 +157,117 @@ export interface TurnStreamingState { } /** - * Hook returning both the raw groups data (for filtering the thread - * upstream) and the list of hidden message ids. Kept here alongside - * the component so the single query is shared — ChatTab calls this - * hook too and TanStack dedupes on the shared query key. + * useTurnGroupsView — the client-side grouping hook consumed by + * `CurrentTurnCard` and `CompletedTurnGroupsList`. It folds the raw + * message stream into the same shape the server use case returns, + * but runs on every render so the UI is always optimistic against + * the chat-state cache (no stale-query window). + * + * `isAgentBusy` controls whether the latest turn gets promoted to + * `currentTurn` (in-progress fuchsia card) or stays as the last + * `completed` card in the chronological list. Pass the chat + * runtime's `status.isRunning` flag so finished conversations + * never leave a stale "Working on your request…" surface pinned. */ -export function useTurnGroups(featureId: string): TurnGroupsResponse { - const enabled = featureId.trim().length > 0; - const { data } = useQuery({ - queryKey: turnGroupsQueryKey(featureId), - queryFn: () => fetchTurnGroups(featureId), - // Short stale window so the card appears fast when the user - // sends a message — the chat-state refetch triggered by the - // mutation will bump this in practice, but the low staleTime - // is a belt-and-braces fallback. - staleTime: 1_000, - refetchOnWindowFocus: false, - enabled, - }); - return data ?? { groups: [], currentTurn: null, hiddenMessageIds: [] }; +export function useTurnGroupsView( + messages: readonly InteractiveMessage[], + isAgentBusy: boolean +): TurnGroupsView { + return useMemo( + () => computeTurnGroupsFromMessages(messages, isAgentBusy), + [messages, isAgentBusy] + ); } -export interface TurnGroupListProps { - featureId: string; - /** Raw messages from the main chat query — used to hydrate cards. */ +export interface CurrentTurnCardProps { + /** Pre-computed groups view (from `useTurnGroupsView`). */ + view: TurnGroupsView; + /** Raw messages, used to hydrate the card children. */ allMessages: readonly InteractiveMessage[]; - /** Live streaming state for the in-progress card. */ + /** Live streaming state pinned at the bottom of the card. */ streaming: TurnStreamingState; } -export function TurnGroupList({ featureId, allMessages, streaming }: TurnGroupListProps) { - const { groups, currentTurn } = useTurnGroups(featureId); - if (groups.length === 0 && !currentTurn) return null; +/** + * The in-progress "Working on your request…" card for the latest + * user turn. Always renders at the chronological tail of the + * timeline — never pinned at the top — so the conversation reads + * top-to-bottom: setup → completed turns → current turn. + */ +export function CurrentTurnCard({ view, allMessages, streaming }: CurrentTurnCardProps) { + if (!view.currentTurn) return null; + const byId = new Map(); + for (const m of allMessages) byId.set(m.id, m); + + // Default surface: ONLY the user message + a high-level friendly + // streaming indicator. Raw assistant / tool bubbles never appear + // here — that's the rule in features/chat/CLAUDE.md. They live + // behind the chevron inside `details`. + const userMessageId = view.currentTurn.messageIds.find((mid) => { + const m = byId.get(mid); + return m?.role === InteractiveMessageRole.user; + }); + const userMessage = userMessageId ? byId.get(userMessageId) : null; + + const condensed = ( +
+ {userMessage ? ( +
+
+ You +
+ {userMessage.content} +
+ ) : null} + {/* Condensed indicator — never the raw assistant stream. Only + a high-level activity line (tool status, "Thinking…", or + "Agent is waking up…") so the card obeys the layer rule: + by default the card shows only the user request and a + friendly live-activity line. Expand the chevron to see the + raw assistant content. */} + +
+ ); + + const details = ( +
+ + +
+ ); + + return ( + + ); +} + +export interface CompletedTurnGroupsListProps { + /** Pre-computed groups view (from `useTurnGroupsView`). */ + view: TurnGroupsView; + /** Raw messages, used to hydrate card children. */ + allMessages: readonly InteractiveMessage[]; +} - // Index messages by id once so every card's children are O(1) lookups. +/** + * The list of COMPLETED, collapsed-by-default turn group cards. + * Rendered chronologically between the StepTracker and the + * in-progress card so the whole conversation reads top-to-bottom. + */ +export function CompletedTurnGroupsList({ view, allMessages }: CompletedTurnGroupsListProps) { + if (view.groups.length === 0) return null; const byId = new Map(); for (const m of allMessages) byId.set(m.id, m); return (
- {groups.map((g) => ( + {view.groups.map((g) => ( ))} - {currentTurn ? ( - -
- - -
-
- ) : null}
); } @@ -213,3 +365,38 @@ function StreamingIndicator({ streaming }: { streaming: TurnStreamingState }) { return null; } + +/** + * High-level activity line rendered inside the in-progress turn + * card's DEFAULT (collapsed) surface. Strict rule: never show the + * raw assistant stream — only a spinner plus a friendly status + * (tool activity, "Thinking…", or "Working…"). Raw assistant + * content lives behind the chevron. See `CLAUDE.md` in this + * directory for the layered contract. + */ +function CondensedStreamingIndicator({ streaming }: { streaming: TurnStreamingState }) { + const hasStatus = (streaming.statusLog ?? '').trim().length > 0; + const hasText = streaming.text.trim().length > 0; + const isBooting = streaming.sessionStatus === 'booting'; + + // Fallback label priority: tool status → "Thinking…" while + // awaiting the first delta / booting → "Working…" whenever raw + // text is streaming but we refuse to show it here. + let label: string; + if (hasStatus) { + label = streaming.statusLog ?? 'Working…'; + } else if (streaming.awaiting || isBooting) { + label = isBooting ? 'Agent is waking up…' : 'Thinking…'; + } else if (hasText) { + label = 'Working…'; + } else { + return null; + } + + return ( +
+ + {label} +
+ ); +} diff --git a/src/presentation/web/components/features/chat/useChatRuntime.ts b/src/presentation/web/components/features/chat/useChatRuntime.ts index 64f7bb0f4..919eeba29 100644 --- a/src/presentation/web/components/features/chat/useChatRuntime.ts +++ b/src/presentation/web/components/features/chat/useChatRuntime.ts @@ -222,6 +222,16 @@ export interface ChatRuntimeOptions { * typically inside an in-progress turn card owned by the server. */ suppressStreamingIndicator?: boolean; + /** + * When true, `threadMessages` is returned as an empty list — the + * flat thread renders no persisted bubbles at all. The host is + * expected to render the full conversation via its own grouping + * layer (e.g. `CurrentTurnCard` + `CompletedTurnGroupsList`) using + * `rawMessages`. Stable contract used by the web UI when turn-group + * rendering is on: the flat thread is dead, the overlay is the + * only visible surface. + */ + hideAllMessages?: boolean; } /** A debug event captured from SSE for display in debug mode. */ @@ -748,6 +758,17 @@ export function useChatRuntime( ); const threadMessages: ThreadMessageLike[] = useMemo(() => { + // Short-circuit: the host is rendering a full grouping overlay + // (turn group cards + step tracker + operation bubbles) and + // does NOT want any raw persisted bubbles to appear in the + // flat thread. Return an empty list so no message from the + // chat-state cache leaks into the Thread body; the composer + // and pending-interaction surfaces continue to work because + // they live OUTSIDE `threadMessages`. + if (options?.hideAllMessages === true) { + return []; + } + const hasPlan = stepProgress.hasPlan; // When a workflow is active and STILL RUNNING: hide stepless @@ -893,6 +914,7 @@ export function useChatRuntime( statusLog, options?.debugMode, options?.suppressStreamingIndicator, + options?.hideAllMessages, debugEvents, stepProgress.hasPlan, stepProgress.allDone, From aa61257b2c3dbddfe2c4dc486ed648b516dc4706 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Wed, 15 Apr 2026 18:10:59 +0300 Subject: [PATCH 6/7] feat(web): zero-brain get online with unified logs and detached icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalises the one-click "Get online" pipeline, detaches the activity log button from the split, and hardens the unified logs drawer against hung requests. - handleGetOnline is fully zero-brain: ensure owners, create the github remote with the default owner plus slugified name, pick the first connected cloud provider, fire deploy. The ONLY stop conditions are missing tokens — github connect goes to the PublishToGitHub modal, missing cloud provider opens the ConnectProviderModal on the first enabled provider. No toasts, no panel side-trips, one click equals one specific next action - activity log icon button is detached from the SmartDeployButton split, rendered as a standalone 28x28 muted icon next to the primary action so it reads as a neighbour, not a welded third segment - SmartDeployLogsDrawer gains AbortController per-request timeouts (6s), splits hasLoadedOnce from background polling so the "Loading logs..." spinner only appears on first fetch, adds a manual Refresh button, and surfaces per-scope errors as a soft banner while still rendering the entries that did come back — no more stuck-on-loading drawer - Drawer empty state distinguishes "could not load" from "no activity yet" so the user knows whether to retry or just wait - configure state kind removed from use-smart-deploy-state — the button stays "Get online" in every pre-live state, the pipeline itself handles the token gates inline Co-Authored-By: Shep Bot --- .../application-page/smart-deploy-button.tsx | 27 --- .../application-page/smart-deploy-cluster.tsx | 154 ++++++++++-------- .../smart-deploy-logs-drawer.tsx | 91 ++++++++--- .../web/hooks/use-smart-deploy-state.ts | 7 +- 4 files changed, 159 insertions(+), 120 deletions(-) diff --git a/src/presentation/web/components/features/application-page/smart-deploy-button.tsx b/src/presentation/web/components/features/application-page/smart-deploy-button.tsx index 1cc3fc3ff..f541bf6fc 100644 --- a/src/presentation/web/components/features/application-page/smart-deploy-button.tsx +++ b/src/presentation/web/components/features/application-page/smart-deploy-button.tsx @@ -21,7 +21,6 @@ import { RefreshCw, Rocket, Save, - ScrollText, Sparkles, } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -40,10 +39,6 @@ export interface SmartDeployButtonProps { * path instead of committing to a specific action. */ panelOpen?: boolean; onPanelOpenChange?(open: boolean): void; - /** Opens the unified Smart Deploy activity log drawer. When omitted - * the log button segment is hidden — kept optional so the - * component still works in storybook without the drawer wired up. */ - onOpenLogs?(): void; className?: string; } @@ -195,7 +190,6 @@ export function SmartDeployButton({ panel, panelOpen: controlledPanelOpen, onPanelOpenChange, - onOpenLogs, className, }: SmartDeployButtonProps) { const spec = labelFor(state); @@ -295,27 +289,6 @@ export function SmartDeployButton({ {panel} - - {/* ── Right-most: unified activity log trigger ────────────── - Persistent, always visible next to the Smart Deploy surface - so the user can read the full cross-operation timeline - (GitHub + Cloud + Sync) in one place without hunting for a - context-specific button inside the panel. */} - {onOpenLogs ? ( - - ) : null}
); } diff --git a/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx b/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx index 8ca4507c4..b3ee0b993 100644 --- a/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx +++ b/src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx @@ -10,6 +10,7 @@ */ import { useCallback, useEffect, useState } from 'react'; +import { ScrollText } from 'lucide-react'; import { toast } from 'sonner'; import { CloudDeploymentProvider, type Application } from '@shepai/core/domain/generated/output'; import type { useCloudDeployAction } from '@/hooks/use-cloud-deploy-action'; @@ -254,88 +255,89 @@ export function SmartDeployCluster({ const handleGetOnline = useCallback(async () => { if (oneClickRunning || agentRunning) return; setOneClickRunning(true); - // Open the unified activity drawer IMMEDIATELY so the user - // never stares at a silently-reverting button. Every server-side - // log entry that the pipeline writes streams into the drawer - // live via its 1.5s poll. - setLogsOpen(true); - let didAnyWork = false; + + // Zero-brain policy: the pipeline defaults everything and only + // stops if a TOKEN is genuinely required. In both "no GitHub" + // and "no cloud provider" cases we open the appropriate inline + // connect modal — NOT the panel, NOT a toast — so the user's + // next click is literally "paste token and hit Connect". On + // token submission the state recomputes and the next click runs + // the pipeline end-to-end. + // + // The activity drawer opens only when real work is about to + // run, so token-prompt paths don't flash an empty "No activity + // yet" drawer on the user's screen. try { - // 1. Ensure owners loaded — needed to pick a default owner - // for the create-remote call below. await ensureOwners(); - // 2. Create the GitHub remote if we don't have one yet. We - // re-read `gitStatus` defensively in case it loaded between - // user intent and click. + // 1. GitHub token gate. Empty owners ⇒ no token on file. + // The publish modal owns the GitHub connect + owner-picker + // flow so we defer to it here; once the user submits, + // owners repopulate and the next Get Online click runs. + const ownersSnapshot = owners; + if (!ownersSnapshot || ownersSnapshot.length === 0) { + setPublishModalOpen(true); + return; + } + + // 2. Cloud provider token gate. Pick the first enabled + // provider; if none of them are connected we need a token. + // Default target = first enabled provider (typically + // Cloudflare Pages). The panel chevron still lets the + // user pick a different one if they prefer. + const connectedProvider = providers.find((p) => p.enabled && p.connected); + if (!connectedProvider) { + const defaultProvider = providers.find((p) => p.enabled) ?? null; + if (defaultProvider) { + setConnectMode('connect'); + setConnectingProvider(defaultProvider.id); + return; + } + // Provider catalog is empty — edge case, fall back to the + // panel so the user can see SOMETHING actionable. + setPanelOpen(true); + return; + } + + // 3. Both tokens are on file → open the activity drawer and + // run the full pipeline end-to-end with defaults. + setLogsOpen(true); + const hasRemoteNow = gitStatus?.hasRemote === true || Boolean(application.gitRemoteUrl); if (!hasRemoteNow) { - const ownersSnapshot = owners; - const firstOwner = ownersSnapshot && ownersSnapshot.length > 0 ? ownersSnapshot[0] : null; - if (!firstOwner) { - // No GitHub connection at all — we cannot create a remote - // automatically. Surface the gap with a toast so the user - // knows WHY nothing happened, and open the panel so they - // can hit "Connect GitHub" in one click. - toast.error('Connect GitHub first', { - description: - 'One-click Get online needs a GitHub connection to create the remote. Open the panel and connect GitHub.', + const firstOwner = ownersSnapshot[0]; + const defaultRepoName = application.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + try { + const res = await fetch(`/api/applications/${application.id}/git/create-remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ownerLogin: firstOwner.login, + repoName: defaultRepoName, + visibility: 'private', + }), }); - setPanelOpen(true); - } else { - const defaultRepoName = application.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - try { - const res = await fetch(`/api/applications/${application.id}/git/create-remote`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ownerLogin: firstOwner.login, - repoName: defaultRepoName, - visibility: 'private', - }), - }); - didAnyWork = true; - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string }; - toast.error('GitHub publish failed', { - description: body.error ?? `HTTP ${res.status}`, - }); - } - } catch (err) { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; toast.error('GitHub publish failed', { - description: err instanceof Error ? err.message : 'Network error', + description: body.error ?? `HTTP ${res.status}`, }); } - await refreshGitStatus(); - } - } - - // 3. Auto-deploy to the first connected cloud provider. - const connected = providers.find((p) => p.enabled && p.connected); - if (connected) { - if (!cloudDeploy.state.provider || cloudDeploy.state.provider !== connected.id) { - await cloudDeploy.selectProvider(connected.id); + } catch (err) { + toast.error('GitHub publish failed', { + description: err instanceof Error ? err.message : 'Network error', + }); } - await cloudDeploy.initiate(); - didAnyWork = true; - } else { - // No cloud provider connected — surface the gap with a toast - // and open the panel. The repo half may have already run. - toast.error('Connect a hosting provider', { - description: - 'One-click Get online needs a connected cloud provider to deploy. Pick one from the panel.', - }); - setPanelOpen(true); + await refreshGitStatus(); } - if (!didAnyWork) { - // Nothing actually ran — close the drawer again so the user - // isn't staring at an empty "No activity yet" state. - setLogsOpen(false); + if (!cloudDeploy.state.provider || cloudDeploy.state.provider !== connectedProvider.id) { + await cloudDeploy.selectProvider(connectedProvider.id); } + await cloudDeploy.initiate(); } finally { setOneClickRunning(false); } @@ -439,12 +441,24 @@ export function SmartDeployCluster({ return ( <> + {/* Standalone icon-only activity-log button, detached from the + Smart Deploy split. Small, borderless, sits next to the main + surface so the user can always read the unified log without + the log affordance feeling welded to the deploy action. */} + { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + export function SmartDeployLogsDrawer({ open, onOpenChange, @@ -119,33 +132,52 @@ export function SmartDeployLogsDrawer({ subtitle, }: SmartDeployLogsDrawerProps) { const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(false); + // `hasLoadedOnce` splits "first load" from "background poll": the + // "Loading logs…" spinner only shows while we've never completed a + // fetch, so subsequent 1.5s polls don't flash the user back to a + // loading state each tick when entries legitimately stay empty. + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const [backgroundFetching, setBackgroundFetching] = useState(false); const [error, setError] = useState(null); const [showDebug, setShowDebug] = useState(false); const bodyRef = useRef(null); const refresh = useCallback(async () => { - setLoading(true); + setBackgroundFetching(true); setError(null); try { - // Fetch all three scopes in parallel, then merge. Any single - // request failing doesn't poison the drawer — we still show - // what we have and surface a soft error banner. - const responses = await Promise.all( + // Fetch all three scopes in parallel with per-request timeouts. + // Any single request failing (HTTP error, abort, network drop) + // doesn't poison the drawer — we still merge what came back and + // surface a soft error banner so the user knows something went + // wrong without staring at a blank drawer forever. + const results = await Promise.all( OP_KINDS.map(async (kind) => { try { - const res = await fetch( + const res = await fetchWithTimeout( `/api/operations/${encodeURIComponent(kind)}/${encodeURIComponent(applicationId)}/logs` ); - if (!res.ok) return { kind, entries: [] as OperationLogEntryDto[] }; + if (!res.ok) { + return { + kind, + entries: [] as OperationLogEntryDto[], + error: `${kind}: HTTP ${res.status}`, + }; + } const body = (await res.json()) as { entries?: OperationLogEntryDto[] }; - return { kind, entries: body.entries ?? [] }; - } catch { - return { kind, entries: [] as OperationLogEntryDto[] }; + return { kind, entries: body.entries ?? [], error: null as string | null }; + } catch (err) { + const msg = + err instanceof Error + ? err.name === 'AbortError' + ? `${kind}: timed out after ${FETCH_TIMEOUT_MS / 1000}s` + : `${kind}: ${err.message}` + : `${kind}: unknown error`; + return { kind, entries: [] as OperationLogEntryDto[], error: msg }; } }) ); - const merged = responses + const merged = results .flatMap((r) => r.entries) .sort((a, b) => { const ta = new Date(a.createdAt).getTime(); @@ -153,16 +185,23 @@ export function SmartDeployLogsDrawer({ return ta - tb; }); setEntries(merged); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load logs'); + const errors = results.map((r) => r.error).filter((e): e is string => e !== null); + setError(errors.length > 0 ? errors.join(' · ') : null); } finally { - setLoading(false); + setBackgroundFetching(false); + setHasLoadedOnce(true); } }, [applicationId]); - // Fetch on open + poll while running. + // Fetch on open + poll while running. Reset the one-shot spinner + // flag when the drawer is closed OR the applicationId switches so + // the user sees "Loading logs…" on the next fresh open, not a stale + // "No activity yet" while the first new fetch is still in flight. useEffect(() => { - if (!open) return; + if (!open) { + setHasLoadedOnce(false); + return; + } void refresh(); if (!isRunning) return; const timer = setInterval(() => { @@ -234,11 +273,21 @@ export function SmartDeployLogsDrawer({ Copy all +
- {loading && entries.length === 0 ? ( + {!hasLoadedOnce ? (
Loading logs…
@@ -248,10 +297,10 @@ export function SmartDeployLogsDrawer({ {error}
) : null} - {visible.length === 0 && !loading && !error ? ( + {visible.length === 0 && hasLoadedOnce ? (
- No activity yet. - {isRunning ? ' The operation just started — entries will appear here.' : ''} + {error ? 'Could not load activity. Use Refresh to try again.' : 'No activity yet.'} + {isRunning && !error ? ' The operation just started — entries will appear here.' : ''}
) : null} diff --git a/src/presentation/web/hooks/use-smart-deploy-state.ts b/src/presentation/web/hooks/use-smart-deploy-state.ts index d6901f700..b3aea7e79 100644 --- a/src/presentation/web/hooks/use-smart-deploy-state.ts +++ b/src/presentation/web/hooks/use-smart-deploy-state.ts @@ -50,7 +50,7 @@ import type { SyncActionState } from './use-sync-action'; export type SmartDeployStateKind = | 'loading' - | 'getOnline' // no remote yet + | 'getOnline' // no remote yet — one click runs the full zero-brain pipeline | 'deploy' // clean + has remote, no deployment yet | 'save' // dirty + has remote, no cloud connected | 'pushAndDeploy' // dirty + has remote + cloud connected (not yet live) @@ -262,7 +262,10 @@ export function useSmartDeployState({ }); } - // 4. No remote at all — first-time setup + // 4. No remote at all — first-time setup. The button is always + // "Get online" regardless of cloud provider connectivity; + // the one-click handler walks the whole zero-brain pipeline + // and only stops to prompt when a token is actually required. if (!hasRemote) { return baseState({ kind: 'getOnline', From d70fec46a5b0768b66cb87242bf0b5dd51634459 Mon Sep 17 00:00:00 2001 From: "shep-ai[bot]" Date: Mon, 20 Apr 2026 15:20:56 +0300 Subject: [PATCH 7/7] feat(web): real-time sse deltas for application setup and operation logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the last two client-side polling paths in the application page (refetchInterval on the loader query, setInterval in the activity drawer) and replaces them with typed, backend-owned SSE deltas. The server remains the sole poller; the client patches state in-place as typed events arrive. Backend: - New NotificationEventType values ApplicationUpdated + OperationLogAppended with scoped payload models, plumbed through TypeSpec codegen. - New IOperationLogEventBus port + in-memory adapter. The SQLite operation-log repo publishes after each successful INSERT so clients see every appended log line within a tick, not on a 2s poll. - StreamAgentEventsUseCase diffs applications rows per poll and re-emits bus publishes as notifications. Per-connection cache seeds silently on reconnect. Client: - useApplicationUpdate + useOperationLogAppend selector hooks on the shared agent-events context. - ApplicationPageLoader patches the React Query cache in-place on event, zero HTTP refetch. Removes the prior invalidate-on-any-event hack. - SmartDeployLogsDrawer appends new entries with dedup by id. Initial hydration fetch on open is preserved; setInterval is gone. Also bundles prior chat-timeline / smart-deploy polish on the branch: scaffold stdout streaming to operation logs (enables the ApplicationSetup kind to populate the drawer), bubbled chat-timeline groupings, and AppTopBar / SmartDeployButton / SmartDeployCluster layout polish. Spec: specs/090-application-sse-deltas/ Invariants: DB is source of truth; SSE is a hint. Missing an event is never fatal — next read rehydrates from the row. No client timers. Tests: 20 new spec-scoped unit tests. Full suite green (6604 unit + 763 integration). typecheck + lint + tsp + storybook clean. Co-Authored-By: Shep Bot --- .../json-schema/ApplicationUpdatePayload.yaml | 24 ++ apis/json-schema/NotificationEvent.yaml | 6 + apis/json-schema/NotificationEventConfig.yaml | 8 + apis/json-schema/NotificationEventType.yaml | 2 + .../OperationLogAppendPayload.yaml | 10 + apis/json-schema/OperationLogKind.yaml | 1 + package.json | 2 +- .../application-scaffolder.interface.ts | 36 ++ .../operation-log-event-bus.interface.ts | 25 ++ .../agents/stream-agent-events.use-case.ts | 90 ++++- .../compute-application-deltas.ts | 68 ++++ .../stream-agent-events.types.ts | 14 +- .../create-application.use-case.ts | 46 ++- packages/core/src/domain/generated/output.ts | 197 ++++++---- .../di/modules/register-repositories.ts | 4 +- .../di/modules/register-services.ts | 9 + .../sqlite-operation-log.repository.ts | 10 +- .../in-memory-operation-log-event-bus.ts | 51 +++ .../notifications/notification.service.ts | 2 + .../bun-shadcn-scaffolder.service.ts | 160 +++++++- .../contracts/.gitkeep | 0 .../090-application-sse-deltas/data-model.md | 64 ++++ specs/090-application-sse-deltas/feature.yaml | 48 +++ specs/090-application-sse-deltas/plan.yaml | 269 ++++++++++++++ .../090-application-sse-deltas/research.yaml | 200 ++++++++++ specs/090-application-sse-deltas/spec.yaml | 142 +++++++ specs/090-application-sse-deltas/tasks.yaml | 308 +++++++++++++++ .../web/components/assistant-ui/thread.tsx | 27 +- .../feature-node/derive-feature-state.ts | 9 +- .../application-page/app-overflow-menu.tsx | 15 +- .../features/application-page/app-top-bar.tsx | 6 +- .../application-page/app-view-tabs.tsx | 64 ++-- .../application-page-loader.tsx | 23 +- .../application-page/application-page.tsx | 5 +- .../application-page/smart-deploy-button.tsx | 105 ++---- .../application-page/smart-deploy-cluster.tsx | 51 ++- .../smart-deploy-logs-drawer.tsx | 202 +++++++--- .../web/components/features/chat/ChatTab.tsx | 190 +++++++--- .../features/chat/operation-bubble.tsx | 351 ++++++++++++++---- .../features/chat/turn-group-card.tsx | 78 ++-- .../features/chat/turn-group-list.tsx | 84 ++++- .../web/hooks/agent-events-provider.tsx | 50 ++- .../sqlite-operation-log.repository.test.ts | 3 +- .../agents/compute-application-deltas.test.ts | 169 +++++++++ .../stream-agent-events-application.test.ts | 203 ++++++++++ ...eam-agent-events-operation-log-bus.test.ts | 248 +++++++++++++ .../stream-agent-events.use-case.test.ts | 52 +++ .../create-application.use-case.test.ts | 22 +- ...e-operation-log-repository-publish.test.ts | 107 ++++++ .../in-memory-operation-log-event-bus.test.ts | 125 +++++++ .../application-page-loader-patch.test.ts | 225 +++++++++++ .../smart-deploy-logs-drawer-append.test.tsx | 255 +++++++++++++ tsconfig.json | 1 + tsp/common/enums/notification.tsp | 6 + tsp/common/enums/operation-log.tsp | 3 + tsp/domain/entities/notification-event.tsp | 47 +++ tsp/domain/entities/settings.tsp | 6 + vitest.config.ts | 4 + 58 files changed, 4065 insertions(+), 467 deletions(-) create mode 100644 apis/json-schema/ApplicationUpdatePayload.yaml create mode 100644 apis/json-schema/OperationLogAppendPayload.yaml create mode 100644 packages/core/src/application/ports/output/services/operation-log-event-bus.interface.ts create mode 100644 packages/core/src/application/use-cases/agents/stream-agent-events/compute-application-deltas.ts create mode 100644 packages/core/src/infrastructure/services/events/in-memory-operation-log-event-bus.ts create mode 100644 specs/090-application-sse-deltas/contracts/.gitkeep create mode 100644 specs/090-application-sse-deltas/data-model.md create mode 100644 specs/090-application-sse-deltas/feature.yaml create mode 100644 specs/090-application-sse-deltas/plan.yaml create mode 100644 specs/090-application-sse-deltas/research.yaml create mode 100644 specs/090-application-sse-deltas/spec.yaml create mode 100644 specs/090-application-sse-deltas/tasks.yaml create mode 100644 tests/unit/application/use-cases/agents/compute-application-deltas.test.ts create mode 100644 tests/unit/application/use-cases/agents/stream-agent-events-application.test.ts create mode 100644 tests/unit/application/use-cases/agents/stream-agent-events-operation-log-bus.test.ts create mode 100644 tests/unit/infrastructure/repositories/sqlite-operation-log-repository-publish.test.ts create mode 100644 tests/unit/infrastructure/services/events/in-memory-operation-log-event-bus.test.ts create mode 100644 tests/unit/presentation/web/features/application-page/application-page-loader-patch.test.ts create mode 100644 tests/unit/presentation/web/features/application-page/smart-deploy-logs-drawer-append.test.tsx 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/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-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/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 be04c8286..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-12 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 314a6b328..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-12 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' ? (