<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Sse on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/tags/sse/</link><description>Recent content in Sse on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 19 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/tags/sse/index.xml" rel="self" type="application/rss+xml"/><item><title>Creative Agent Studio #3 — The 134-Commit Megapush: PR1 Launcher, PR2 Workspace, PR3 SSE, PR4 ApproveGate</title><link>https://ice-ice-bear.github.io/posts/2026-05-19-creative-agent-studio-dev3/</link><pubDate>Tue, 19 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-19-creative-agent-studio-dev3/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #3 — The 134-Commit Megapush: PR1 Launcher, PR2 Workspace, PR3 SSE, PR4 ApproveGate" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-05-18-creative-agent-studio-dev2/" &gt;Previous post: #2 — Bitbucket migration, production-readying, React rewrite begins&lt;/a&gt; ended with the React infrastructure bootstrapped: empty Zustand store, Vite + Tailwind + TS, three primitives, a &lt;code&gt;&amp;lt;T&amp;gt;&lt;/code&gt; i18n component, and a PR1 plan. Twenty-four hours later, &lt;strong&gt;134 non-merge commits&lt;/strong&gt; had landed four PRs in sequence and produced a working React frontend with real database persistence and live SSE streaming.&lt;/p&gt;
&lt;p&gt;The work fell into four PRs and one stack underneath:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PR1 — Launcher&lt;/strong&gt; (projects CRUD, ProjectCard, NewProjectCard, CreateProjectModal, ProjectGrid)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR2 — Workspace shell&lt;/strong&gt; (StageStepper, ProjectSubbar, SessionsRail, Composer, ChatPanel, CanvasPanel placeholder)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR3 — SSE + chat flow&lt;/strong&gt; (chat-stream parser, dispatch-sse mapper, useChatStream hook, AgentAvatar, all 7 FeedItem variants, ChatFeed)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR4 — ApproveSheet + Gates&lt;/strong&gt; (3-mode drawer, ApproveGateCopy / Scenario / ResearchInput variants)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Underneath&lt;/strong&gt; — &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;artifacts&lt;/code&gt;, &lt;code&gt;diff_history&lt;/code&gt; tables; CRUD operations; full REST API; soft-delete semantics&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["#2 — bootstrap (752095f)"] --&gt; DB["DB layer &amp;lt;br/&amp;gt; migrations + CRUD + REST"]
 DB --&gt; PR1["PR1 — Launcher &amp;lt;br/&amp;gt; projects survive reload"]
 PR1 --&gt; PR2["PR2 — Workspace shell &amp;lt;br/&amp;gt; StageStepper + SessionsRail + Composer"]
 PR2 --&gt; PR3["PR3 — SSE chat flow &amp;lt;br/&amp;gt; chat-stream + dispatch-sse + 7 feed variants"]
 PR3 --&gt; PR4["PR4 — ApproveSheet &amp;lt;br/&amp;gt; 3-mode drawer + 3 gate variants"]
 PR4 --&gt; End["#3 end (9a2f851) &amp;lt;br/&amp;gt; restore project gate from artifacts"]&lt;/pre&gt;&lt;p&gt;One running theme — &lt;strong&gt;build the surface bottom-up, and never let a commit cross a layer it shouldn&amp;rsquo;t.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-database-layer--same-day-as-the-ui"&gt;The Database Layer — Same Day as the UI
