diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index 9d00de85c..4c718e425 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -113,6 +113,15 @@ function migrate(db) { if (!messageCols.some((c) => c.name === 'agent_name')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`); } + if (!messageCols.some((c) => c.name === 'run_id')) { + db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`); + } + if (!messageCols.some((c) => c.name === 'run_status')) { + db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`); + } + if (!messageCols.some((c) => c.name === 'last_run_event_id')) { + db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`); + } } // ---------- projects ---------- @@ -135,6 +144,66 @@ export function listProjects(db) { return rows.map(normalizeProject); } +export function listLatestProjectRunStatuses(db) { + const rows = db + .prepare( + `SELECT c.project_id AS projectId, + m.run_id AS runId, + m.run_status AS status, + COALESCE(m.ended_at, m.started_at, m.created_at) AS updatedAt + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.run_status IS NOT NULL + ORDER BY updatedAt DESC`, + ) + .all(); + const latestByProject = new Map(); + for (const row of rows) { + if (!latestByProject.has(row.projectId)) { + latestByProject.set(row.projectId, { + value: normalizeProjectRunStatus(row.status), + updatedAt: Number(row.updatedAt), + runId: row.runId ?? undefined, + }); + } + } + return latestByProject; +} + +export function listProjectsAwaitingInput(db) { + const rows = db + .prepare( + `SELECT latest.projectId + FROM ( + SELECT c.project_id AS projectId, + m.conversation_id AS conversationId, + m.created_at AS createdAt, + m.position AS position, + ROW_NUMBER() OVER ( + PARTITION BY c.project_id + ORDER BY m.created_at DESC, m.position DESC + ) AS rowNum + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.role = 'assistant' + AND LOWER(m.content) LIKE '% latest.createdAt + OR (reply.created_at = latest.createdAt AND reply.position > latest.position) + ) + )`, + ) + .all(); + return new Set(rows.map((row) => row.projectId)); +} + export function getProject(db, id) { const row = db .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`) @@ -215,6 +284,21 @@ function normalizeProject(row) { }; } +function normalizeProjectRunStatus(status) { + if (status === 'starting') return 'running'; + if (status === 'cancelled') return 'canceled'; + if ( + status === 'queued' || + status === 'running' || + status === 'succeeded' || + status === 'failed' || + status === 'canceled' + ) { + return status; + } + return 'not_started'; +} + // ---------- templates ---------- export function listTemplates(db) { @@ -349,6 +433,8 @@ export function listMessages(db, conversationId) { return db .prepare( `SELECT id, role, content, agent_id AS agentId, agent_name AS agentName, + run_id AS runId, run_status AS runStatus, + last_run_event_id AS lastRunEventId, events_json AS eventsJson, attachments_json AS attachmentsJson, produced_files_json AS producedFilesJson, @@ -371,6 +457,7 @@ export function upsertMessage(db, conversationId, m) { db.prepare( `UPDATE messages SET role = ?, content = ?, agent_id = ?, agent_name = ?, + run_id = ?, run_status = ?, last_run_event_id = ?, events_json = ?, attachments_json = ?, produced_files_json = ?, started_at = ?, ended_at = ? WHERE id = ?`, @@ -379,6 +466,9 @@ export function upsertMessage(db, conversationId, m) { m.content, m.agentId ?? null, m.agentName ?? null, + m.runId ?? null, + m.runStatus ?? null, + m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, @@ -393,15 +483,16 @@ export function upsertMessage(db, conversationId, m) { ) .get(conversationId); const position = (max?.m ?? -1) + 1; - // 13 values: id, conversation_id, role, content, agent_id, agent_name, - // events_json, attachments_json, produced_files_json, started_at, - // ended_at, position, created_at. + // 16 values: id, conversation_id, role, content, agent_id, agent_name, + // run_id, run_status, last_run_event_id, events_json, attachments_json, + // produced_files_json, started_at, ended_at, position, created_at. db.prepare( `INSERT INTO messages - (id, conversation_id, role, content, agent_id, agent_name, events_json, + (id, conversation_id, role, content, agent_id, agent_name, + run_id, run_status, last_run_event_id, events_json, attachments_json, produced_files_json, started_at, ended_at, position, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( m.id, conversationId, @@ -409,6 +500,9 @@ export function upsertMessage(db, conversationId, m) { m.content, m.agentId ?? null, m.agentName ?? null, + m.runId ?? null, + m.runStatus ?? null, + m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, @@ -426,6 +520,8 @@ export function upsertMessage(db, conversationId, m) { const row = db .prepare( `SELECT id, role, content, agent_id AS agentId, agent_name AS agentName, + run_id AS runId, run_status AS runStatus, + last_run_event_id AS lastRunEventId, events_json AS eventsJson, attachments_json AS attachmentsJson, produced_files_json AS producedFilesJson, @@ -448,6 +544,9 @@ function normalizeMessage(row) { content: row.content, agentId: row.agentId ?? undefined, agentName: row.agentName ?? undefined, + runId: row.runId ?? undefined, + runStatus: row.runStatus ?? undefined, + lastRunEventId: row.lastRunEventId ?? undefined, events: parseJsonOrUndef(row.eventsJson), attachments: parseJsonOrUndef(row.attachmentsJson), producedFiles: parseJsonOrUndef(row.producedFilesJson), diff --git a/apps/web/src/prompts/deck-framework.ts b/apps/daemon/src/prompts/deck-framework.ts similarity index 100% rename from apps/web/src/prompts/deck-framework.ts rename to apps/daemon/src/prompts/deck-framework.ts diff --git a/apps/web/src/prompts/directions.ts b/apps/daemon/src/prompts/directions.ts similarity index 100% rename from apps/web/src/prompts/directions.ts rename to apps/daemon/src/prompts/directions.ts diff --git a/apps/daemon/src/prompts/discovery.ts b/apps/daemon/src/prompts/discovery.ts new file mode 100644 index 000000000..5c30199b7 --- /dev/null +++ b/apps/daemon/src/prompts/discovery.ts @@ -0,0 +1,263 @@ +/** + * Discovery + planning + huashu-philosophy directives. + * + * This is the dominant layer of the composed system prompt. It stacks + * BEFORE the official OD designer prompt so the hard rules below — emit + * a discovery form on turn 1, branch into a direction picker / brand + * extraction on turn 2, plan with TodoWrite on turn 3 — beat the softer + * "skip questions for small tweaks" wording in the base prompt. + * + * The arc: + * Turn 1 → one prose line + + STOP + * Turn 2 → branch on the brand answer: + * · "Pick a direction for me" → emit a 2nd + STOP + * · "I have a brand spec / Match a reference site / screenshot" + * → brand-spec extraction (Bash + Read), then TodoWrite + * · otherwise → TodoWrite directly + * Turn 3+ → work the plan, show progress live, build, self-check, emit . + * + * Distilled from alchaincyf/huashu-design (Junior-Designer mode, + * variations-not-answers, anti-AI-slop, embody-the-specialist) and + * op7418/guizang-ppt-skill (pre-flight asset reads, P0 self-check, + * theme-rhythm rules). + */ +import { renderDirectionFormBody, renderDirectionSpecBlock } from './directions.js'; + +export const DISCOVERY_AND_PHILOSOPHY = `# OD core directives (read first — these override anything later in this prompt) + +You are an expert designer working with the user as your manager. You produce design artifacts in HTML — prototypes, decks, dashboards, marketing pages. **HTML is your tool, not your medium**: when making slides be a slide designer, when making an app prototype be an interaction designer. Don't write a web page when the brief is a deck. + +Three hard rules govern the start of every new design task. They are not optional. The user is paying attention to *speed of feedback*; obeying these rules is what makes the agent feel responsive instead of stuck. + +--- + +## RULE 1 — turn 1 must emit a \`\` (not tools, not thinking) + +When the user opens a new project or sends a fresh design brief, your **very first output** is one short prose line + a \`\` block. Nothing else. No file reads. No Bash. No TodoWrite. No extended thinking. The form is your time-to-first-byte. + +\`\`\` + +{ + "description": "I'll lock these in before building. Skip what doesn't apply — I'll fill defaults.", + "questions": [ + { "id": "output", "label": "What are we making?", "type": "radio", "required": true, + "options": ["Slide deck / pitch", "Single web prototype / landing", "Multi-screen app prototype", "Dashboard / tool UI", "Editorial / marketing page", "Other — I'll describe"] }, + { "id": "platform", "label": "Primary surface", "type": "radio", + "options": ["Mobile (iOS/Android)", "Desktop web", "Tablet", "Responsive — all sizes", "Fixed canvas (1920×1080)"] }, + { "id": "audience", "label": "Who is this for?", "type": "text", + "placeholder": "e.g. early-stage investors, dev-tools buyers, internal exec review" }, + { "id": "tone", "label": "Visual tone", "type": "checkbox", "maxSelections": 2, + "options": ["Editorial / magazine", "Modern minimal", "Playful / illustrative", "Tech / utility", "Luxury / refined", "Brutalist / experimental", "Soft / warm"] }, + { "id": "brand", "label": "Brand context", "type": "radio", + "options": ["Pick a direction for me", "I have a brand spec — I'll share it", "Match a reference site / screenshot — I'll attach it"] }, + { "id": "scale", "label": "Roughly how much?", "type": "text", + "placeholder": "e.g. 8 slides, 1 landing + 3 sub-pages, 4 mobile screens" }, + { "id": "constraints", "label": "Anything else I should know?", "type": "textarea", + "placeholder": "Real copy, fonts you must use, things to avoid, deadline…" } + ] +} + +\`\`\` + +Form authoring rules: +- Body must be valid JSON. No comments. No trailing commas. +- \`type\` is one of: \`radio\`, \`checkbox\`, \`select\`, \`text\`, \`textarea\`. +- For \`checkbox\` questions, include \`maxSelections\` when the user should choose only a limited number of options. Do not encode limits only in the label text. +- Tailor the questions to the actual brief — drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page). +- **Read the "Project metadata" section later in this prompt before writing the form.** That block lists what the user already chose at create time (kind, fidelity, speakerNotes, animations, template). Drop the matching default question if the field is set; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio. Don't re-ask the kind itself if metadata.kind is set — the user already told you. +- Keep it under ~7 questions. Second batch in a follow-up form if needed. +- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble. +- After \`\`, **stop your turn**. Do not write code. Do not start tools. Do not narrate "I'll wait." + +The form **applies** even when the user's brief looks complete. A detailed brief still leaves design decisions open: visual tone, color stance, scale, variation count, brand context — exactly the things the form locks down. Do not justify skipping it ("the brief is rich enough"); ask anyway. The user is fast at picking radios; they are slow at re-doing a wrong direction. + +**Only** skip the form in these narrow cases: +- The user is replying *inside an active design* with a tweak ("make the headline bigger", "swap slide 3 image", "add a feature row"). +- The user explicitly says "skip questions" / "just build" / "no questions, go". +- The user's message starts with \`[form answers — …]\` (you already have the answers). + +When skipping, jump straight to RULE 3. + +--- + +## RULE 2 — turn 2 branches on the \`brand\` answer + +Once the user submits the discovery form (their next message starts with \`[form answers — discovery]\`), look at the \`brand\` field and branch: + +### Branch A — \`brand: "Pick a direction for me"\` + +Don't go to TodoWrite yet. Emit a SECOND \`\` using the **direction-cards** question type so the user picks from a curated set of visual directions rendered as rich cards (palette swatches + type sample + mood blurb + real-world references). This converts "model freestyles a visual" into "user picks 1 of 5 deterministic packages" — the single biggest reduction in AI-slop variance we have. + +Emit this verbatim (the JSON body is generated from the canonical direction library, so palette / fonts / refs match the **Direction library** spec block below): + +\`\`\` + +${renderDirectionFormBody()} + +\`\`\` + +After \`\`, stop. Wait for the user to pick. + +The form's answer comes back as the direction's **id** (e.g. \`editorial-monocle\`, \`modern-minimal\`). Look that id up in the **Direction library** below and bind the direction's palette + font stacks **verbatim** into the seed template's \`:root\` block. Do not improvise palette values. + +If the user fills the **accent_override** field, take their request as the new \`--accent\` and otherwise keep the chosen direction's defaults. + +### Branch B — \`brand: "I have a brand spec — I'll share it"\` or \`"Match a reference site / screenshot"\` + +Run brand-spec extraction *before* TodoWrite — five steps, each in its own \`Bash\` / \`Read\` / \`WebFetch\` call: + +1. **Locate the source.** If the user attached files, list them. If they gave a URL, hit \`.com/brand\`, \`.com/press\`, \`.com/about\` via WebFetch. +2. **Download styling artefacts.** Their CSS, brand-guide PDF, screenshots — whatever's available. +3. **Extract real values.** \`grep -E '#[0-9a-fA-F]{3,8}'\` on the CSS for hex; eyeball screenshots for typography. Never guess colors from memory. +4. **Codify.** Write \`brand-spec.md\` in the project root with: + - Six color tokens (\`--bg\`, \`--surface\`, \`--fg\`, \`--muted\`, \`--border\`, \`--accent\`) in OKLch + - Display + body + mono font stacks + - 3–5 layout posture rules you observed (radii, border weight, accent budget) +5. **Vocalise.** State the system you'll use in one sentence ("warm cream background, single rust accent at oklch(58% 0.15 35), Newsreader display + system body") so the user can redirect cheaply. + +Then proceed to RULE 3. + +### Branch C — anything else (or no brand info) + +Skip directly to RULE 3. + +--- + +## RULE 3 — TodoWrite the plan, then live updates + +Once direction / brand-spec is locked, your **first tool call** is TodoWrite with a plan of 5–10 short imperative items in the order you'll do them. The chat renders this as a live "Todos" card — it is the user's primary way to see your plan and redirect cheaply. + +The standard plan template (adapt the middle steps to the brief): + +\`\`\` +- 1. Read active DESIGN.md + skill assets (template.html, layouts.md, checklist.md) +- 2. (if branch B) Confirm brand-spec.md + bind to :root + (if branch A) Bind chosen direction's palette to :root + (else) Pick a direction matching the tone, bind to :root +- 3. Plan section/slide/screen list with rhythm (state list aloud before writing) +- 4. Copy the seed template to project root +- 5. Paste & fill the planned layouts/screens/slides +- 6. Replace [REPLACE] placeholders with real, specific copy from the brief +- 7. Self-check: run references/checklist.md (P0 must all pass) +- 8. Critique: 5-dim radar (philosophy / hierarchy / execution / specificity / restraint), fix any < 3/5 +- 9. Emit single +\`\`\` + +**Decks especially — framework first, content second.** For \`kind=deck\` projects, step 4 is the load-bearing one: copy the deck framework HTML (the active skill's \`assets/template.html\`, or, if no skill is bound, the canonical skeleton in the deck-mode directive at the bottom of this prompt) **verbatim** before authoring any slide content. Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework. Your job is to drop the framework in, bind the palette, then fill the \`
\` slots. That's it. + +After TodoWrite, immediately update — **mark step 1 \`in_progress\` before starting it, \`completed\` the moment it's done, mark step 2 \`in_progress\`**, etc. Do not batch updates at the end of the turn; the live progress is the point. If the plan changes, edit the list rather than silently abandoning items. + +Step 7 (checklist) and step 8 (critique) are non-negotiable. + +### Step 7 — checklist self-check + +Every skill that ships a \`references/checklist.md\` has a P0/P1/P2 list. Read it after writing the artifact. Every P0 must pass; if any fails, fix it before moving on. Do not emit \`\` with a failing P0. + +### Step 8 — 5-dimensional critique + +After the checklist passes, score yourself silently across five dimensions on a 1–5 scale: + +1. **Philosophy** — does the visual posture match what was asked (editorial vs minimal vs brutalist)? Or did you drift back to your favourite default? +2. **Hierarchy** — does the eye land in one obvious place per screen? Or is everything competing? +3. **Execution** — typography, spacing, alignment, contrast — are they right or just close? +4. **Specificity** — is every word, number, image specific to *this* brief? Or did filler / generic stat-slop creep in? +5. **Restraint** — one accent used at most twice, one decisive flourish — or three competing flourishes? + +Any dimension under 3/5 is a regression. Go back, fix the weakest, re-score. Two passes is normal. Then emit. + +--- + +${renderDirectionSpecBlock()} + +--- + +## Design philosophy (huashu-distilled — applies to every artifact) + +### A. Embody the specialist +Pick the persona before writing CSS: +- **Slide deck** → slide designer. Fixed canvas, scale-to-fit, one idea per slide, headlines ≥ 36px, body ≥ 22px, slide counter visible, theme rhythm (no 3+ same-theme in a row). +- **Mobile app prototype** → interaction designer. Real iPhone frame (Dynamic Island, status bar SVGs, home indicator), 44px hit targets, real screens not "feature one" placeholders. +- **Landing / marketing** → brand designer. One hero, 3–6 sections, real copy, *one* decisive flourish. +- **Dashboard / tool UI** → systems designer. Information density is the feature. Monospace numerics, tabular data, no decoration. + +### B. Use the skill's seed + layouts — don't write from scratch +Every prototype / mobile / deck skill ships: +- \`assets/template.html\` — a complete, opinionated seed with tokens + class system +- \`references/layouts.md\` — paste-ready section/screen/slide skeletons +- \`references/checklist.md\` — P0/P1/P2 self-review + +**Read them in that order before writing anything.** Don't write CSS from scratch — copy the seed, replace tokens, paste layouts. This is the single biggest reason guizang-ppt outputs look better than ad-hoc decks: the agent isn't re-deriving good defaults each time. + +### C. Anti-AI-slop checklist (audit before shipping) +- ❌ Aggressive purple/violet gradient backgrounds +- ❌ Generic emoji feature icons (✨ 🚀 🎯 …) +- ❌ Rounded card with a left coloured border accent +- ❌ Hand-drawn SVG humans / faces / scenery +- ❌ Inter / Roboto / Arial as a *display* face (body is fine) +- ❌ Invented metrics ("10× faster", "99.9% uptime") without a source +- ❌ Filler copy — "Feature One / Feature Two", lorem ipsum +- ❌ An icon next to every heading +- ❌ A gradient on every background + +When you don't have a real value, leave a short honest placeholder (\`—\`, a grey block, a labelled stub) instead of inventing one. An honest placeholder beats a fake stat. + +### D. Variations, not "the answer" +Default to 2–3 differentiated directions on the same brief — different colour, type personality, rhythm — when the user is exploring. For prototypes mid-flight, prefer Tweaks on a single page over multiplying files. + +### E. Junior-pass first +Show something visible early, even if it is a wireframe with grey blocks and labelled placeholders. The user redirects cheaply at this stage. Wrap the first pass in a visible artifact and *say* it is a wireframe. + +### F. Color and type +Prefer the active design system's palette OR the chosen direction's palette. If extending, derive harmonious colors with \`oklch()\` instead of inventing hex. Pair a display face with a quieter body face — never let body and display be the same family (the only exception is "tech / utility" direction which is intentionally one family). One accent colour, used at most twice per screen. + +### G. Slides + prototypes +Slides: persist position to localStorage (the simple-deck and guizang-ppt seeds already do). Tag slides with \`data-screen-label="01 Title"\`. Slide numbers are 1-indexed. Theme rhythm: no 3+ same-theme in a row. +Prototypes: include a small floating Tweaks panel exposing 3–5 design knobs (primary colour, type scale, dark mode, layout variant) when it adds value. + +### H. Multi-device + multi-screen layouts — use shared frames +When the brief calls for showing the SAME product across multiple devices (desktop + tablet + phone) or showing MULTIPLE screens of the same app side-by-side (onboarding 1 → 2 → 3, or feed → detail → checkout), do NOT re-draw a phone/laptop frame from scratch. The repo ships pixel-accurate shared frames at \`/frames/\` (served as static assets): + +- \`/frames/iphone-15-pro.html\` — 390 × 844, Dynamic Island +- \`/frames/android-pixel.html\` — 412 × 900, punch-hole + nav bar +- \`/frames/ipad-pro.html\` — iPad Pro 11" +- \`/frames/macbook.html\` — MacBook Pro 14" with notch + chin +- \`/frames/browser-chrome.html\` — macOS Safari window with traffic lights + +Each accepts \`?screen=\` and embeds that path inside the device chrome. The recommended pattern for a multi-screen prototype: + +\`\`\` +project/ +├── index.html ← gallery: composes 3+ frames in a row +├── screens/ +│ ├── 01-onboarding.html ← inner content rendered inside the frame +│ ├── 02-paywall.html +│ └── 03-home.html +\`\`\` + +Then in \`index.html\` use: + +\`\`\`html + + + +\`\`\` + +The single-screen \`mobile-app\` skill already inlines the iPhone frame in its seed; you only need the shared frames for the multi-device / multi-screen case. Don't re-draw — use these. + +### I. Restraint over ornament +"One thousand no's for every yes." A single decisive flourish — one orchestrated load animation, one striking pull quote, one piece of real photography — separates work from a sketch. Three competing flourishes turn it back into noise. + +--- + +## Default arc (recap) + +- **Turn 1** — short prose line + \`\` + stop. +- **Turn 2** — branch on \`brand\`: + - "Pick a direction for me" → emit \`\` + stop. + - "I have a brand spec / Match a reference" → run brand-spec extraction, write \`brand-spec.md\`, then TodoWrite. + - else → TodoWrite directly. +- **Turn 3+** — work the plan; mark todos completed as each step lands; show the user something visible early; iterate; **run checklist + 5-dim critique** before emitting; emit a single \`\`. +`; diff --git a/apps/web/src/prompts/official-system.ts b/apps/daemon/src/prompts/official-system.ts similarity index 100% rename from apps/web/src/prompts/official-system.ts rename to apps/daemon/src/prompts/official-system.ts diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts new file mode 100644 index 000000000..d37b74599 --- /dev/null +++ b/apps/daemon/src/prompts/system.ts @@ -0,0 +1,211 @@ +/** + * Prompt composer. The base is the OD-adapted "expert designer" system + * prompt (see ./official-system.ts) — a full identity, workflow, and + * content-philosophy charter. Stacked on top: + * + * 1. The discovery + planning + huashu-philosophy layer (./discovery.ts) + * — interactive question-form syntax, direction-picker fork, + * brand-spec extraction, TodoWrite reinforcement, 5-dim critique, + * and the embedded `directions.ts` library. + * 2. The active design system's DESIGN.md (if any) — palette, typography, + * spacing rules treated as authoritative tokens. + * 3. The active skill's SKILL.md (if any) — workflow specific to the + * kind of artifact being built. When the skill ships a seed + * (`assets/template.html`) and references (`references/layouts.md`, + * `references/checklist.md`), we inject a hard pre-flight rule above + * the skill body so the agent reads them BEFORE writing any code. + * 4. For decks (skillMode === 'deck' OR metadata.kind === 'deck'), the + * deck framework directive (./deck-framework.ts) is pinned LAST so it + * overrides any softer slide-handling wording earlier in the stack — + * this is the load-bearing nav / counter / scroll JS / print + * stylesheet contract that PDF stitching depends on. We also fire on + * the metadata path so deck-kind projects without a bound skill + * (skill_id null) still get a framework, instead of having the agent + * re-author scaling / nav / print logic from scratch each turn. When + * the active skill ships its own seed (skill body references + * `assets/template.html`), we defer to that seed and skip the generic + * skeleton — the skill's framework wins to avoid double-injection. + * + * The composed string is what the daemon sees as `systemPrompt` and what + * the Anthropic path sends as `system`. + */ +import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js'; +import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js'; +import { DECK_FRAMEWORK_DIRECTIVE } from './deck-framework.js'; + +type ProjectMetadata = { + kind?: string; + fidelity?: string | null; + speakerNotes?: boolean | null; + animations?: boolean | null; + templateId?: string | null; + templateLabel?: string | null; + inspirationDesignSystemIds?: string[]; +}; +type ProjectTemplate = { name: string; description?: string | null; files: Array<{ name: string; content: string }> }; + +export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT; + +export interface ComposeInput { + skillBody?: string | undefined; + skillName?: string | undefined; + skillMode?: 'prototype' | 'deck' | 'template' | 'design-system' | undefined; + designSystemBody?: string | undefined; + designSystemTitle?: string | undefined; + // Project-level metadata captured by the new-project panel. Drives the + // agent's understanding of artifact kind, fidelity, speaker-notes intent + // and animation intent. Missing fields here are exactly what the + // discovery form should re-ask the user about on turn 1. + metadata?: ProjectMetadata | undefined; + // The template the user picked in the From-template tab, when present. + // Snapshot of HTML files that the agent should treat as a starting + // reference rather than a fixed deliverable. + template?: ProjectTemplate | undefined; +} + +export function composeSystemPrompt({ + skillBody, + skillName, + skillMode, + designSystemBody, + designSystemTitle, + metadata, + template, +}: ComposeInput): string { + // Discovery + philosophy goes FIRST so its hard rules ("emit a form on + // turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run + // checklist + critique before ) win precedence over softer + // wording later in the official base prompt. + const parts: string[] = [ + DISCOVERY_AND_PHILOSOPHY, + '\n\n---\n\n# Identity and workflow charter (background)\n\n', + BASE_SYSTEM_PROMPT, + ]; + + if (designSystemBody && designSystemBody.trim().length > 0) { + parts.push( + `\n\n## Active design system${designSystemTitle ? ` — ${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${designSystemBody.trim()}`, + ); + } + + if (skillBody && skillBody.trim().length > 0) { + const preflight = derivePreflight(skillBody); + parts.push( + `\n\n## Active skill${skillName ? ` — ${skillName}` : ''}\n\nFollow this skill's workflow exactly.${preflight}\n\n${skillBody.trim()}`, + ); + } + + const metaBlock = renderMetadataBlock(metadata, template); + if (metaBlock) parts.push(metaBlock); + + // Decks have a load-bearing framework (nav, counter, scroll JS, print + // stylesheet for PDF stitching). Pin it last so it overrides any softer + // wording earlier in the stack ("write a script that handles arrows…"). + // + // We fire on either (a) the active skill is a deck skill OR (b) the + // project metadata declares kind=deck. Case (b) catches projects created + // without a skill (skill_id null) — without this, a deck-kind project + // with no bound skill gets neither a skill seed nor the framework + // skeleton, and the agent writes scaling / nav / print logic from scratch + // with the same buggy `place-items: center` + transform pattern we keep + // having to fix at runtime. Skill seeds (when present) win — they + // already define their own opinionated framework (simple-deck's + // scroll-snap, guizang-ppt's magazine layout) and re-pinning the generic + // skeleton would conflict. The skill-seed path takes over via + // `derivePreflight` above, so we only fire the generic skeleton when no + // skill seed is on offer. + const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck'; + const hasSkillSeed = + !!skillBody && /assets\/template\.html/.test(skillBody); + if (isDeckProject && !hasSkillSeed) { + parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`); + } + + return parts.join(''); +} + +function renderMetadataBlock( + metadata: ProjectMetadata | undefined, + template: ProjectTemplate | undefined, +): string { + if (!metadata) return ''; + const lines: string[] = []; + lines.push('\n\n## Project metadata'); + lines.push( + 'These are the structured choices the user made (or skipped) when creating this project. Treat known fields as authoritative; for any field marked "(unknown — ask)" you MUST include a matching question in your turn-1 discovery form.', + ); + lines.push(''); + lines.push(`- **kind**: ${metadata.kind}`); + + if (metadata.kind === 'prototype') { + lines.push( + `- **fidelity**: ${metadata.fidelity ?? '(unknown — ask: wireframe vs high-fidelity)'}`, + ); + } + if (metadata.kind === 'deck') { + lines.push( + `- **speakerNotes**: ${typeof metadata.speakerNotes === 'boolean' ? metadata.speakerNotes : '(unknown — ask: include speaker notes?)'}`, + ); + } + if (metadata.kind === 'template') { + lines.push( + `- **animations**: ${typeof metadata.animations === 'boolean' ? metadata.animations : '(unknown — ask: include motion/animations?)'}`, + ); + if (metadata.templateLabel) { + lines.push(`- **template**: ${metadata.templateLabel}`); + } + } + + if (metadata.inspirationDesignSystemIds && metadata.inspirationDesignSystemIds.length > 0) { + lines.push( + `- **inspirationDesignSystemIds**: ${metadata.inspirationDesignSystemIds.join(', ')} — the user picked these systems as *additional* inspiration alongside the primary one. Borrow palette accents, typographic personality, or component patterns from them; don't replace the primary system's tokens.`, + ); + } + + if (metadata.kind === 'template' && template && template.files.length > 0) { + lines.push(''); + lines.push( + `### Template reference — "${template.name}"${template.description ? ` (${template.description})` : ''}`, + ); + lines.push( + 'These HTML snapshots are what the user wants to start FROM. Read them as a stylistic + structural reference. You may copy structure, palette, typography, and component patterns; you may adapt them to the new brief; do NOT ship them verbatim. The agent should still produce its own artifact, just one that visibly inherits this template\'s design language.', + ); + for (const f of template.files) { + // Cap each file at ~12k chars so a giant template doesn't blow out + // the system prompt budget. The agent gets enough to read structure. + const truncated = + f.content.length > 12000 + ? `${f.content.slice(0, 12000)}\n` + : f.content; + lines.push(''); + lines.push(`#### \`${f.name}\``); + lines.push('```html'); + lines.push(truncated); + lines.push('```'); + } + } + + return lines.join('\n'); +} + +/** + * Detect the seed/references pattern shipped by the upgraded + * web-prototype / mobile-app / simple-deck / guizang-ppt skills, and + * inject a hard pre-flight rule that lists which side files to Read + * before doing anything else. The skill body's own workflow already says + * this — but skills get truncated under context pressure and the agent + * sometimes skips Step 0. A short up-front directive helps. + * + * Returns an empty string when the skill ships no side files (legacy + * SKILL.md-only skills) so we don't add noise. + */ +function derivePreflight(skillBody: string): string { + const refs: string[] = []; + if (/assets\/template\.html/.test(skillBody)) refs.push('`assets/template.html`'); + if (/references\/layouts\.md/.test(skillBody)) refs.push('`references/layouts.md`'); + if (/references\/themes\.md/.test(skillBody)) refs.push('`references/themes.md`'); + if (/references\/components\.md/.test(skillBody)) refs.push('`references/components.md`'); + if (/references\/checklist\.md/.test(skillBody)) refs.push('`references/checklist.md`'); + if (refs.length === 0) return ''; + return ` **Pre-flight (do this before any other tool):** Read ${refs.join(', ')} via the path written in the skill-root preamble. The seed template defines the class system you'll paste into; the layouts file is the only acceptable source of section/screen/slide skeletons; the checklist is your P0/P1/P2 gate before emitting \`\`. Skipping this step is the #1 reason output regresses to generic AI-slop.`; +} diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts new file mode 100644 index 000000000..a10ff68b3 --- /dev/null +++ b/apps/daemon/src/runs.ts @@ -0,0 +1,156 @@ +// @ts-nocheck +import { randomUUID } from 'node:crypto'; + +export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']); + +export function createChatRunService({ + createSseResponse, + createSseErrorPayload, + maxEvents = 2_000, + ttlMs = 30 * 60 * 1000, +}) { + const runs = new Map(); + + const create = (meta = {}) => { + const now = Date.now(); + const run = { + id: randomUUID(), + projectId: typeof meta.projectId === 'string' && meta.projectId ? meta.projectId : null, + conversationId: typeof meta.conversationId === 'string' && meta.conversationId ? meta.conversationId : null, + assistantMessageId: typeof meta.assistantMessageId === 'string' && meta.assistantMessageId ? meta.assistantMessageId : null, + clientRequestId: typeof meta.clientRequestId === 'string' && meta.clientRequestId ? meta.clientRequestId : null, + agentId: typeof meta.agentId === 'string' && meta.agentId ? meta.agentId : null, + status: 'queued', + createdAt: now, + updatedAt: now, + events: [], + nextEventId: 1, + clients: new Set(), + waiters: new Set(), + child: null, + acpSession: null, + exitCode: null, + signal: null, + cancelRequested: false, + promptFileCleaned: null, + }; + runs.set(run.id, run); + return run; + }; + + const get = (id) => runs.get(id) ?? null; + + const scheduleCleanup = (run) => { + setTimeout(() => { + if (TERMINAL_RUN_STATUSES.has(run.status)) runs.delete(run.id); + }, ttlMs).unref?.(); + }; + + const emit = (run, event, data) => { + const id = run.nextEventId++; + const record = { id, event, data }; + run.events.push(record); + if (run.events.length > maxEvents) run.events.splice(0, run.events.length - maxEvents); + run.updatedAt = Date.now(); + for (const sse of run.clients) sse.send(event, data, id); + return record; + }; + + const statusBody = (run) => ({ + id: run.id, + projectId: run.projectId, + conversationId: run.conversationId, + assistantMessageId: run.assistantMessageId, + agentId: run.agentId, + status: run.status, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + exitCode: run.exitCode, + signal: run.signal, + }); + + const finish = (run, status, code = null, signal = null) => { + if (TERMINAL_RUN_STATUSES.has(run.status)) return; + run.status = status; + run.exitCode = code; + run.signal = signal; + run.updatedAt = Date.now(); + run.promptFileCleaned?.(); + emit(run, 'end', { code, signal, status }); + for (const sse of run.clients) sse.end(); + run.clients.clear(); + for (const waiter of run.waiters) waiter(statusBody(run)); + run.waiters.clear(); + scheduleCleanup(run); + }; + + const fail = (run, code, message, init = {}) => { + emit(run, 'error', createSseErrorPayload(code, message, init)); + finish(run, 'failed', 1, null); + }; + + const start = (run, starter) => { + void starter(run).catch((err) => { + fail(run, 'AGENT_EXECUTION_FAILED', err instanceof Error ? err.message : String(err)); + }); + return run; + }; + + const stream = (run, req, res) => { + const sse = createSseResponse(res); + const lastEventId = Number(req.get('Last-Event-ID') || req.query.after || 0); + for (const record of run.events) { + if (!Number.isFinite(lastEventId) || record.id > lastEventId) { + sse.send(record.event, record.data, record.id); + } + } + if (TERMINAL_RUN_STATUSES.has(run.status)) { + sse.end(); + return; + } + run.clients.add(sse); + res.on('close', () => { + run.clients.delete(sse); + sse.cleanup(); + }); + }; + + const list = ({ projectId, conversationId, status } = {}) => Array.from(runs.values()).filter((run) => { + if (typeof projectId === 'string' && projectId && run.projectId !== projectId) return false; + if (typeof conversationId === 'string' && conversationId && run.conversationId !== conversationId) return false; + if (status === 'active') return !TERMINAL_RUN_STATUSES.has(run.status); + if (typeof status === 'string' && status) return run.status === status; + return true; + }); + + const cancel = (run) => { + if (!TERMINAL_RUN_STATUSES.has(run.status)) { + run.cancelRequested = true; + run.updatedAt = Date.now(); + if (run.child && !run.child.killed) run.child.kill('SIGTERM'); + else finish(run, 'canceled', null, 'SIGTERM'); + } + }; + + const wait = (run) => { + if (TERMINAL_RUN_STATUSES.has(run.status)) return Promise.resolve(statusBody(run)); + return new Promise((resolve) => run.waiters.add(resolve)); + }; + + return { + create, + start, + get, + list, + stream, + cancel, + wait, + emit, + finish, + fail, + statusBody, + isTerminal(status) { + return TERMINAL_RUN_STATUSES.has(status); + }, + }; +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 95c3e37a3..8b42cdc9b 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; +import { composeSystemPrompt } from './prompts/system.js'; import { detectAgents, getAgentDef, @@ -23,6 +24,7 @@ import { createCopilotStreamHandler } from './copilot-stream.js'; import { createJsonEventStreamHandler } from './json-event-stream.js'; import { renderDesignSystemPreview } from './design-system-preview.js'; import { renderDesignSystemShowcase } from './design-system-showcase.js'; +import { createChatRunService } from './runs.js'; import { importClaudeDesignZip } from './claude-design-import.js'; import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; @@ -48,7 +50,9 @@ import { insertConversation, insertProject, insertTemplate, + listProjectsAwaitingInput, listConversations, + listLatestProjectRunStatuses, listMessages, listProjects, listTabs, @@ -104,6 +108,20 @@ const promptFileBootstrap = (fp) => 'Do not begin your response until you have read the entire file.'; export const SSE_KEEPALIVE_INTERVAL_MS = 25_000; +export function normalizeProjectDisplayStatus(status) { + return status === 'starting' || status === 'queued' ? 'running' : status; +} + +export function composeProjectDisplayStatus(baseStatus, awaitingInputProjects, projectId) { + if (baseStatus.value === 'succeeded' && awaitingInputProjects.has(projectId)) { + return { ...baseStatus, value: 'awaiting_input' }; + } + return { + ...baseStatus, + value: normalizeProjectDisplayStatus(baseStatus.value), + }; +} + /** * @param {ApiErrorCode} code * @param {string} message @@ -282,8 +300,9 @@ export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INT return { /** @param {ChatSseEvent['event'] | ProxySseEvent['event'] | string} event */ - send(event, data) { + send(event, data, id = null) { if (!canWrite()) return false; + if (id !== null && id !== undefined) res.write(`id: ${id}\n`); res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); return true; @@ -325,14 +344,49 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { app.get('/api/projects', (_req, res) => { try { + const latestRunStatuses = listLatestProjectRunStatuses(db); + const awaitingInputProjects = listProjectsAwaitingInput(db); + const activeRunStatuses = new Map(); + for (const run of design.runs.list()) { + if (!run.projectId) continue; + const runStatus = projectStatusFromRun(run); + if (design.runs.isTerminal(run.status)) { + const existing = latestRunStatuses.get(run.projectId); + if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) { + latestRunStatuses.set(run.projectId, runStatus); + } + } else { + const existing = activeRunStatuses.get(run.projectId); + if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) { + activeRunStatuses.set(run.projectId, runStatus); + } + } + } /** @type {import('@open-design/contracts').ProjectsResponse} */ - const body = { projects: listProjects(db) }; + const body = { + projects: listProjects(db).map((project) => ({ + ...project, + status: composeProjectDisplayStatus( + activeRunStatuses.get(project.id) ?? latestRunStatuses.get(project.id) ?? { value: 'not_started' }, + awaitingInputProjects, + project.id, + ), + })), + }; res.json(body); } catch (err) { sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); } }); + function projectStatusFromRun(run) { + return { + value: normalizeProjectDisplayStatus(run.status), + updatedAt: run.updatedAt, + runId: run.id, + }; + } + app.post('/api/projects', async (req, res) => { try { const { id, name, skillId, designSystemId, pendingPrompt, metadata } = @@ -1013,25 +1067,82 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { }, ); - app.post('/api/chat', async (req, res) => { + const design = { + runs: createChatRunService({ createSseResponse, createSseErrorPayload }), + }; + + const composeDaemonSystemPrompt = async ({ projectId, skillId, designSystemId }) => { + const project = typeof projectId === 'string' && projectId ? getProject(db, projectId) : null; + const effectiveSkillId = typeof skillId === 'string' && skillId ? skillId : project?.skillId; + const effectiveDesignSystemId = typeof designSystemId === 'string' && designSystemId ? designSystemId : project?.designSystemId; + const metadata = project?.metadata; + + let skillBody; + let skillName; + let skillMode; + if (effectiveSkillId) { + const skill = (await listSkills(SKILLS_DIR)).find((s) => s.id === effectiveSkillId); + if (skill) { + skillBody = skill.body; + skillName = skill.name; + skillMode = skill.mode; + } + } + + let designSystemBody; + let designSystemTitle; + if (effectiveDesignSystemId) { + const systems = await listDesignSystems(DESIGN_SYSTEMS_DIR); + const summary = systems.find((s) => s.id === effectiveDesignSystemId); + designSystemTitle = summary?.title; + designSystemBody = await readDesignSystem(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId) ?? undefined; + } + + const template = metadata?.kind === 'template' && typeof metadata.templateId === 'string' + ? getTemplate(db, metadata.templateId) ?? undefined + : undefined; + + return composeSystemPrompt({ + skillBody, + skillName, + skillMode, + designSystemBody, + designSystemTitle, + metadata, + template, + }); + }; + + const startChatRun = async (chatBody, run) => { /** @type {Partial & { imagePaths?: string[] }} */ - const chatBody = req.body || {}; + chatBody = chatBody || {}; const { agentId, message, systemPrompt, imagePaths = [], projectId, + conversationId, + assistantMessageId, + clientRequestId, + skillId, + designSystemId, attachments = [], model, reasoning, } = chatBody; + if (typeof projectId === 'string' && projectId) run.projectId = projectId; + if (typeof conversationId === 'string' && conversationId) run.conversationId = conversationId; + if (typeof assistantMessageId === 'string' && assistantMessageId) run.assistantMessageId = assistantMessageId; + if (typeof clientRequestId === 'string' && clientRequestId) run.clientRequestId = clientRequestId; + if (typeof agentId === 'string' && agentId) run.agentId = agentId; const def = getAgentDef(agentId); - if (!def) return sendApiError(res, 400, 'AGENT_UNAVAILABLE', `unknown agent: ${agentId}`); - if (!def.bin) return sendApiError(res, 400, 'AGENT_UNAVAILABLE', 'agent has no binary'); + if (!def) return design.runs.fail(run, 'AGENT_UNAVAILABLE', `unknown agent: ${agentId}`); + if (!def.bin) return design.runs.fail(run, 'AGENT_UNAVAILABLE', 'agent has no binary'); if (typeof message !== 'string' || !message.trim()) { - return sendApiError(res, 400, 'BAD_REQUEST', 'message required'); + return design.runs.fail(run, 'BAD_REQUEST', 'message required'); } + if (run.cancelRequested || design.runs.isTerminal(run.status)) return; // Resolve the project working directory (creating the folder if it // doesn't exist yet). Without one we don't pass cwd to spawn — the @@ -1047,6 +1158,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { cwd = null; } } + if (run.cancelRequested || design.runs.isTerminal(run.status)) return; // Sanitise supplied image paths: must live under UPLOAD_DIR. const safeImages = imagePaths.filter((p) => { @@ -1094,9 +1206,14 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { const attachmentHint = safeAttachments.length ? `\n\nAttached project files: ${safeAttachments.map((p) => `\`${p}\``).join(', ')}` : ''; + const daemonSystemPrompt = await composeDaemonSystemPrompt({ projectId, skillId, designSystemId }); + const instructionPrompt = [daemonSystemPrompt, systemPrompt] + .map((part) => (typeof part === 'string' ? part.trim() : '')) + .filter(Boolean) + .join('\n\n---\n\n'); const composed = [ - systemPrompt && systemPrompt.trim() - ? `# Instructions (read first)\n\n${systemPrompt.trim()}${cwdHint}\n\n---\n` + instructionPrompt + ? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}\n\n---\n` : cwdHint ? `# Instructions${cwdHint}\n\n---\n` : '', @@ -1180,10 +1297,9 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { } } + run.promptFileCleaned = cleanPromptFile; const args = def.buildArgs(effectivePrompt, safeImages, extraAllowedDirs, agentOptions, { cwd }); - - const sse = createSseResponse(res); - const send = sse.send; + const send = (event, data) => design.runs.emit(run, event, data); // resolvedBin was already looked up above for the ENAMETOOLONG check. // If detection can't find the binary, surface a friendly SSE error @@ -1192,13 +1308,13 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { // from issue #10 the rest of this block is meant to prevent. if (!resolvedBin) { cleanPromptFile(); - send('error', createSseErrorPayload( + design.runs.emit(run, 'error', createSseErrorPayload( 'AGENT_UNAVAILABLE', `Agent "${def.name}" (\`${def.bin}\`) is not installed or not on PATH. ` + 'Install it and refresh the agent list (GET /api/agents) before retrying.', { retryable: true }, )); - return sse.end(); + return design.runs.finish(run, 'failed', 1, null); } // npm shims on Windows are .cmd/.bat files; Node ≥21 refuses to spawn // those without `shell: true` (CVE-2024-27980). When `shell: true` is set @@ -1227,7 +1343,12 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { const useShell = process.platform === 'win32' && CMD_BAT_RE.test(resolvedBin); + if (run.cancelRequested || design.runs.isTerminal(run.status)) return; + + run.status = 'running'; + run.updatedAt = Date.now(); send('start', { + runId: run.id, agentId, bin: resolvedBin, streamFormat: def.streamFormat ?? 'plain', @@ -1251,6 +1372,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { cwd: cwd || undefined, shell: useShell, }); + run.child = child; if ((def.promptViaStdin || needsFilePrompt) && child.stdin && def.streamFormat !== 'pi-rpc') { // EPIPE from a fast-exiting CLI (bad auth, missing model, exit on // launch) would otherwise surface as an unhandled stream error and @@ -1265,8 +1387,8 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { } } catch (err) { cleanPromptFile(); - send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', `spawn failed: ${err.message}`)); - return sse.end(); + design.runs.emit(run, 'error', createSseErrorPayload('AGENT_EXECUTION_FAILED', `spawn failed: ${err.message}`)); + return design.runs.finish(run, 'failed', 1, null); } child.stdout.setEncoding('utf8'); @@ -1311,25 +1433,64 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { } child.stderr.on('data', (chunk) => send('stderr', { chunk })); - const kill = () => { - if (child && !child.killed) child.kill('SIGTERM'); - }; - res.on('close', () => { - if (!res.writableEnded) kill(); - }); - child.on('error', (err) => { send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message)); - sse.end(); + design.runs.finish(run, 'failed', 1, null); }); child.on('close', (code, signal) => { if (acpSession?.hasFatalError()) { - return sse.end(); + return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); } - cleanPromptFile(); - send('end', { code, signal }); - sse.end(); + const status = run.cancelRequested + ? 'canceled' + : code === 0 + ? 'succeeded' + : 'failed'; + design.runs.finish(run, status, code, signal); }); + }; + + app.post('/api/runs', (req, res) => { + const run = design.runs.create(req.body || {}); + /** @type {import('@open-design/contracts').ChatRunCreateResponse} */ + const body = { runId: run.id }; + res.status(202).json(body); + design.runs.start(run, () => startChatRun(req.body || {}, run)); + }); + + app.get('/api/runs', (req, res) => { + const { projectId, conversationId, status } = req.query; + const runs = design.runs.list({ projectId, conversationId, status }); + /** @type {import('@open-design/contracts').ChatRunListResponse} */ + const body = { runs: runs.map(design.runs.statusBody) }; + res.json(body); + }); + + app.get('/api/runs/:id', (req, res) => { + const run = design.runs.get(req.params.id); + if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + res.json(design.runs.statusBody(run)); + }); + + app.get('/api/runs/:id/events', (req, res) => { + const run = design.runs.get(req.params.id); + if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + design.runs.stream(run, req, res); + }); + + app.post('/api/runs/:id/cancel', (req, res) => { + const run = design.runs.get(req.params.id); + if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + design.runs.cancel(run); + /** @type {import('@open-design/contracts').ChatRunCancelResponse} */ + const body = { ok: true }; + res.json(body); + }); + + app.post('/api/chat', (req, res) => { + const run = design.runs.create(); + design.runs.stream(run, req, res); + design.runs.start(run, () => startChatRun(req.body || {}, run)); }); // ---- API Proxy (SSE) for OpenAI-compatible endpoints --------------------- diff --git a/apps/daemon/tests/project-status.test.ts b/apps/daemon/tests/project-status.test.ts new file mode 100644 index 000000000..bf8ca3711 --- /dev/null +++ b/apps/daemon/tests/project-status.test.ts @@ -0,0 +1,158 @@ +// @ts-nocheck +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test } from 'vitest'; + +import { + closeDatabase, + insertConversation, + insertProject, + listLatestProjectRunStatuses, + listProjectsAwaitingInput, + openDatabase, + upsertMessage, +} from '../src/db.js'; +import { composeProjectDisplayStatus } from '../src/server.js'; + +const tempDirs = []; + +afterEach(() => { + closeDatabase(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createDb() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-project-status-')); + tempDirs.push(dir); + return openDatabase(dir, { dataDir: path.join(dir, '.od') }); +} + +function seedProject(db, projectId, runStatus = 'succeeded') { + insertProject(db, { + id: projectId, + name: projectId, + createdAt: 1, + updatedAt: 1, + }); + insertConversation(db, { + id: `${projectId}-conversation`, + projectId, + title: null, + createdAt: 1, + updatedAt: 1, + }); + upsertMessage(db, `${projectId}-conversation`, { + id: `${projectId}-run`, + role: 'assistant', + content: 'done', + runId: `${projectId}-run-id`, + runStatus, + endedAt: 50, + }); + return `${projectId}-conversation`; +} + +function addMessage(db, conversationId, id, role, content) { + upsertMessage(db, conversationId, { id, role, content }); +} + +test('unanswered structured question marks project as awaiting input', () => { + const db = createDb(); + const conversationId = seedProject(db, 'project-a'); + + addMessage(db, conversationId, 'assistant-question', 'assistant', 'Need one choice\n'); + + assert.deepEqual([...listProjectsAwaitingInput(db)], ['project-a']); +}); + +test('user reply after structured question clears awaiting input', () => { + const db = createDb(); + const conversationId = seedProject(db, 'project-b'); + + addMessage(db, conversationId, 'assistant-question', 'assistant', ''); + addMessage(db, conversationId, 'user-answer', 'user', 'Here is my answer'); + + assert.equal(listProjectsAwaitingInput(db).has('project-b'), false); +}); + +test('latest structured question form wins across assistant turns', () => { + const db = createDb(); + const conversationId = seedProject(db, 'project-c'); + + addMessage(db, conversationId, 'assistant-question-1', 'assistant', ''); + addMessage(db, conversationId, 'user-answer', 'user', 'answered'); + addMessage(db, conversationId, 'assistant-question-2', 'assistant', ''); + + assert.equal(listProjectsAwaitingInput(db).has('project-c'), true); +}); + +test('plain text question does not mark awaiting input', () => { + const db = createDb(); + const conversationId = seedProject(db, 'project-d'); + + addMessage(db, conversationId, 'assistant-question', 'assistant', 'Can you clarify the color palette?'); + + assert.equal(listProjectsAwaitingInput(db).has('project-d'), false); +}); + +test('only succeeded statuses are overridden by awaiting input', () => { + const db = createDb(); + const failedConversationId = seedProject(db, 'project-failed', 'failed'); + const canceledConversationId = seedProject(db, 'project-canceled', 'canceled'); + const runningConversationId = seedProject(db, 'project-running', 'running'); + + addMessage(db, failedConversationId, 'failed-question', 'assistant', ''); + addMessage(db, canceledConversationId, 'canceled-question', 'assistant', ''); + addMessage(db, runningConversationId, 'running-question', 'assistant', ''); + + const awaiting = listProjectsAwaitingInput(db); + const runStatuses = listLatestProjectRunStatuses(db); + + assert.equal(awaiting.has('project-failed'), true); + assert.equal(awaiting.has('project-canceled'), true); + assert.equal(awaiting.has('project-running'), true); + assert.equal(runStatuses.get('project-failed')?.value, 'failed'); + assert.equal(runStatuses.get('project-canceled')?.value, 'canceled'); + assert.equal(runStatuses.get('project-running')?.value, 'running'); +}); + +test('queued active run surfaces as running in project projection', () => { + const status = composeProjectDisplayStatus( + { + value: 'queued', + updatedAt: 42, + runId: 'active-run', + }, + new Set(), + 'project-queued-active', + ); + + assert.deepEqual(status, { + value: 'running', + updatedAt: 42, + runId: 'active-run', + }); +}); + +test('queued db-latest run status surfaces as running in project projection', () => { + const db = createDb(); + seedProject(db, 'project-queued-db', 'queued'); + + const runStatuses = listLatestProjectRunStatuses(db); + const status = composeProjectDisplayStatus( + runStatuses.get('project-queued-db') ?? { value: 'not_started' }, + new Set(), + 'project-queued-db', + ); + + assert.equal(runStatuses.get('project-queued-db')?.value, 'queued'); + assert.deepEqual(status, { + value: 'running', + updatedAt: 50, + runId: 'project-queued-db-run-id', + }); +}); diff --git a/apps/daemon/tests/server-paths.test.ts b/apps/daemon/tests/server-paths.test.ts index e43497861..ad6633292 100644 --- a/apps/daemon/tests/server-paths.test.ts +++ b/apps/daemon/tests/server-paths.test.ts @@ -9,6 +9,12 @@ describe('resolveProjectRoot', () => { expect(resolveProjectRoot(path.join(root, 'apps', 'daemon'))).toBe(root); }); + it('resolves the repository root from the live TypeScript source directory', () => { + const root = path.resolve(import.meta.dirname, '../../..'); + + expect(resolveProjectRoot(path.join(root, 'apps', 'daemon', 'src'))).toBe(root); + }); + it('resolves the repository root from the compiled daemon dist directory', () => { const root = path.resolve(import.meta.dirname, '../../..'); diff --git a/apps/daemon/tests/sse-response.test.ts b/apps/daemon/tests/sse-response.test.ts index 6ccf340e5..b7d73f0ea 100644 --- a/apps/daemon/tests/sse-response.test.ts +++ b/apps/daemon/tests/sse-response.test.ts @@ -25,6 +25,15 @@ describe('createSseResponse', () => { expect(res.writes.join('')).toBe('event: start\ndata: {"ok":true}\n\n'); }); + it('can attach SSE event ids for resumable streams', () => { + const res = new FakeResponse(); + const sse = createSseResponse(res, { keepAliveIntervalMs: 0 }); + + expect(sse.send('stdout', { chunk: 'hello' }, 12)).toBe(true); + + expect(res.writes.join('')).toBe('id: 12\nevent: stdout\ndata: {"chunk":"hello"}\n\n'); + }); + it('emits heartbeat comments before real events', () => { const res = new FakeResponse(); const sse = createSseResponse(res, { keepAliveIntervalMs: 0 }); diff --git a/apps/web/sidecar/server.ts b/apps/web/sidecar/server.ts index f239a36ac..6ebbd1391 100644 --- a/apps/web/sidecar/server.ts +++ b/apps/web/sidecar/server.ts @@ -22,6 +22,7 @@ import { const HOST = "127.0.0.1"; const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT; const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; +const SHUTDOWN_TIMEOUT_MS = 3000; const require = createRequire(import.meta.url); const createNextServer = require("next") as (options: { dev: boolean; dir: string }) => { close?: () => Promise; @@ -98,6 +99,31 @@ async function closeHttpServer(server: Server): Promise { }); } +async function settleShutdownTask(task: Promise | undefined): Promise { + if (task == null) return; + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + task.catch(() => undefined), + new Promise((resolveTimeout) => { + timeout = setTimeout(resolveTimeout, SHUTDOWN_TIMEOUT_MS); + timeout.unref(); + }), + ]); + } finally { + if (timeout != null) clearTimeout(timeout); + } +} + +function stopThenExit(stop: () => Promise): void { + const hardExit = setTimeout(() => process.exit(0), SHUTDOWN_TIMEOUT_MS + 1000); + hardExit.unref(); + void stop().finally(() => { + clearTimeout(hardExit); + process.exit(0); + }); +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -114,7 +140,7 @@ function attachParentMonitor(stop: () => Promise): void { const timer = setInterval(() => { if (isProcessAlive(parentPid)) return; clearInterval(timer); - void stop().finally(() => process.exit(0)); + stopThenExit(stop); }, 1000); timer.unref(); } @@ -150,9 +176,9 @@ export async function startWebSidecar(runtime: SidecarRuntimeContext undefined); - await closeHttpServer(httpServer).catch(() => undefined); - await (app as unknown as { close?: () => Promise }).close?.().catch(() => undefined); + await settleShutdownTask(ipcServer?.close()); + await settleShutdownTask(closeHttpServer(httpServer)); + await settleShutdownTask((app as unknown as { close?: () => Promise }).close?.()); resolveStopped(); } @@ -167,7 +193,7 @@ export async function startWebSidecar(runtime: SidecarRuntimeContext { - void stop().finally(() => process.exit(0)); + stopThenExit(stop); }); return { accepted: true }; } @@ -176,7 +202,7 @@ export async function startWebSidecar(runtime: SidecarRuntimeContext { - void stop().finally(() => process.exit(0)); + stopThenExit(stop); }); } diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 1093c1759..68259b537 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -105,10 +105,14 @@ export function ChatPane({ const logRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); + const didInitialScrollRef = useRef(false); const [tab, setTab] = useState('chat'); const [showConvList, setShowConvList] = useState(false); const [scrolledFromBottom, setScrolledFromBottom] = useState(false); const lastAssistantId = [...messages].reverse().find((m) => m.role === 'assistant')?.id; + const hasActiveRunMessage = messages.some( + (m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus), + ); // Map each assistant message id to the user message that follows it // (if any) so QuestionFormView can render its locked "answered" state // with the user's picks. @@ -124,6 +128,20 @@ export function ChatPane({ return map; })(); + useEffect(() => { + didInitialScrollRef.current = false; + }, [activeConversationId]); + + useEffect(() => { + const el = logRef.current; + if (!el || didInitialScrollRef.current || messages.length === 0) return; + didInitialScrollRef.current = true; + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + setScrolledFromBottom(false); + }); + }, [activeConversationId, messages.length]); + useEffect(() => { const el = logRef.current; if (!el) return; @@ -334,8 +352,11 @@ export function ChatPane({ ) : null} - {messages.map((m) => - m.role === 'user' ? ( + {messages.map((m) => { + const messageStreaming = + m.role === 'assistant' && + ((streaming && m.id === lastAssistantId) || isActiveRunStatus(m.runStatus)); + return m.role === 'user' ? ( - ), - )} + ); + })} {error ?
{error}
: null} {scrolledFromBottom ? ( @@ -381,7 +402,7 @@ export function ChatPane({ ref={composerRef} projectId={projectId} projectFiles={projectFiles} - streaming={streaming} + streaming={streaming || hasActiveRunMessage} initialDraft={initialDraft} onEnsureProject={onEnsureProject} onSend={onSend} @@ -394,6 +415,10 @@ export function ChatPane({ ); } +function isActiveRunStatus(status: ChatMessage['runStatus']): boolean { + return status === 'queued' || status === 'running'; +} + function ConversationRow({ conversation, active, diff --git a/apps/web/src/components/DesignsTab.test.ts b/apps/web/src/components/DesignsTab.test.ts new file mode 100644 index 000000000..ca672c075 --- /dev/null +++ b/apps/web/src/components/DesignsTab.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { STATUS_LABEL_KEYS, STATUS_ORDER } from './DesignsTab'; + +describe('DesignsTab status metadata', () => { + it('places awaiting_input between running and succeeded', () => { + expect(STATUS_ORDER).toEqual([ + 'not_started', + 'running', + 'awaiting_input', + 'succeeded', + 'failed', + 'canceled', + ]); + }); + + it('maps awaiting_input to the i18n label key', () => { + expect(STATUS_LABEL_KEYS.awaiting_input).toBe('designs.status.awaitingInput'); + }); +}); diff --git a/apps/web/src/components/DesignsTab.tsx b/apps/web/src/components/DesignsTab.tsx index 9ed19b076..4cfbd5b10 100644 --- a/apps/web/src/components/DesignsTab.tsx +++ b/apps/web/src/components/DesignsTab.tsx @@ -1,10 +1,34 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useT } from '../i18n'; -import type { DesignSystemSummary, Project, SkillSummary } from '../types'; +import type { DesignSystemSummary, Project, ProjectDisplayStatus, SkillSummary } from '../types'; import { Icon } from './Icon'; type SubTab = 'recent' | 'yours'; +const DESIGNS_VIEW_STORAGE_KEY = 'od:designs:view'; + +// Single source of truth for the order kanban columns are rendered in and the +// i18n key each status maps to. Keeping this typed as a tuple lets us derive +// both the column list and the `statusLabel` lookup without duplication. +export const STATUS_ORDER = [ + 'not_started', + 'running', + 'awaiting_input', + 'succeeded', + 'failed', + 'canceled', +] as const satisfies readonly ProjectDisplayStatus[]; + +export const STATUS_LABEL_KEYS = { + not_started: 'designs.status.notStarted', + queued: 'designs.status.queued', + running: 'designs.status.running', + awaiting_input: 'designs.status.awaitingInput', + succeeded: 'designs.status.succeeded', + failed: 'designs.status.failed', + canceled: 'designs.status.canceled', +} as const satisfies Record>[0]>; + interface Props { projects: Project[]; skills: SkillSummary[]; @@ -17,6 +41,24 @@ export function DesignsTab({ projects, skills, designSystems, onOpen, onDelete } const t = useT(); const [filter, setFilter] = useState(''); const [sub, setSub] = useState('recent'); + const [view, setView] = useState<'grid' | 'kanban'>(() => { + if (typeof window === 'undefined') { + return 'grid'; + } + + try { + const storedView = window.localStorage.getItem(DESIGNS_VIEW_STORAGE_KEY); + return storedView === 'grid' || storedView === 'kanban' ? storedView : 'grid'; + } catch { + return 'grid'; + } + }); + + useEffect(() => { + try { + window.localStorage.setItem(DESIGNS_VIEW_STORAGE_KEY, view); + } catch {} + }, [view]); const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); @@ -32,25 +74,23 @@ export function DesignsTab({ projects, skills, designSystems, onOpen, onDelete } const dsName = (id: string | null) => designSystems.find((d) => d.id === id)?.title ?? ''; return ( -
+
-
- - - - setFilter(e.target.value)} - /> +
+
+ + + + setFilter(e.target.value)} + /> +
+
+ + +
{filtered.length === 0 ? ( @@ -75,11 +141,12 @@ export function DesignsTab({ projects, skills, designSystems, onOpen, onDelete } ? t('designs.emptyNoProjects') : t('designs.emptyNoMatch')}
- ) : ( + ) : view === 'grid' ? (
{filtered.map((p) => { const skill = skillName(p.skillId); const ds = dsName(p.designSystemId); + const status = p.status?.value ?? 'not_started'; return (
onOpen(p.id)} onKeyDown={(e) => { - if (e.key === 'Enter') onOpen(p.id); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(p.id); + } }} >
@@ -114,18 +185,86 @@ export function DesignsTab({ projects, skills, designSystems, onOpen, onDelete } )} {skill ? ` · ${skill}` : ''} {' · '} - {relativeTime(p.updatedAt, t)} + + {statusLabel(status, t)} + + {p.status?.updatedAt ? ` · ${relativeTime(p.status.updatedAt, t)}` : ''}
); })}
+ ) : ( +
+ {STATUS_ORDER.map((status) => { + const colProjects = filtered.filter( + p => ((p.status?.value ?? 'not_started') === 'queued' ? 'running' : (p.status?.value ?? 'not_started')) === status, + ); + return ( +
+
+ {statusLabel(status, t)} + {colProjects.length} +
+
+ {colProjects.length === 0 ? ( +
{t('designs.kanbanEmptyColumn')}
+ ) : ( + colProjects.map((p) => { + const skill = skillName(p.skillId); + const ds = dsName(p.designSystemId); + return ( +
onOpen(p.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(p.id); + } + }} + > + +
{p.name}
+
+ {ds ? {ds} : {t('designs.cardFreeform')}} + {skill ? ` · ${skill}` : ''} + {p.status?.updatedAt ? ` · ${relativeTime(p.status.updatedAt, t)}` : ''} +
+
+ ); + }) + )} +
+
+ ); + })} +
)}
); } +function statusLabel(status: ProjectDisplayStatus, t: ReturnType): string { + return t(STATUS_LABEL_KEYS[status]); +} + function relativeTime(ts: number, t: ReturnType): string { const diff = Date.now() - ts; const min = 60_000; diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx index 1dce3f043..af39d2c19 100644 --- a/apps/web/src/components/Icon.tsx +++ b/apps/web/src/components/Icon.tsx @@ -21,6 +21,7 @@ type IconName = | 'history' | 'image' | 'import' + | 'kanban' | 'languages' | 'link' | 'mic' @@ -212,6 +213,14 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) { ); + case 'kanban': + return ( + + + + + + ); case 'languages': return ( diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index aeac83f9b..d47c8a37d 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -3,14 +3,19 @@ import { createHtmlArtifactManifest } from '../artifacts/manifest'; import { createArtifactParser } from '../artifacts/parser'; import { useT } from '../i18n'; import { streamMessage } from '../providers/anthropic'; -import { streamViaDaemon } from '../providers/daemon'; +import { + fetchChatRunStatus, + listActiveChatRuns, + reattachDaemonRun, + streamViaDaemon, +} from '../providers/daemon'; import { fetchDesignSystem, fetchProjectFiles, fetchSkill, writeProjectTextFile, } from '../providers/registry'; -import { composeSystemPrompt } from '../prompts/system'; +import { composeSystemPrompt } from '@open-design/contracts'; import { navigate } from '../router'; import { agentDisplayName } from '../utils/agentLabels'; import type { TodoItem } from '../runtime/todos'; @@ -112,6 +117,10 @@ export function ProjectView({ // tab still focuses it. const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null); const abortRef = useRef(null); + const cancelRef = useRef(null); + const reattachControllersRef = useRef>(new Map()); + const reattachCancelControllersRef = useRef>(new Map()); + const completedReattachRunsRef = useRef>(new Set()); const skillCache = useRef>(new Map()); const designCache = useRef>(new Map()); const templateCache = useRef>(new Map()); @@ -175,6 +184,19 @@ export function ProjectView({ }; }, [project.id, activeConversationId]); + useEffect(() => { + return () => { + for (const controller of reattachControllersRef.current.values()) { + controller.abort(); + } + for (const controller of reattachCancelControllersRef.current.values()) { + controller.abort(); + } + reattachControllersRef.current.clear(); + reattachCancelControllersRef.current.clear(); + }; + }, [project.id, activeConversationId]); + // Hydrate the open-tabs state once per project. After this initial // load, every mutation flows through saveTabsState() which keeps DB + // local state coherent. @@ -337,6 +359,217 @@ export function ProjectView({ [project.id, activeConversationId], ); + const persistMessageById = useCallback( + (messageId: string) => { + if (!activeConversationId) return; + setMessages((curr) => { + const found = curr.find((m) => m.id === messageId); + if (found) void saveMessage(project.id, activeConversationId, found); + return curr; + }); + }, + [project.id, activeConversationId], + ); + + const updateMessageById = useCallback( + (messageId: string, updater: (message: ChatMessage) => ChatMessage, persist = false) => { + setMessages((curr) => { + let saved: ChatMessage | null = null; + const next = curr.map((m) => { + if (m.id !== messageId) return m; + const updated = updater(m); + saved = updated; + return updated; + }); + if (persist && saved && activeConversationId) { + void saveMessage(project.id, activeConversationId, saved); + } + return next; + }); + }, + [project.id, activeConversationId], + ); + + useEffect(() => { + if (!daemonLive || !activeConversationId || streaming) return; + let cancelled = false; + + const attachRecoverableRuns = async () => { + const activeRuns = messages.some( + (m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus) && !m.runId, + ) + ? await listActiveChatRuns(project.id, activeConversationId) + : []; + if (cancelled) return; + const activeByMessage = new Map( + activeRuns + .filter((run) => run.assistantMessageId) + .map((run) => [run.assistantMessageId!, run]), + ); + + for (const message of messages) { + if (cancelled) return; + if (message.role !== 'assistant') continue; + if (!isActiveRunStatus(message.runStatus)) continue; + const fallbackRun = !message.runId ? activeByMessage.get(message.id) : null; + const runId = message.runId ?? fallbackRun?.id; + if (!runId) continue; + if (reattachControllersRef.current.has(runId)) continue; + if (completedReattachRunsRef.current.has(runId)) continue; + + if (fallbackRun && !message.runId) { + updateMessageById( + message.id, + (prev) => ({ ...prev, runId, runStatus: fallbackRun.status }), + true, + ); + } + + const status = fallbackRun ?? await fetchChatRunStatus(runId); + if (cancelled) return; + if (!status) { + updateMessageById( + message.id, + (prev) => ({ ...prev, runStatus: 'failed', endedAt: prev.endedAt ?? Date.now() }), + true, + ); + completedReattachRunsRef.current.add(runId); + continue; + } + updateMessageById( + message.id, + (prev) => ({ ...prev, runStatus: status.status }), + true, + ); + + const controller = new AbortController(); + const cancelController = new AbortController(); + reattachControllersRef.current.set(runId, controller); + reattachCancelControllersRef.current.set(runId, cancelController); + if (!isTerminalRunStatus(status.status)) { + abortRef.current = controller; + cancelRef.current = cancelController; + setStreaming(true); + } + + let persistTimer: ReturnType | null = null; + const persistSoon = () => { + if (persistTimer) return; + persistTimer = setTimeout(() => { + persistTimer = null; + persistMessageById(message.id); + }, 500); + }; + const persistNow = () => { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + persistMessageById(message.id); + }; + + void reattachDaemonRun({ + runId, + signal: controller.signal, + cancelSignal: cancelController.signal, + initialLastEventId: message.lastRunEventId ?? null, + handlers: { + onDelta: (delta) => { + updateMessageById(message.id, (prev) => ({ ...prev, content: prev.content + delta })); + persistSoon(); + }, + onAgentEvent: (ev) => { + updateMessageById(message.id, (prev) => ({ ...prev, events: [...(prev.events ?? []), ev] })); + persistSoon(); + }, + onDone: () => { + updateMessageById( + message.id, + (prev) => ({ ...prev, runStatus: 'succeeded', endedAt: prev.endedAt ?? Date.now() }), + true, + ); + completedReattachRunsRef.current.add(runId); + reattachControllersRef.current.delete(runId); + reattachCancelControllersRef.current.delete(runId); + if (abortRef.current === controller) abortRef.current = null; + if (cancelRef.current === cancelController) cancelRef.current = null; + setStreaming(false); + persistNow(); + void refreshProjectFiles(); + onProjectsRefresh(); + }, + onError: (err) => { + setError(err.message); + updateMessageById( + message.id, + (prev) => ({ ...prev, runStatus: 'failed', endedAt: prev.endedAt ?? Date.now() }), + true, + ); + completedReattachRunsRef.current.add(runId); + reattachControllersRef.current.delete(runId); + reattachCancelControllersRef.current.delete(runId); + if (abortRef.current === controller) abortRef.current = null; + if (cancelRef.current === cancelController) cancelRef.current = null; + setStreaming(false); + persistNow(); + }, + }, + onRunStatus: (runStatus) => { + updateMessageById( + message.id, + (prev) => ({ + ...prev, + runStatus, + endedAt: isTerminalRunStatus(runStatus) ? prev.endedAt ?? Date.now() : prev.endedAt, + }), + true, + ); + if (runStatus === 'canceled') { + completedReattachRunsRef.current.add(runId); + reattachControllersRef.current.delete(runId); + reattachCancelControllersRef.current.delete(runId); + if (abortRef.current === controller) abortRef.current = null; + if (cancelRef.current === cancelController) cancelRef.current = null; + setStreaming(false); + persistNow(); + } + }, + onRunEventId: (lastRunEventId) => { + updateMessageById(message.id, (prev) => ({ ...prev, lastRunEventId })); + persistSoon(); + }, + }) + .catch((err) => { + if ((err as Error).name !== 'AbortError') { + setError(err instanceof Error ? err.message : String(err)); + } + }) + .finally(() => { + if (persistTimer) clearTimeout(persistTimer); + reattachControllersRef.current.delete(runId); + reattachCancelControllersRef.current.delete(runId); + if (abortRef.current === controller) abortRef.current = null; + if (cancelRef.current === cancelController) cancelRef.current = null; + }); + } + }; + + void attachRecoverableRuns(); + return () => { + cancelled = true; + }; + }, [ + daemonLive, + activeConversationId, + streaming, + messages, + project.id, + updateMessageById, + persistMessageById, + refreshProjectFiles, + onProjectsRefresh, + ]); + const handleSend = useCallback( async (prompt: string, attachments: ChatAttachment[]) => { if (!activeConversationId) return; @@ -366,6 +599,7 @@ export function ProjectView({ agentId: assistantAgentId, agentName: assistantAgentName, events: [], + runStatus: config.mode === 'daemon' ? 'running' : undefined, startedAt, }; const nextHistory = [...messages, userMsg]; @@ -375,6 +609,7 @@ export function ProjectView({ savedArtifactRef.current = null; onTouchProject(); persistMessage(userMsg); + persistMessage(assistantMsg); // If this is the first turn, derive a working title from the prompt // so the conversation is identifiable in the dropdown without a // round-trip through the agent. @@ -403,9 +638,17 @@ export function ProjectView({ curr.map((m) => (m.id === assistantId ? updater(m) : m)), ); }; - + let persistTimer: ReturnType | null = null; + const persistAssistantSoon = () => { + if (persistTimer) return; + persistTimer = setTimeout(() => { + persistTimer = null; + persistMessageById(assistantId); + }, 500); + }; const pushEvent = (ev: AgentEvent) => { updateAssistant((prev) => ({ ...prev, events: [...(prev.events ?? []), ev] })); + persistAssistantSoon(); // Track Write tool invocations so we can auto-open the destination // file the moment the agent finishes writing it. The file-creating // tools we care about: Write (new file), Edit (existing file — @@ -434,6 +677,7 @@ export function ProjectView({ const appendContent = (delta: string) => { updateAssistant((prev) => ({ ...prev, content: prev.content + delta })); + persistAssistantSoon(); for (const ev of parser.feed(delta)) { if (ev.type === 'artifact:start') { liveHtml = ''; @@ -452,9 +696,9 @@ export function ProjectView({ }; const controller = new AbortController(); + const cancelController = new AbortController(); abortRef.current = controller; - const systemPrompt = await composedSystemPrompt(); - + cancelRef.current = cancelController; const handlers = { onDelta: appendContent, onAgentEvent: pushEvent, @@ -464,9 +708,14 @@ export function ProjectView({ setArtifact((prev) => (prev ? { ...prev, html: ev.fullContent } : null)); } } - updateAssistant((prev) => ({ ...prev, endedAt: Date.now() })); + updateAssistant((prev) => ({ + ...prev, + endedAt: Date.now(), + runStatus: prev.runId ? 'succeeded' : prev.runStatus, + })); setStreaming(false); abortRef.current = null; + cancelRef.current = null; // Persist the finished artifact to the project folder so it shows // up as a real tab (not just the synthetic "live" stream). setArtifact((prev) => { @@ -497,9 +746,14 @@ export function ProjectView({ }, onError: (err: Error) => { setError(err.message); - updateAssistant((prev) => ({ ...prev, endedAt: Date.now() })); + updateAssistant((prev) => ({ + ...prev, + endedAt: Date.now(), + runStatus: prev.runId || isActiveRunStatus(prev.runStatus) ? 'failed' : prev.runStatus, + })); setStreaming(false); abortRef.current = null; + cancelRef.current = null; setMessages((curr) => { const finalized = curr.find((m) => m.id === assistantId); if (finalized) persistMessage(finalized); @@ -518,15 +772,39 @@ export function ProjectView({ void streamViaDaemon({ agentId: config.agentId, history: nextHistory, - systemPrompt, signal: controller.signal, + cancelSignal: cancelController.signal, handlers, projectId: project.id, + conversationId: activeConversationId, + assistantMessageId: assistantId, + clientRequestId: crypto.randomUUID(), + skillId: project.skillId ?? null, + designSystemId: project.designSystemId ?? null, attachments: attachments.map((a) => a.path), model: choice?.model ?? null, reasoning: choice?.reasoning ?? null, + onRunCreated: (runId) => { + updateMessageById(assistantId, (prev) => ({ ...prev, runId, runStatus: 'queued' }), true); + }, + onRunStatus: (runStatus) => { + updateMessageById( + assistantId, + (prev) => ({ + ...prev, + runStatus, + endedAt: isTerminalRunStatus(runStatus) ? prev.endedAt ?? Date.now() : prev.endedAt, + }), + true, + ); + }, + onRunEventId: (lastRunEventId) => { + updateMessageById(assistantId, (prev) => ({ ...prev, lastRunEventId })); + persistAssistantSoon(); + }, }); } else { + const systemPrompt = await composedSystemPrompt(); pushEvent({ kind: 'status', label: 'requesting', detail: config.model }); void streamMessage(config, systemPrompt, nextHistory, controller.signal, { onDelta: (delta) => { @@ -549,6 +827,8 @@ export function ProjectView({ projectFiles, refreshProjectFiles, persistMessage, + persistMessageById, + updateMessageById, onProjectsRefresh, ], ); @@ -639,22 +919,37 @@ export function ProjectView({ ); const handleStop = useCallback(() => { + const stoppedAt = Date.now(); + cancelRef.current?.abort(); + cancelRef.current = null; + for (const controller of reattachCancelControllersRef.current.values()) { + controller.abort(); + } + reattachCancelControllersRef.current.clear(); abortRef.current?.abort(); abortRef.current = null; + for (const controller of reattachControllersRef.current.values()) { + controller.abort(); + } + reattachControllersRef.current.clear(); setStreaming(false); setMessages((curr) => { - const next = curr.map((m) => - m.role === 'assistant' && m.endedAt === undefined - ? { ...m, endedAt: Date.now() } - : m, - ); - const finalized = next.find( - (m) => - m.role === 'assistant' && - m.endedAt !== undefined && - !curr.find((x) => x.id === m.id && x.endedAt !== undefined), - ); - if (finalized) persistMessage(finalized); + const finalized: ChatMessage[] = []; + const next = curr.map((m) => { + if (m.role !== 'assistant') return m; + if (isActiveRunStatus(m.runStatus)) { + const updated = { ...m, runStatus: 'canceled' as const, endedAt: m.endedAt ?? stoppedAt }; + finalized.push(updated); + return updated; + } + if (m.endedAt === undefined) { + const updated = { ...m, endedAt: stoppedAt }; + finalized.push(updated); + return updated; + } + return m; + }); + for (const message of finalized) persistMessage(message); return next; }); }, [persistMessage]); @@ -850,3 +1145,11 @@ function assistantAgentDisplayName( ): string | undefined { return agentDisplayName(agentId, fallbackName) ?? undefined; } + +function isTerminalRunStatus(status: ChatMessage['runStatus']): boolean { + return status === 'succeeded' || status === 'failed' || status === 'canceled'; +} + +function isActiveRunStatus(status: ChatMessage['runStatus']): boolean { + return status === 'queued' || status === 'running'; +} diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 48fac7723..2b7377834 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -158,6 +158,18 @@ export const en: Dict = { 'designs.deleteTitle': 'Delete project', 'designs.deleteConfirm': 'Delete "{name}"?', 'designs.cardFreeform': 'freeform', + 'designs.status.notStarted': 'Not started', + 'designs.status.queued': 'Queued', + 'designs.status.running': 'Running', + 'designs.status.awaitingInput': 'Needs input', + 'designs.status.succeeded': 'Completed', + 'designs.status.failed': 'Failed', + 'designs.status.canceled': 'Canceled', + 'designs.viewToggleAria': 'View mode', + 'designs.viewGrid': 'Grid view', + 'designs.viewKanban': 'Board view', + 'designs.kanbanEmptyColumn': 'No designs', + 'designs.deleteAria': 'Delete project {name}', 'examples.typeLabel': 'Type', 'examples.scenarioLabel': 'Scenario', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 4e937bf80..2fa4deeca 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -158,6 +158,18 @@ export const fa: Dict = { 'designs.deleteTitle': 'حذف پروژه', 'designs.deleteConfirm': 'آیا «{name}» حذف شود؟', 'designs.cardFreeform': 'آزاد', + 'designs.status.notStarted': 'شروع نشده', + 'designs.status.queued': 'در صف', + 'designs.status.running': 'در حال اجرا', + 'designs.status.awaitingInput': 'نیازمند ورودی', + 'designs.status.succeeded': 'تکمیل شد', + 'designs.status.failed': 'ناموفق', + 'designs.status.canceled': 'لغو شد', + 'designs.viewToggleAria': 'حالت نمایش', + 'designs.viewGrid': 'نمای شبکه‌ای', + 'designs.viewKanban': 'نمای برد', + 'designs.kanbanEmptyColumn': 'هیچ طرحی نیست', + 'designs.deleteAria': 'حذف پروژه {name}', 'examples.typeLabel': 'نوع', 'examples.scenarioLabel': 'سناریو', @@ -365,6 +377,10 @@ export const fa: Dict = { 'fileViewer.binaryMeta': 'باینری · {size}', 'fileViewer.binaryNote': 'فایل باینری ({size} بایت). برای بررسی دانلود یا از دیسک باز کنید.', + 'fileViewer.markdownStreamingMeta': 'پیش‌نمایش در حال استریم…', + 'fileViewer.markdownErrorMeta': 'پیش‌نمایش ممکن است ناقص باشد (خطای تولید).', + 'fileViewer.markdownStreamingStatus': 'در حال استریم… Markdown ناقص نمایش داده می‌شود.', + 'fileViewer.markdownErrorStatus': 'خطای تولید. آخرین محتوای در دسترس نمایش داده می‌شود.', 'fileViewer.pdfMeta': 'PDF · {size}', 'fileViewer.documentMeta': 'سند', 'fileViewer.presentationMeta': 'ارائه', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 94bd47e00..818734926 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -158,6 +158,18 @@ export const ptBR: Dict = { 'designs.deleteTitle': 'Excluir projeto', 'designs.deleteConfirm': 'Excluir "{name}"?', 'designs.cardFreeform': 'livre', + 'designs.status.notStarted': 'Não iniciado', + 'designs.status.queued': 'Na fila', + 'designs.status.running': 'Em execução', + 'designs.status.awaitingInput': 'Aguardando resposta', + 'designs.status.succeeded': 'Concluído', + 'designs.status.failed': 'Falhou', + 'designs.status.canceled': 'Cancelado', + 'designs.viewToggleAria': 'Modo de visualização', + 'designs.viewGrid': 'Visualização em grade', + 'designs.viewKanban': 'Visualização em quadro', + 'designs.kanbanEmptyColumn': 'Sem designs', + 'designs.deleteAria': 'Excluir projeto {name}', 'examples.typeLabel': 'Tipo', 'examples.scenarioLabel': 'Cenário', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 954d58fd1..955b9f384 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -158,6 +158,18 @@ export const ru: Dict = { 'designs.deleteTitle': 'Удалить проект', 'designs.deleteConfirm': 'Удалить «{name}»?', 'designs.cardFreeform': 'произвольная форма', + 'designs.status.notStarted': 'Не начато', + 'designs.status.queued': 'В очереди', + 'designs.status.running': 'Выполняется', + 'designs.status.awaitingInput': 'Нужен ввод', + 'designs.status.succeeded': 'Завершено', + 'designs.status.failed': 'Ошибка', + 'designs.status.canceled': 'Отменено', + 'designs.viewToggleAria': 'Режим просмотра', + 'designs.viewGrid': 'Вид сеткой', + 'designs.viewKanban': 'Вид доской', + 'designs.kanbanEmptyColumn': 'Нет дизайнов', + 'designs.deleteAria': 'Удалить проект {name}', 'examples.typeLabel': 'Тип', 'examples.scenarioLabel': 'Сценарий', @@ -365,6 +377,10 @@ export const ru: Dict = { 'fileViewer.binaryMeta': 'Бинарный · {size}', 'fileViewer.binaryNote': 'Бинарный файл ({size} байт). Скачайте или откройте с диска для просмотра.', + 'fileViewer.markdownStreamingMeta': 'Потоковый предпросмотр…', + 'fileViewer.markdownErrorMeta': 'Предпросмотр может быть неполным (ошибка генерации).', + 'fileViewer.markdownStreamingStatus': 'Потоковая передача… показан частичный Markdown.', + 'fileViewer.markdownErrorStatus': 'Ошибка генерации. Показано последнее доступное содержимое.', 'fileViewer.pdfMeta': 'PDF · {size}', 'fileViewer.documentMeta': 'Документ', 'fileViewer.presentationMeta': 'Презентация', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 3b7b27f7d..204edd55b 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -155,6 +155,18 @@ export const zhCN: Dict = { 'designs.deleteTitle': '删除项目', 'designs.deleteConfirm': '确定删除「{name}」?', 'designs.cardFreeform': '自由设计', + 'designs.status.notStarted': '未开始', + 'designs.status.queued': '等待中', + 'designs.status.running': '运行中', + 'designs.status.awaitingInput': '等待回复', + 'designs.status.succeeded': '已完成', + 'designs.status.failed': '失败', + 'designs.status.canceled': '已取消', + 'designs.viewToggleAria': '视图模式', + 'designs.viewGrid': '网格视图', + 'designs.viewKanban': '看板视图', + 'designs.kanbanEmptyColumn': '暂无设计', + 'designs.deleteAria': '删除项目 {name}', 'examples.typeLabel': '类型', 'examples.scenarioLabel': '场景', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 22cbfaf33..009efa0a8 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -155,6 +155,18 @@ export const zhTW: Dict = { 'designs.deleteTitle': '刪除專案', 'designs.deleteConfirm': '確定刪除「{name}」?', 'designs.cardFreeform': '自由設計', + 'designs.status.notStarted': '未開始', + 'designs.status.queued': '等待中', + 'designs.status.running': '執行中', + 'designs.status.awaitingInput': '等待回覆', + 'designs.status.succeeded': '已完成', + 'designs.status.failed': '失敗', + 'designs.status.canceled': '已取消', + 'designs.viewToggleAria': '檢視模式', + 'designs.viewGrid': '網格檢視', + 'designs.viewKanban': '看板檢視', + 'designs.kanbanEmptyColumn': '暫無設計', + 'designs.deleteAria': '刪除專案 {name}', 'examples.typeLabel': '類型', 'examples.scenarioLabel': '情境', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 94f858740..56d1a8f5d 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -171,6 +171,18 @@ export interface Dict { 'designs.deleteTitle': string; 'designs.deleteConfirm': string; 'designs.cardFreeform': string; + 'designs.status.notStarted': string; + 'designs.status.queued': string; + 'designs.status.running': string; + 'designs.status.awaitingInput': string; + 'designs.status.succeeded': string; + 'designs.status.failed': string; + 'designs.status.canceled': string; + 'designs.viewToggleAria': string; + 'designs.viewGrid': string; + 'designs.viewKanban': string; + 'designs.kanbanEmptyColumn': string; + 'designs.deleteAria': string; // Examples tab 'examples.typeLabel': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0abe77ac9..c42811099 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1884,6 +1884,7 @@ code { justify-content: space-between; } .tab-panel-toolbar .toolbar-left { display: flex; gap: 8px; align-items: center; } +.tab-panel-toolbar .toolbar-right { display: flex; gap: 8px; align-items: center; } .tab-panel-toolbar .toolbar-search { position: relative; width: 280px; @@ -1933,6 +1934,17 @@ code { color: white; box-shadow: var(--shadow-xs); } +/* Icon-only variant: any pill button whose sole child is an SVG icon. + Center the glyph via inline-flex (removes text line-height drift) and + use padding that matches the text variant's overall height so both + sub-pills align on the same baseline in the toolbar. */ +.subtab-pill button:has(> svg:only-child) { + padding: 5px 8px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} /* Designs grid */ .design-grid { @@ -2015,6 +2027,26 @@ code { .design-card-meta .ds { color: var(--accent); } +.design-card-status { + font-weight: 500; +} +.design-card-status-running { + color: var(--accent); +} +.design-card-status-awaiting_input { + color: var(--amber); +} +.design-card-status-queued, +.design-card-status-not_started, +.design-card-status-canceled { + color: var(--text-muted); +} +.design-card-status-succeeded { + color: var(--green); +} +.design-card-status-failed { + color: var(--red); +} .design-card-close { position: absolute; top: 8px; @@ -2030,8 +2062,24 @@ code { transition: opacity 0.15s; border-color: var(--border); z-index: 2; + /* Keep button interactive for keyboard/AT users even while visually hidden. */ + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} +.design-card:hover .design-card-close, +.design-card:focus-within .design-card-close, +.design-kanban-card:hover .design-card-close, +.design-kanban-card:focus-within .design-card-close, +.design-card-close:focus-visible { opacity: 1; } +.design-card-close:hover { color: var(--text-strong); border-color: var(--border-strong); } +/* Larger comfortable touch target on coarse pointers (tablets/touch laptops). + On touch the hover reveal never fires, so keep the close button visible. */ +@media (hover: none) { + .design-card .design-card-close, + .design-kanban-card .design-card-close { opacity: 1; } } -.design-card:hover .design-card-close { opacity: 1; } /* Featured (tutorial) card variant */ .design-card.featured .design-card-thumb { @@ -2043,6 +2091,169 @@ code { } .design-card.featured .design-card-thumb::after { display: none; } +/* Grid card keyboard focus (cards carry role="button" on a div). */ +.design-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-color: var(--border-strong); +} + +/* Kanban View */ +.tab-panel.design-kanban-view { + /* Fill the scrollable parent (.entry-tab-content) so columns can size to + the available viewport without a fragile 100vh calc. */ + flex: 1 1 auto; + min-height: 0; + height: 100%; +} +.design-kanban-board { + display: flex; + gap: 14px; + overflow-x: auto; + /* Let columns grow to fill the remaining vertical space inside + .tab-panel.design-kanban-view. */ + flex: 1 1 auto; + min-height: 0; + padding-bottom: 8px; + /* Hint that horizontal content may overflow on narrow viewports. */ + scroll-snap-type: x proximity; + scrollbar-gutter: stable; +} +.design-kanban-col { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-subtle); + border-radius: var(--radius); + padding: 12px; + gap: 12px; + min-height: 0; + scroll-snap-align: start; +} +.design-kanban-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text-strong); + padding-left: 2px; + /* Prevent long status labels from pushing the count chip out of the column. */ + min-width: 0; +} +.design-kanban-header > span:first-child { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.design-kanban-count { + background: var(--bg-panel); + border: 1px solid var(--border-soft); + color: var(--text-muted); + font-size: 11px; + padding: 2px 8px; + border-radius: var(--radius-pill); + flex-shrink: 0; +} +.design-kanban-list { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + /* Keep the scrollbar gutter from shifting the list when content grows. */ + padding-right: 4px; + margin-right: -4px; + flex: 1 1 auto; + min-height: 0; +} +.design-kanban-empty { + color: var(--text-faint); + font-size: 13px; + text-align: center; + padding: 20px 0; +} +.design-kanban-card { + position: relative; + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + box-shadow: var(--shadow-xs); +} +.design-kanban-card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} +.design-kanban-card:active { + transform: none; +} +.design-kanban-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-color: var(--border-strong); +} +.design-kanban-card::before { + content: ''; + position: absolute; + left: -1px; + top: -1px; + bottom: -1px; + width: 3px; + border-radius: var(--radius) 0 0 var(--radius); + background: var(--text-muted); +} +.design-kanban-card.status-running::before { background: var(--accent); } +.design-kanban-card.status-awaiting_input::before { background: var(--amber); } +.design-kanban-card.status-succeeded::before { background: var(--green); } +.design-kanban-card.status-failed::before { background: var(--red); } +.design-kanban-card.status-not_started::before, +.design-kanban-card.status-queued::before, +.design-kanban-card.status-canceled::before { background: var(--text-muted); } + +.design-kanban-card-name { + font-size: 13px; + font-weight: 500; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* Reserve room for the absolutely-positioned close button. */ + padding-right: 20px; +} +.design-kanban-card-meta { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.design-kanban-card-meta .ds { + color: var(--text-strong); + font-weight: 500; +} + +/* Honor user motion preferences for the new hover transform / transitions + introduced alongside the kanban view. */ +@media (prefers-reduced-motion: reduce) { + .design-card, + .design-kanban-card { + transition: none; + } + .design-card:hover, + .design-kanban-card:hover { + transform: none; + } +} + /* Examples gallery */ .examples-panel { gap: 32px; } .example-card { diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 36fe1893b..13ed2fe3e 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -1,5 +1,5 @@ /** - * Daemon provider — fetch-based SSE client for /api/chat. The daemon can + * Daemon provider — fetch-based SSE client for /api/runs. The daemon can * emit three event streams depending on the agent's streamFormat: * - 'agent' : typed events emitted by Claude Code's stream-json parser * (status, text_delta, thinking_delta, tool_use, tool_result, @@ -11,6 +11,10 @@ */ import type { AgentEvent, ChatMessage } from '../types'; import type { + ChatRunCreateResponse, + ChatRunListResponse, + ChatRunStatus, + ChatRunStatusResponse, ChatRequest, ChatSseEvent, ChatSseStartPayload, @@ -27,13 +31,22 @@ export interface DaemonStreamHandlers extends StreamHandlers { export interface DaemonStreamOptions { agentId: string; history: ChatMessage[]; - systemPrompt: string; + /** Legacy field accepted by older tests/callers. Daemon-owned prompt composition ignores it. */ + systemPrompt?: string; + /** Stops the current browser-side SSE subscription. The daemon run continues. */ signal: AbortSignal; + /** Explicit user cancellation signal. This maps to POST /api/runs/:id/cancel. */ + cancelSignal?: AbortSignal; handlers: DaemonStreamHandlers; // The active project's id. When supplied, the daemon spawns the agent // with cwd = the project folder so its file tools target the right // workspace. projectId?: string | null; + conversationId?: string | null; + assistantMessageId?: string | null; + clientRequestId?: string | null; + skillId?: string | null; + designSystemId?: string | null; // Project-relative paths the user has staged for this turn. The // daemon resolves them inside the project folder, validates they // exist, and stitches them into the user message as `@` hints. @@ -43,18 +56,41 @@ export interface DaemonStreamOptions { // options and falls back to the CLI default when missing. model?: string | null; reasoning?: string | null; + initialLastEventId?: string | null; + onRunCreated?: (runId: string) => void; + onRunStatus?: (status: ChatRunStatus) => void; + onRunEventId?: (eventId: string) => void; +} + +export interface DaemonReattachOptions { + runId: string; + signal: AbortSignal; + cancelSignal?: AbortSignal; + handlers: DaemonStreamHandlers; + initialLastEventId?: string | null; + onRunStatus?: (status: ChatRunStatus) => void; + onRunEventId?: (eventId: string) => void; } export async function streamViaDaemon({ agentId, history, - systemPrompt, signal, + cancelSignal, handlers, projectId, + conversationId, + assistantMessageId, + clientRequestId, + skillId, + designSystemId, attachments, model, reasoning, + initialLastEventId, + onRunCreated, + onRunStatus, + onRunEventId, }: DaemonStreamOptions): Promise { // Local CLIs are single-turn print-mode programs, so we collapse the whole // chat into one string. If this becomes too noisy for long histories, the @@ -64,110 +100,244 @@ export async function streamViaDaemon({ .join('\n\n'); const request: ChatRequest = { agentId, - systemPrompt, message: transcript, projectId: projectId ?? null, + conversationId: conversationId ?? null, + assistantMessageId: assistantMessageId ?? null, + clientRequestId: clientRequestId ?? null, + skillId: skillId ?? null, + designSystemId: designSystemId ?? null, attachments: attachments ?? [], model: model ?? null, reasoning: reasoning ?? null, }; const body = JSON.stringify(request); - let acc = ''; - let stderrBuf = ''; - let exitCode: number | null = null; - try { - const resp = await fetch('/api/chat', { + const createResp = await fetch('/api/runs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, - signal, }); - if (!resp.ok || !resp.body) { - const text = await resp.text().catch(() => ''); - handlers.onError(new Error(`daemon ${resp.status}: ${text || 'no body'}`)); + if (!createResp.ok) { + const text = await createResp.text().catch(() => ''); + onRunStatus?.('failed'); + handlers.onError(new Error(`daemon ${createResp.status}: ${text || 'no body'}`)); return; } - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; + const created = (await createResp.json()) as ChatRunCreateResponse; + const runId = created.runId; + onRunCreated?.(runId); + onRunStatus?.('queued'); + await consumeDaemonRun({ + runId, + signal, + cancelSignal, + handlers, + initialLastEventId, + onRunStatus, + onRunEventId, + }); + } catch (err) { + if ((err as Error).name === 'AbortError') return; + onRunStatus?.('failed'); + handlers.onError(err instanceof Error ? err : new Error(String(err))); + } +} - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - let idx: number; - while ((idx = buf.indexOf('\n\n')) !== -1) { - const frame = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const parsed = parseSseFrame(frame); - if (!parsed || parsed.kind !== 'event') continue; +export async function reattachDaemonRun(options: DaemonReattachOptions): Promise { + await consumeDaemonRun(options); +} - const event = parsed as unknown as ChatSseEvent; +export async function fetchChatRunStatus(runId: string): Promise { + try { + const resp = await fetch(`/api/runs/${encodeURIComponent(runId)}`); + if (!resp.ok) return null; + return (await resp.json()) as ChatRunStatusResponse; + } catch { + return null; + } +} - if (event.event === 'stdout') { - const chunk = String(event.data.chunk ?? ''); - acc += chunk; - handlers.onDelta(chunk); - handlers.onAgentEvent({ kind: 'text', text: chunk }); - continue; - } +export async function listActiveChatRuns( + projectId: string, + conversationId: string, +): Promise { + try { + const qs = new URLSearchParams({ projectId, conversationId, status: 'active' }); + const resp = await fetch(`/api/runs?${qs.toString()}`); + if (!resp.ok) return []; + const body = (await resp.json()) as ChatRunListResponse; + return body.runs ?? []; + } catch { + return []; + } +} - if (event.event === 'stderr') { - stderrBuf += event.data.chunk ?? ''; - continue; - } +async function consumeDaemonRun({ + runId, + signal, + cancelSignal, + handlers, + initialLastEventId, + onRunStatus, + onRunEventId, +}: DaemonReattachOptions): Promise { + let acc = ''; + let stderrBuf = ''; + let exitCode: number | null = null; + let exitSignal: string | null = null; + let endStatus: ChatRunStatus | null = null; + let lastEventId: string | null = initialLastEventId ?? null; + let canceled = false; + const cancelRun = () => { + if (canceled) return; + canceled = true; + void fetch(`/api/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST' }).catch(() => {}); + }; - if (event.event === 'agent') { - const translated = translateAgentEvent(event.data); - if (!translated) continue; - if (translated.kind === 'text') { - acc += translated.text; - handlers.onDelta(translated.text); + cancelSignal?.addEventListener('abort', cancelRun, { once: true }); + try { + if (cancelSignal?.aborted) { + cancelRun(); + return; + } + + for (let reconnects = 0; endStatus === null && reconnects < 5;) { + const qs = lastEventId ? `?after=${encodeURIComponent(lastEventId)}` : ''; + let resp: Response; + try { + resp = await fetch(`/api/runs/${encodeURIComponent(runId)}/events${qs}`, { + method: 'GET', + signal, + }); + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + reconnects += 1; + continue; + } + + if (!resp.ok || !resp.body) { + const text = await resp.text().catch(() => ''); + handlers.onError(new Error(`daemon ${resp.status}: ${text || 'no body'}`)); + return; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let sawStreamProgress = false; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const parsed = parseSseFrame(frame); + if (!parsed) continue; + if (parsed.kind === 'comment') { + sawStreamProgress = true; + continue; + } + if (parsed.kind !== 'event') continue; + sawStreamProgress = true; + if (parsed.id) { + lastEventId = parsed.id; + onRunEventId?.(parsed.id); } - handlers.onAgentEvent(translated); - continue; - } - if (event.event === 'start') { - const data = event.data as ChatSseStartPayload; - handlers.onAgentEvent({ - kind: 'status', - label: 'starting', - detail: typeof data.bin === 'string' ? data.bin : undefined, - }); - continue; - } + const event = parsed as unknown as ChatSseEvent; - if (event.event === 'error') { - const data = event.data as SseErrorPayload; - handlers.onError(new Error(String(data.error?.message ?? data.message ?? 'daemon error'))); - return; - } + if (event.event === 'stdout') { + const chunk = String(event.data.chunk ?? ''); + acc += chunk; + handlers.onDelta(chunk); + handlers.onAgentEvent({ kind: 'text', text: chunk }); + continue; + } - if (event.event === 'end') { - exitCode = typeof event.data.code === 'number' ? event.data.code : null; + if (event.event === 'stderr') { + stderrBuf += event.data.chunk ?? ''; + continue; + } + + if (event.event === 'agent') { + const translated = translateAgentEvent(event.data); + if (!translated) continue; + if (translated.kind === 'text') { + acc += translated.text; + handlers.onDelta(translated.text); + } + handlers.onAgentEvent(translated); + continue; + } + + if (event.event === 'start') { + const data = event.data as ChatSseStartPayload; + onRunStatus?.('running'); + handlers.onAgentEvent({ + kind: 'status', + label: 'starting', + detail: typeof data.bin === 'string' ? data.bin : undefined, + }); + continue; + } + + if (event.event === 'error') { + onRunStatus?.('failed'); + const data = event.data as SseErrorPayload; + handlers.onError(new Error(String(data.error?.message ?? data.message ?? 'daemon error'))); + return; + } + + if (event.event === 'end') { + exitCode = typeof event.data.code === 'number' ? event.data.code : null; + exitSignal = typeof event.data.signal === 'string' ? event.data.signal : null; + endStatus = isChatRunStatus(event.data.status) ? event.data.status : 'succeeded'; + onRunStatus?.(endStatus); + } } } + reconnects = sawStreamProgress ? 0 : reconnects + 1; + } + + if (endStatus === null) { + const status = await fetchChatRunStatus(runId); + if (status && isChatRunStatus(status.status) && status.status !== 'queued' && status.status !== 'running') { + endStatus = status.status; + exitCode = status.exitCode ?? null; + exitSignal = status.signal ?? null; + onRunStatus?.(endStatus); + } else { + handlers.onError(new Error('daemon stream disconnected before run completed')); + return; + } } - if (exitCode !== null && exitCode !== 0) { + if (endStatus === 'canceled') return; + + if (endStatus === 'failed' || exitSignal || (exitCode !== null && exitCode !== 0)) { const tail = stderrBuf.trim().slice(-400); handlers.onError( - new Error(`agent exited with code ${exitCode}${tail ? `\n${tail}` : ''}`), + new Error(`agent exited with ${exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`}${tail ? `\n${tail}` : ''}`), ); return; } handlers.onDone(acc); - } catch (err) { - if ((err as Error).name === 'AbortError') return; - handlers.onError(err instanceof Error ? err : new Error(String(err))); + } finally { + cancelSignal?.removeEventListener('abort', cancelRun); } } +function isChatRunStatus(value: unknown): value is ChatRunStatus { + return value === 'queued' || value === 'running' || value === 'succeeded' || value === 'failed' || value === 'canceled'; +} + // Translate a raw `agent` SSE payload (what apps/daemon/src/claude-stream.ts emits) // into the UI's AgentEvent union. Keep this liberal — unknown types just // return null so the UI ignores them instead of rendering garbage. diff --git a/apps/web/src/providers/sse.test.ts b/apps/web/src/providers/sse.test.ts index 5afc43b43..1881e6aab 100644 --- a/apps/web/src/providers/sse.test.ts +++ b/apps/web/src/providers/sse.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { streamViaDaemon } from './daemon'; +import { reattachDaemonRun, streamViaDaemon } from './daemon'; import { streamMessageOpenAI } from './openai-compatible'; import { parseSseFrame } from './sse'; @@ -10,8 +10,9 @@ afterEach(() => { describe('parseSseFrame', () => { it('parses JSON event frames', () => { - expect(parseSseFrame('event: stdout\ndata: {"chunk":"hello"}')).toEqual({ + expect(parseSseFrame('id: 12\nevent: stdout\ndata: {"chunk":"hello"}')).toEqual({ kind: 'event', + id: '12', event: 'stdout', data: { chunk: 'hello' }, }); @@ -32,7 +33,9 @@ describe('parseSseFrame', () => { describe('streamViaDaemon', () => { it('ignores comment frames without notifying handlers', async () => { const handlers = createDaemonHandlers(); - vi.stubGlobal('fetch', vi.fn(async () => sseResponse(': keepalive\n\n'))); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce(sseResponse(': keepalive\n\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'))); await streamViaDaemon({ agentId: 'mock', @@ -52,8 +55,10 @@ describe('streamViaDaemon', () => { const handlers = createDaemonHandlers(); vi.stubGlobal( 'fetch', - vi.fn(async () => - sseResponse( + vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce( + sseResponse( [ ': keepalive', '', @@ -68,9 +73,10 @@ describe('streamViaDaemon', () => { 'event: end', 'data: {"code":0}', '', + '', ].join('\n'), + ), ), - ), ); await streamViaDaemon({ @@ -90,16 +96,18 @@ describe('streamViaDaemon', () => { const handlers = createDaemonHandlers(); vi.stubGlobal( 'fetch', - vi.fn(async () => - sseResponse( + vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce( + sseResponse( [ 'event: error', 'data: {"message":"legacy message","error":{"code":"AGENT_UNAVAILABLE","message":"typed message"}}', '', '', ].join('\n'), + ), ), - ), ); await streamViaDaemon({ @@ -113,6 +121,305 @@ describe('streamViaDaemon', () => { expect(handlers.onError).toHaveBeenCalledWith(new Error('typed message')); expect(handlers.onDone).not.toHaveBeenCalled(); }); + + it('keeps the daemon run alive when the browser-side stream aborts', async () => { + const handlers = createDaemonHandlers(); + const controller = new AbortController(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/runs') return jsonResponse({ runId: 'run-1' }); + if (url === '/api/runs/run-1/events') { + controller.abort(); + throw new DOMException('aborted', 'AbortError'); + } + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: controller.signal, + handlers, + }); + + expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' }); + expect(handlers.onDone).not.toHaveBeenCalled(); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + + it('cancels the daemon run when the explicit cancel signal aborts', async () => { + const handlers = createDaemonHandlers(); + const streamController = new AbortController(); + const cancelController = new AbortController(); + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/runs') return jsonResponse({ runId: 'run-1' }); + if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true }); + if (url === '/api/runs/run-1/events') { + cancelController.abort(); + streamController.abort(); + throw new DOMException('aborted', 'AbortError'); + } + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: streamController.signal, + cancelSignal: cancelController.signal, + handlers, + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({ + method: 'POST', + })); + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/events', { + method: 'GET', + signal: streamController.signal, + }); + expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/runs/run-1/cancel', { method: 'POST' }); + expect(handlers.onDone).not.toHaveBeenCalled(); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + + it('keeps the create-run request alive across browser-side stream aborts', async () => { + const handlers = createDaemonHandlers(); + const controller = new AbortController(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === '/api/runs') { + controller.abort(); + return jsonResponse({ runId: 'run-1' }); + } + if (url === '/api/runs/run-1/events') throw new DOMException('aborted', 'AbortError'); + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: controller.signal, + handlers, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith('/api/runs', expect.objectContaining({ + method: 'POST', + })); + expect(handlers.onDone).not.toHaveBeenCalled(); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + + it('cancels an accepted daemon run when explicit cancel happens during create-run', async () => { + const handlers = createDaemonHandlers(); + const streamController = new AbortController(); + const cancelController = new AbortController(); + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/runs') { + cancelController.abort(); + streamController.abort(); + return jsonResponse({ runId: 'run-1' }); + } + if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true }); + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: streamController.signal, + cancelSignal: cancelController.signal, + handlers, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({ method: 'POST' })); + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/cancel', { method: 'POST' }); + expect(handlers.onDone).not.toHaveBeenCalled(); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + + it('marks create-run HTTP failures as failed', async () => { + const handlers = createDaemonHandlers(); + const onRunStatus = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('down', { status: 503 }))); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + onRunStatus, + }); + + expect(onRunStatus).toHaveBeenCalledWith('failed'); + expect(handlers.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'daemon 503: down' })); + expect(handlers.onDone).not.toHaveBeenCalled(); + }); + + it('marks invalid create-run JSON as failed', async () => { + const handlers = createDaemonHandlers(); + const onRunStatus = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('not json', { status: 202 }))); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + onRunStatus, + }); + + expect(onRunStatus).toHaveBeenCalledWith('failed'); + expect(handlers.onError).toHaveBeenCalledWith(expect.any(Error)); + expect(handlers.onDone).not.toHaveBeenCalled(); + }); + + it('reconnects to a daemon run after an incomplete stream closes', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce(sseResponse('id: 1\nevent: stdout\ndata: {"chunk":"he"}\n\n')) + .mockResolvedValueOnce(sseResponse('id: 2\nevent: stdout\ndata: {"chunk":"llo"}\n\nid: 3\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=1', { + method: 'GET', + signal: expect.any(AbortSignal), + }); + expect(handlers.onDone).toHaveBeenCalledWith('hello'); + }); + + it('posts run correlation fields and reports run metadata callbacks', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce(sseResponse('id: 4\nevent: start\ndata: {"bin":"mock-agent"}\n\nid: 5\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n')); + const onRunCreated = vi.fn(); + const onRunStatus = vi.fn(); + const onRunEventId = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + projectId: 'project-1', + conversationId: 'conversation-1', + assistantMessageId: 'assistant-1', + clientRequestId: 'client-1', + onRunCreated, + onRunStatus, + onRunEventId, + }); + + expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({ + projectId: 'project-1', + conversationId: 'conversation-1', + assistantMessageId: 'assistant-1', + clientRequestId: 'client-1', + }); + expect(onRunCreated).toHaveBeenCalledWith('run-1'); + expect(onRunStatus).toHaveBeenCalledWith('queued'); + expect(onRunStatus).toHaveBeenCalledWith('running'); + expect(onRunStatus).toHaveBeenCalledWith('succeeded'); + expect(onRunEventId).toHaveBeenCalledWith('4'); + expect(onRunEventId).toHaveBeenCalledWith('5'); + }); + + it('reattaches to an existing daemon run after the last stored event id', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(sseResponse('id: 8\nevent: stdout\ndata: {"chunk":"lo"}\n\nid: 9\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + await reattachDaemonRun({ + runId: 'run-1', + signal: new AbortController().signal, + initialLastEventId: '7', + handlers, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=7', { + method: 'GET', + signal: expect.any(AbortSignal), + }); + expect(handlers.onDelta).toHaveBeenCalledWith('lo'); + expect(handlers.onDone).toHaveBeenCalledWith('lo'); + }); + + it('keeps reconnecting when quiet resumed streams only receive keepalives', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce(sseResponse(': keepalive\n\n')) + .mockResolvedValueOnce(sseResponse(': keepalive\n\n')) + .mockResolvedValueOnce(sseResponse(': keepalive\n\n')) + .mockResolvedValueOnce(sseResponse(': keepalive\n\n')) + .mockResolvedValueOnce(sseResponse(': keepalive\n\n')) + .mockResolvedValueOnce(sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(fetchMock).toHaveBeenCalledTimes(7); + expect(handlers.onError).not.toHaveBeenCalled(); + expect(handlers.onDone).toHaveBeenCalledWith(''); + }); + + it('reports an error when reconnects are exhausted before an end event', async () => { + const handlers = createDaemonHandlers(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/runs') return jsonResponse({ runId: 'run-1' }); + if (url === '/api/runs/run-1/events') return sseResponse(''); + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' }); + expect(handlers.onError).toHaveBeenCalledWith(new Error('daemon stream disconnected before run completed')); + expect(handlers.onDone).not.toHaveBeenCalled(); + }); }); describe('streamMessageOpenAI', () => { @@ -191,3 +498,10 @@ function sseResponse(text: string): Response { }, ); } + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 202, + headers: { 'content-type': 'application/json' }, + }); +} diff --git a/apps/web/src/providers/sse.ts b/apps/web/src/providers/sse.ts index 7657a0c8f..61a824e3f 100644 --- a/apps/web/src/providers/sse.ts +++ b/apps/web/src/providers/sse.ts @@ -1,5 +1,5 @@ export type ParsedSseFrame = - | { kind: 'event'; event: string; data: Record } + | { kind: 'event'; event: string; data: Record; id?: string } | { kind: 'comment'; comment: string } | { kind: 'empty' }; @@ -7,6 +7,7 @@ export function parseSseFrame(frame: string): ParsedSseFrame | null { const lines = frame.split('\n'); const comments: string[] = []; let event = 'message'; + let id: string | undefined; const dataLines: string[] = []; for (const rawLine of lines) { @@ -15,6 +16,8 @@ export function parseSseFrame(frame: string): ParsedSseFrame | null { comments.push(line.slice(1).trimStart()); } else if (line.startsWith('event: ')) { event = line.slice(7).trim(); + } else if (line.startsWith('id: ')) { + id = line.slice(4).trim(); } else if (line.startsWith('data: ')) { dataLines.push(line.slice(6)); } @@ -28,7 +31,7 @@ export function parseSseFrame(frame: string): ParsedSseFrame | null { } try { - return { kind: 'event', event, data: JSON.parse(dataLines.join('\n')) }; + return { kind: 'event', event, data: JSON.parse(dataLines.join('\n')), ...(id ? { id } : {}) }; } catch { return null; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 92177cfcf..ccf1b08dc 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -7,6 +7,7 @@ import type { DesignSystemSummary, PersistedAgentEvent, Project, + ProjectDisplayStatus, ProjectFile, ProjectFileKind, ProjectKind, @@ -74,6 +75,7 @@ export type { DesignSystemDetail, DesignSystemSummary, Project, + ProjectDisplayStatus, ProjectFile, ProjectFileKind, ProjectKind, diff --git a/e2e/specs/app.spec.ts b/e2e/specs/app.spec.ts index e6c16fc60..3473dca64 100644 --- a/e2e/specs/app.spec.ts +++ b/e2e/specs/app.spec.ts @@ -91,7 +91,10 @@ for (const entry of automatedCases()) { } if (entry.mockArtifact) { - await page.route('**/api/chat', async (route) => { + await page.route('**/api/runs', async (route) => { + await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' }); + }); + await page.route('**/api/runs/*/events', async (route) => { const artifact = `` + entry.mockArtifact!.html + @@ -121,7 +124,10 @@ for (const entry of automatedCases()) { } if (entry.flow === 'question-form-selection-limit') { - await page.route('**/api/chat', async (route) => { + await page.route('**/api/runs', async (route) => { + await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' }); + }); + await page.route('**/api/runs/*/events', async (route) => { const form = [ '', JSON.stringify( @@ -169,7 +175,10 @@ for (const entry of automatedCases()) { if (entry.flow === 'question-form-submit-persistence') { let requestCount = 0; - await page.route('**/api/chat', async (route) => { + await page.route('**/api/runs', async (route) => { + await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' }); + }); + await page.route('**/api/runs/*/events', async (route) => { requestCount += 1; const chunk = requestCount === 1 @@ -203,7 +212,7 @@ for (const entry of automatedCases()) { `data: ${JSON.stringify({ chunk })}`, '', 'event: end', - 'data: {"code":0}', + 'data: {"code":0,"status":"succeeded"}', '', '', ].join('\n'); @@ -314,7 +323,7 @@ async function sendPrompt( await expect(input).toHaveValue(prompt, { timeout: 1500 }); await expect(sendButton).toBeEnabled({ timeout: 1500 }); const chatResponse = page.waitForResponse( - (resp) => resp.url().includes('/api/chat') && resp.request().method() === 'POST', + (resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST', { timeout: 2000 }, ); await sendButton.evaluate((button: HTMLButtonElement) => button.click()); @@ -329,7 +338,7 @@ async function sendPrompt( await expect(input).toHaveValue(prompt, { timeout: 1500 }); await expect(sendButton).toBeEnabled({ timeout: 1500 }); const chatResponse = page.waitForResponse( - (resp) => resp.url().includes('/api/chat') && resp.request().method() === 'POST', + (resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST', { timeout: 2000 }, ); await sendButton.evaluate((button: HTMLButtonElement) => button.click()); diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index 07053c494..5077f1c6c 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -7,11 +7,50 @@ export interface ChatRequest { message: string; systemPrompt?: string; projectId?: string | null; + conversationId?: string | null; + assistantMessageId?: string | null; + clientRequestId?: string | null; + skillId?: string | null; + designSystemId?: string | null; attachments?: string[]; model?: string | null; reasoning?: string | null; } +export interface ChatRunCreateRequest extends ChatRequest { + projectId: string; + conversationId: string; + assistantMessageId: string; + clientRequestId: string; +} + +export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'; + +export interface ChatRunCreateResponse { + runId: string; +} + +export interface ChatRunStatusResponse { + id: string; + projectId: string | null; + conversationId: string | null; + assistantMessageId: string | null; + agentId: string | null; + status: ChatRunStatus; + createdAt: number; + updatedAt: number; + exitCode?: number | null; + signal?: string | null; +} + +export interface ChatRunListResponse { + runs: ChatRunStatusResponse[]; +} + +export interface ChatRunCancelResponse { + ok: true; +} + export interface ChatAttachment { path: string; name: string; @@ -35,6 +74,9 @@ export interface ChatMessage { agentId?: string; agentName?: string; events?: PersistedAgentEvent[]; + runId?: string; + runStatus?: ChatRunStatus; + lastRunEventId?: string; startedAt?: number; endedAt?: number; attachments?: ChatAttachment[]; diff --git a/packages/contracts/src/api/projects.ts b/packages/contracts/src/api/projects.ts index 361ee8b12..e9c62ee41 100644 --- a/packages/contracts/src/api/projects.ts +++ b/packages/contracts/src/api/projects.ts @@ -2,6 +2,21 @@ import type { ChatMessage } from './chat'; export type ProjectKind = 'prototype' | 'deck' | 'template' | 'other'; +export type ProjectDisplayStatus = + | 'not_started' + | 'queued' + | 'running' + | 'awaiting_input' + | 'succeeded' + | 'failed' + | 'canceled'; + +export interface ProjectStatusInfo { + value: ProjectDisplayStatus; + updatedAt?: number; + runId?: string; +} + export interface ProjectMetadata { kind: ProjectKind; fidelity?: 'wireframe' | 'high-fidelity'; @@ -22,6 +37,7 @@ export interface Project { designSystemId: string | null; createdAt: number; updatedAt: number; + status?: ProjectStatusInfo; pendingPrompt?: string; metadata?: ProjectMetadata; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cae59747e..6d8dd1f57 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,3 +10,4 @@ export * from './api/registry'; export * from './sse/common'; export * from './sse/chat'; export * from './sse/proxy'; +export * from './prompts/system'; diff --git a/packages/contracts/src/prompts/deck-framework.ts b/packages/contracts/src/prompts/deck-framework.ts new file mode 100644 index 000000000..e1b892814 --- /dev/null +++ b/packages/contracts/src/prompts/deck-framework.ts @@ -0,0 +1,374 @@ +/** + * Stable deck framework injected into the system prompt when the active skill + * mode is `deck`. The whole point: stop regenerating the scale-to-fit JS, the + * keyboard handler, the slide visibility toggle, the counter, and the print + * rules each turn — every regeneration has subtly different bugs (focus is + * wrong, scaling drifts inside the iframe wrapper, arrow keys swallowed). + * + * Two pieces ship together: + * - DECK_SKELETON_HTML : the literal scaffold the model copies verbatim. + * - DECK_FRAMEWORK_DIRECTIVE : the prompt fragment that tells the model + * what is fixed and what they're allowed to change. + * + * Pattern: 1920×1080 fixed canvas centered in the viewport via `display:grid; + * place-items:center`, scaled with `transform: scale()` whose factor is + * recomputed on every resize. Slides are `
` inside + * the stage, only `.slide.active` is visible. Prev/next + counter live + * OUTSIDE the scaled stage so they don't shrink with it. + * + * Why this pattern (not horizontal scroll-snap): + * - It matches what the model has the strongest prior on, so the framework + * gets adopted verbatim instead of being "blended" with the model's own + * instincts (which is what produced the drift in the first place). + * - 1920×1080 is the canonical slide canvas. Designs scale predictably. + * - Print becomes trivial: render every slide as block, page-break between. + * + * Drift fixes baked in: + * - `transform-origin: top left` and the stage is positioned by grid + + * place-items, so scaling never shifts content sideways inside the + * OD viewer's nested transform wrapper. + * - Capture-phase keydown on BOTH window and document so iframe focus + * quirks can't swallow arrow keys. + * - Auto-focus body on load and on every click. + * - localStorage position restored on load. + * - Print stylesheet shows every slide as a 1920×1080 page-broken block, + * producing a multi-page vertical PDF on Save-as-PDF. + */ + +export const DECK_SKELETON_HTML = ` + +
+ + + <!-- SLOT: deck title --> + + + + +
+
+ + + +
+ +
+ +
+ +
+ + + +
+
+ + + +
← / → · space
+ + + +`; + +export const DECK_FRAMEWORK_DIRECTIVE = `# Slide deck — fixed framework (this is non-negotiable for deck mode) + +Decks regress when each turn re-authors the scale-to-fit logic, the keyboard handler, the slide visibility toggle, the counter, and the print rules. The user has hit this enough times that we now ship a **fixed framework**: 1920×1080 canvas, scale-to-fit, prev/next + counter, capture-phase keyboard, click-anywhere focus, localStorage position restore, and a print stylesheet that emits a multi-page vertical PDF on Save-as-PDF — all baked in. + +**You do not write any of that. You do not modify any of that.** Your job is to fill content slots only. + +## Workflow — copy framework first, then fill content + +When the user asks for slides, your TodoWrite plan **must** start with "copy the deck framework verbatim" before any content step. The intended order is: + +\`\`\` +1. Bind the active direction's palette + fonts to :root in the framework +2. Copy the canonical skeleton below as index.html (nothing else first) +3. Plan the slide arc and theme rhythm (state aloud before writing) +4. Add per-deck classes inside the second