&lt;/h2&gt;&lt;p&gt;Three migrations and a small handful of CRUD modules made &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;artifacts&lt;/code&gt;, and &lt;code&gt;diff_history&lt;/code&gt; first-class. The first commit (&lt;code&gt;feat(db): add projects, artifacts, diff_history tables via migrations&lt;/code&gt;) set the shape:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- which gate the project last paused at
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- soft delete
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;evidence_uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- §8.2 — provenance
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff_history&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON (nullable for first selection)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three commits later, the CRUD module landed: &lt;code&gt;createProject&lt;/code&gt;, &lt;code&gt;listProjects&lt;/code&gt; (active only, updated_at desc), &lt;code&gt;getProject&lt;/code&gt; (returns deleted rows for audit), &lt;code&gt;renameProject&lt;/code&gt; (partial patch semantics), &lt;code&gt;softDeleteProject&lt;/code&gt; (idempotent — no double-flip of &lt;code&gt;deleted_at&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The idempotency rule was specifically tested in &lt;code&gt;fix(db): softDeleteProject preserves deleted_at on idempotent re-call&lt;/code&gt;. The original implementation called &lt;code&gt;UPDATE ... SET deleted_at = ?&lt;/code&gt; which would overwrite an earlier deletion timestamp if called twice. The fix: &lt;code&gt;UPDATE ... SET deleted_at = ? WHERE deleted_at IS NULL&lt;/code&gt;. Small bug, larger principle — soft-delete is a tombstone, not a flag.&lt;/p&gt;
&lt;p&gt;Then the REST API came up on top in five commits:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /api/projects → createProject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /api/projects → listProjects
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /api/projects/:id → getProject (404 if deleted — fix(api))
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PATCH /api/projects/:id → renameProject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;DELETE /api/projects/:id → softDeleteProject
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A separate fix made GET and PATCH treat soft-deleted projects as 404 (audit-visible via the lower-level CRUD path, but invisible at the REST surface). The diff_history module added &lt;code&gt;appendDiff&lt;/code&gt; with a guard against storing literal &lt;code&gt;'null'&lt;/code&gt; strings when &lt;code&gt;after&lt;/code&gt; was &lt;code&gt;null&lt;/code&gt; — the kind of bug that&amp;rsquo;s only obvious when you find someone&amp;rsquo;s project history showing the string &lt;code&gt;&amp;quot;null&amp;quot;&lt;/code&gt; rendered in the UI.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr1--the-launcher"&gt;PR1 — The Launcher
&lt;/h2&gt;&lt;p&gt;The user&amp;rsquo;s first screen. Project list, create-new affordance, search.&lt;/p&gt;
&lt;p&gt;A handful of structural commits set up the page architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): real projects slice (CRUD)&lt;/code&gt; — &lt;code&gt;useProjects&lt;/code&gt; hook with sort-by-updated&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add userName + setUserName to ui slice&lt;/code&gt; — persisted user name shown in the bilingual greeting&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AppTopbar with logo + lang toggle&lt;/code&gt; — site chrome&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add LauncherTopbar + LauncherHero with bilingual greeting&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add SessionsRail hidden-mode stub (PR2 fills workspace mode)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the actual list and creation flow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectsSearch with bilingual placeholder and count kicker&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectCard with progress bar and brand/status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add NewProjectCard with dashed border and accent hover&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add CreateProjectModal with title + optional brand + validation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): wire ProjectGrid — search filter, modal, navigation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The card progress bar reads from the same &lt;code&gt;gate&lt;/code&gt; field the project advanced to — so the launcher card is a live snapshot of how far along each project is. Click → navigate to &lt;code&gt;/projects/:projectId&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s clean about this PR is its strict layering: every interactive piece reads from the store, the store reads from the REST API, the REST API reads from the CRUD module, the CRUD module reads from SQLite. No shortcuts. The component itself only knows about the slice.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr2--the-workspace-shell"&gt;PR2 — The Workspace Shell
&lt;/h2&gt;&lt;p&gt;The screen users land on after clicking a project card. Three columns: sessions rail on the left, chat in the middle, canvas placeholder on the right. The actual stage-by-stage workflow lives inside this shell.&lt;/p&gt;
&lt;p&gt;Eleven commits, structural:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add stage-labels lib (gate ↔ stage + ko/en short/long labels)&lt;/code&gt; — the canonical mapping of stages to their bilingual labels, used by both the stepper and the canvas&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add Session type&lt;/code&gt; + &lt;code&gt;real workspace slice (sessions + composer)&lt;/code&gt; + &lt;code&gt;store-init test&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add sessionsPanelOpen + toggleSessionsPanel to ui slice&lt;/code&gt; — the rail collapses&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add StageStepper (6 dots + bilingual long-form label)&lt;/code&gt; — the stage progress indicator&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectSubbar wrapping StageStepper&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): fill SessionsRail workspace mode (project header + sessions list + new + back)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add Composer (pill input + send button, console-log submit only)&lt;/code&gt; — chat input, no submission wiring yet&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ChatPanel skeleton (header + welcome bubble + Composer)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add CanvasPanel placeholder (PR5 fills variants)&lt;/code&gt; — explicit deferral&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): assemble WorkspacePage (.shell grid) + swap workspace route&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The placeholder pattern is worth noticing. &lt;code&gt;CanvasPanel&lt;/code&gt; was added as a placeholder that explicitly said &amp;ldquo;PR5 fills variants&amp;rdquo; in the commit message. That deferred a large piece of work without leaving a hole in the layout — the layout was done, the slot was wired, the actual content was a stub that wouldn&amp;rsquo;t ship to users yet. PR5 could land cleanly because PR2 had reserved its address.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr3--sse-plumbing-chat-flow-and-the-seven-feed-variants"&gt;PR3 — SSE Plumbing, Chat Flow, and the Seven Feed Variants
&lt;/h2&gt;&lt;p&gt;This is the PR that turned a static shell into a live app. Twenty-something commits across three layers — store, plumbing, components.&lt;/p&gt;
&lt;h3 id="store-layer"&gt;Store layer
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/store/slices/feed.ts (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FeedItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;assistant&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;streaming&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;agent_progress&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;stage_complete&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;system_error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;task_update_note&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FeedSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;: &lt;span class="kt"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendChunk&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// streaming
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;splitParagraphs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// streaming → final
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pushAgentProgress&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pushStageComplete&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A discriminated union for the seven feed item types, with per-kind reducers on the slice. Plus a &lt;code&gt;pipeline&lt;/code&gt; slice for the gate/stage/context/lastSubmit state, and a &lt;code&gt;workspace&lt;/code&gt; slice that gained &lt;code&gt;tasks&lt;/code&gt; + &lt;code&gt;activeSubAgents&lt;/code&gt; (sink for &lt;code&gt;agent_activity&lt;/code&gt; envelopes).&lt;/p&gt;
&lt;h3 id="plumbing-layer"&gt;Plumbing layer
&lt;/h3&gt;&lt;p&gt;Three pure modules and one hook:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add chat-stream lib (POST /api/chat + SSE parse + Zod-validated envelopes)&lt;/code&gt; — the wire-level parser&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add dispatch-sse mapper (SSEEnvelope → store actions)&lt;/code&gt; — pure mapper, no DOM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add useChatStream hook (submit/cancel + AbortController lifecycle)&lt;/code&gt; — lifecycle owner&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/hooks/use-chat-stream.ts (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;useChatStream() {&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;AbortController&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;null&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;: &lt;span class="kt"&gt;ChatContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;: &lt;span class="kt"&gt;controller.signal&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;dispatchSse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// store action
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The split was: chat-stream parses, dispatch-sse maps, useChatStream owns the lifecycle. Three things, three modules — no module ever did more than one job. Tests for each (&lt;code&gt;tighten test fetch types&lt;/code&gt; etc) shipped on the same day.&lt;/p&gt;
&lt;h3 id="component-layer"&gt;Component layer
&lt;/h3&gt;&lt;p&gt;Once envelopes could land in the store, the components rendered them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentAvatar + fix shared/events runtime exports&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add User/Assistant/Streaming bubble components&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentProgress + StageComplete + SystemError + TaskUpdateNote&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add FeedItem discriminated dispatcher (7 variants)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentStrip ('Running now' active sub-agents)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ChatFeed (auto-scroll + FeedItem map + welcome empty state)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): wire ChatPanel to AgentStrip + ChatFeed (replaces static welcome bubble)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;FeedItem&lt;/code&gt; dispatcher is the discriminated-union pattern paying off — one place that maps every variant to its component, with exhaustiveness enforced by TS.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;AgentStrip&lt;/code&gt; was the materialization of decision 2 from &lt;code&gt;interaction-model.md&lt;/code&gt; — &amp;ldquo;one-line status, no full dashboard.&amp;rdquo; It shows only the currently-running sub-agents as a horizontal strip across the top of the chat. Not a side panel, not a dashboard, just an inline strip that appears when work is happening and disappears when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr4--the-approvesheet-and-the-three-gate-variants"&gt;PR4 — The ApproveSheet and the Three Gate Variants
&lt;/h2&gt;&lt;p&gt;The piece that turned the chat-only flow into a chat + approval flow.&lt;/p&gt;
&lt;p&gt;The ApproveSheet is a bottom drawer with three modes — collapsed (just the gate prompt), half-open (gate + summary), full (gate + everything). Drag-to-resize via pointer events. Esc collapses. Three gate variants drop into the same shell — each gate stage gets its own variant component.&lt;/p&gt;
&lt;p&gt;Twelve commits, structural again:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGate payload types&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add approveSheetMode + setApproveSheetMode to ui slice&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): pipeline slice gains copy/scenario history + idx setters&lt;/code&gt; — the back-pointer state for switching between historical drafts&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): projects slice gains bumpProjectGate + assert PR4 fields in store-init&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): dispatch-sse pushes copy/scenario history + bumps project gate on stage_complete&lt;/code&gt; — the SSE → store glue for gate advance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the drawer mechanics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add useApproveSheetDrag hook + PointerEvent jsdom polyfill&lt;/code&gt; — the drag-handle hook. PointerEvent isn&amp;rsquo;t in jsdom by default, so a polyfill shipped alongside.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveSheet base (3-mode drawer + collapse toggle + drag handle)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the three gate variants:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateCopy (grid + select + approve / request-edit)&lt;/code&gt; — the user picks one of N copy drafts&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateScenario (acts list + approve / request-edit)&lt;/code&gt; — the user approves or revises a multi-act scenario&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateResearchInput (question + answer + send)&lt;/code&gt; — a different shape: the agent asks the user a research question and waits for a typed answer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wired together by &lt;code&gt;ApproveGate&lt;/code&gt; dispatcher (variant selection + submit wiring), then mounted inside &lt;code&gt;ChatPanel&lt;/code&gt; with submit forwarded from &lt;code&gt;WorkspacePage&lt;/code&gt;. The user&amp;rsquo;s flow is now: ask in chat → agent runs → drawer rises with the artifact → approve or revise. Decision 3 from &lt;code&gt;interaction-model.md&lt;/code&gt; (gate-based auto-run between stages) is alive.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-end-of-day-state"&gt;The End-of-Day State
&lt;/h2&gt;&lt;p&gt;The last commit of the day (&lt;code&gt;9a2f851 feat(web): restore project gate from artifacts on workspace mount (Slice K)&lt;/code&gt;) closed an important loop: when the user reloads the workspace page, the project&amp;rsquo;s gate state is restored from its persisted artifacts. The day&amp;rsquo;s work survives a refresh.&lt;/p&gt;
&lt;p&gt;Between the underneath stack and PR1–PR4, the React frontend at end-of-day had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A working launcher (real projects, persisted across reloads)&lt;/li&gt;
&lt;li&gt;A workspace shell with a three-column layout&lt;/li&gt;
&lt;li&gt;Live chat with SSE streaming&lt;/li&gt;
&lt;li&gt;All 7 feed variants rendering&lt;/li&gt;
&lt;li&gt;An ApproveSheet drawer with 3 gate variants&lt;/li&gt;
&lt;li&gt;Project state surviving reload&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mockup wasn&amp;rsquo;t deleted yet, but everything it was supposed to demonstrate now ran in React.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;The day worked because every commit respected its layer. The store slices were declared in PR2 with the shape they&amp;rsquo;d need for PR4. The SSE plumbing in PR3 was three modules — parser, mapper, lifecycle — that could each be tested independently. The ApproveGate variants in PR4 plugged into a dispatcher pattern matching the FeedItem dispatcher from PR3.&lt;/p&gt;
&lt;p&gt;The thing that didn&amp;rsquo;t happen — and was the reason 134 commits in a day was not chaos — was &lt;em&gt;cross-layer commits&lt;/em&gt;. No commit added a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; and a SQL column at the same time. No commit reached up from a CRUD module into a slice. When a commit needed to span layers, it was either preceded by a foundation commit one layer below or followed by an adopter commit one layer above. The discipline turned what would otherwise be a sprint of patchwork into a clean stack of additions.&lt;/p&gt;
&lt;p&gt;The mental model worth keeping: &lt;strong&gt;fast does not mean shortcut.&lt;/strong&gt; This day shipped four PRs because of the discipline that let each PR exist, not in spite of it.&lt;/p&gt;
&lt;p&gt;Next: the canvas panel, the 5-gate workflow, the key-concept planner, and the revise-mode multi-select pattern that would let users surgically re-run a single card instead of the whole stage.&lt;/p&gt;</description></item></channel></rss>