chat panel: move the inline webview script into real modules (#215) - #218
Merged
Merged
Conversation
…eam#215) The 4,248-line inline script in chat/ui/webview.ts becomes 32 ES modules under chat/ui/panel/, typechecked against the DOM lib, bundled by scripts/buildPanel.ts (tsup IIFE, treeshake off) into the committed panel.generated.ts, and inlined by chatHtml() exactly as it inlines mdLibs.generated.ts. No behavior, message, string, or DOM change: bodies moved verbatim, the 78 top-level lets became one state object, boot order and boot posts are preserved in main.ts. The panel now imports core's quoteRefs, github, avatar, skillSigil, modelOptions, slashCommands, and engineInstall in place of its pinned copies; every change-BOTH comment is gone. Tests: goldens for the avatar and sigil code captured before the strings were deleted, unit specs for the pure pieces, an artifact spec (freshness, shape, message contract, shell ids), a jsdom pass executing the real chatHtml() output (boot posts, every inbound type, composer send, slash menu, streaming, quote cards, deadlinks), and a headless Chrome pass over the DevTools protocol with real layout. jsdom is the one new devDependency (packages/core only). Build order: pnpm build:panel regenerates the committed artifact; the surface builds and the publish path are unchanged, and the freshness spec fails CI when the artifact is stale. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
Author
|
Quick update: I smoke-tested this PR’s build in a real VS Code Extension Development Host: chat, session switching/restoration, feed loading, and quote navigation all passed. I also have small local fixes with regression checks for the pre-existing feed-image URL bug and the repository-link validation fallback in #208, plus component-test reproductions of the draft-loss and stale-response issues. I’ll keep those follow-ups separate and hold off opening more PRs while this one is under review, to keep review manageable. Optional type cleanup and #209 are parked for now. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
chat panel: move the inline webview script into real modules (#215)
What changed
The 4,248-line inline
<script>template literal inpackages/core/src/chat/ui/webview.ts(W:2147-6394 at6ee005e2) is now 30 ES modules underpackages/core/src/chat/ui/panel/(includingstate.ts,host.ts,dom.ts; three unit specs sit beside them). They are typechecked against the DOM lib bypnpm typecheck:panel(owntsconfig.json, non-strict on purpose: the script was never typed; flippingstrictper file is the follow-up ratchet), bundled bypackages/core/scripts/buildPanel.ts(tsup's programmatic IIFE build, esbuild 0.27.7 from the lockfile,treeshake: falsebecause tsup's rollup pass rewrote data in the experiment), and inlined bychatHtml()from the committed artifact.Build order: the panel script is now a generated, committed artifact (
packages/core/src/chat/ui/panel.generated.ts, produced bypnpm build:panelfrompackages/core/src/chat/ui/panel/), andchatHtml()inlines it at call time exactly as it already inlinesmdLibs.generated.ts. The surface builds and the publish path (tsupin surfaces/vscode,vsce package->vscode:prepublish) are unchanged and read the committed file, so no new step is needed to reproduce a release; editing the panel means runningpnpm build:paneland committing the result, and CI'spanel.generated.spec.tsrebuilds the bundle and fails when the committed artifact is stale.One deliberate semantic change, stated up front: the bundle runs in strict mode (ES modules are strict; the legacy inline script was sloppy mode). The plan audited the script for sloppy-only constructs before the cut (no
with, noargumentsaliasing, no octal literals, no unqualifieddelete, no top-levelthis, no block-level function declarations read outside their block) and found none; implicit globals would failtypecheck:panel(Cannot find name) and the jsdom and Chrome passes run every scenario with zero page errors. The build emits the"use strict"directive explicitly (a banner) so the contract is deliberate, not inherited from a tsconfig.No other behavior, message, string, or DOM change. Function bodies moved verbatim; the only edits inside them are erasable TypeScript (annotations,
ascasts,?markers on parameters the legacy callers already omitted), theS.<name>rewrite of the 75 top-levellets into one state object (state.ts; ESM import bindings are read-only and ten sections write each other's state from the message listener), and the import substitutions listed under Deletions. The message listener and the boot posts sit inmain.tsverbatim, after thewire*()calls in the legacy statement order, so the boot post order (ownedSkills, getSkillShopping, getRpcStatus, ready, wallet, getBalance) and the registration order of the shareddocumentlisteners are unchanged.surfaces/vscode/package.json,surfaces/vscode/tsup.config.ts,install-guide/vscode.md: untouched.Bundle size, before and after
<script>body ofchatHtml()at6ee005e2, i.e. the JS the browser evaluated including the interpolated avatar, sigil, model, slash, and install data; the template source alone was 238,448 bytes).PANEL_SCRIPT; the committedpanel.generated.tsis 248,602 bytes as a JSON string literal).chatHtml()output: 543,978 -> 494,210 bytes.webview.ts: 6,397 -> 2,140 lines.The bytes are not identical because esbuild drops comments, rewrites top-level
consttovar, normalizes quotes to double, and wraps each module; the avatar and sigil code and the model/slash/install data that used to be interpolated are now inside the bundle. Strings, DOM structure, and message shapes are unchanged and pinned by the specs below (the Korean UI strings stay literal:charset: "utf8").Deletions (pinned copies and change-BOTH comments)
QUOTE_REF_RE+ its pinned-copy / change-BOTH comment lines,const QUOTE_REFS_MAX = 4,quoteRefWallet(id)import { splitQuoteRefs, parseNoteRef, QUOTE_REFS_MAX } from "notes/quoteRefs.js";appendQuoteTextiteratessplitQuoteRefs(text)(text segments -> text nodes, ref segments -> marker + card); the per-view accounting (quoteSeen/quoteSlots) stays panel-sidenotes/quoteRefs.tssplitQuoteRefs(no cap, no dedupe: those areextractQuoteRefs' job)AVATAR_SCRIPT(a JS string) and the "string injected into its <script>" header inavatar.tshashSeed/avatarSvgexports (AVATAR_SVGkept: the React surface imports it)SKILL_SIGIL_SCRIPT+ the_skprefix collision paragraph inskillSigil.tsskillSigilSvgexport with module-privatehash/rngsafeExternalUrl/parseGithubLink+ "Mirror of packages/core/src/links/github.ts"import { parseGithubLink, safeExternalUrl } from "links/github.js";GH_KIND_LABEL(four literals next to the one display site) maps core'srepo|pull|commit|blobto the panel'sRepo|PR|Commit|File(the artifact spec asserts the four strings)${SLASH_COMMANDS_JSON},${MODEL_OPTIONS_JSON},${INSTALL_COMMANDS_JSON},${JSON.stringify(CODEX_UPDATE_COMMAND)},${JSON.stringify(WAND_SVG/IQ_LOGO_SVG/LAYERS_SVG)},${JSON.stringify(AVATAR_SVG)},${AVATAR_SCRIPT},${SKILL_SIGIL_SCRIPT}) andMODEL_OPTIONS_JSON/SLASH_COMMANDS_JSON/INSTALL_COMMANDS_JSONinwebview.tsMODELS = CHAT_MODEL_OPTIONSetc. keep their local names so the code below them is untouched)WAND_SVG,PAPERCLIP_SVG,LAYERS_SVGinwebview.tschat/ui/icons.ts, shared by the HTML shell andmarket.ts<script>${PANEL_SCRIPT}</script>test-skills.ts"no compile-time typecheck" rationaleExplicitly NOT unified here (each would change one surface's behavior or is outside the panel): CLI
quoteCardLines(trims the title before deciding promotion, the panel does not); CLIagoShortvs panelfdAgo; the ReactwalletAvatar.ts/skillSigil.tsre-implementations; typing the panel's message unions withchat/marketMessages.ts; flippingstrictin the panel tsconfig.Tests
avatar.spec.ts,skillSigil.spec.ts(byte-for-byte).quoteRefs.spec.tsgains thesplitQuoteRefscases and an equivalence run against the retired panel regex (kept only in the spec).panel/format.spec.ts,panel/tiers.spec.ts,panel/quoteCard.spec.ts: the pure pieces.panel.generated.spec.ts: freshness (an in-process rebuild equals the committed artifact), shape (strict IIFE that parses, no</script, nothing node-only, no double-escaped newline, Korean strings literal, under 400 KB, everyCHAT_MODEL_OPTIONSengine and model present), contract (all 53 outbound and 52 inbound types, inbound chain order, boot posts after the listener), shell (two scripts in order, everydom.tsid present).panel.jsdom.spec.ts(CI gate): the realchatHtml()output executed in jsdom withacquireVsCodeApistubbed before the host shim runs; boot posts, every inbound type on minimal payloads, composer Enter ->{ type: "send", text, images }, slash menu on/mod, Escape interrupt, replace-semantics streaming (one bubble, no duplication), feed quote cards (hydrate throughgetBlogPost/blogPost, deadlink,QUOTE_REFS_MAXcap, glued ref ignored),openUrl, sessions toggle, webview-state round trip. No page error allowed in any scenario.panel.chrome.spec.ts: headless Chrome over the DevTools protocol with the platformWebSocket(Node 22+, no driver dependency): zero uncaught exceptions, boot posts, non-zero layout for#input/#log, send on Enter, slash menu visible with height, hydrated quote card with height and title. Skips locally with a printed reason when no Chrome is found (PANEL_CHROME=<path>overrides);PANEL_CHROME_REQUIRED=1makes a missing browser a failure.PANEL_CHROME_REQUIRED=1on the vitest step; a "Typecheck panel (DOM)" step after "Typecheck agent-sdk".New devDependency (packages/core only)
jsdom(^30.0.1) +@types/jsdom(^30.0.0). jsdom is the only way to execute the actualchatHtml()string (both<script>tags, document order,beforeParseto defineacquireVsCodeApibefore the shim runs) without a browser binary, deterministically on CI.happy-domwas rejected for weaker script and HTML fidelity;@vitest/browserand playwright are heavier than the Chrome pass, which needs nothing.@types/jsdomis needed because the roottsc --noEmittypecheckssrc/**/*.spec.ts. Nothing else: noesbuild(tsup's API; 0.27.7 already pinned), nows(globalWebSocket), no vitest config file.The five rules, against the diff
wire*()functions are not wrappers: each holds the module's former top-level statements somain.tscan run them in the legacy order (ESM would otherwise order them by import graph, which would reorder listener registration).bundlePanel()andrenderPanelModule()are the build's two real steps (bundle, then render the module text) and the spec reuses the first.MODELS = CHAT_MODEL_OPTIONS,CODEX_UPDATE_CMD,SLASH_CMDSare the legacy local names kept so every call site below them stays untouched; they are aliases with readers, not pass-throughs (the legacyINSTALL_CMDShad no reader left onceshell.tsimported the core table, so it is gone).markdownLibs()is untouched.quoteRefs,github,avatar,skillSigil,modelOptions,slashCommands,engineInstallin place of its pinned copies. The additions are the minimum the reuse needed:splitQuoteRefs(the renderer needs the segments;extractQuoteRefscaps and dedupes, which is the wrong policy for rendering),quoteCardModel(the panel's title/snippet rule made testable),GH_KIND_LABEL(four literals at the one site where core's kind names meet the panel's display words),icons.ts(three SVGs the shell and the bundle both need). The specs reuse jsdom and the platform WebSocket instead of a driver library.Sis a value, the panel tsconfig is non-strict, and the specs type inline; the jsdom spec'sPageinterface is used by every helper and scenario, the Chrome spec'sCdpclass by every step.bundlePanel()takes nothing;renderPanelModule(js)takes the one thing it renders;buildOptions(outDir)takes the dir because watch mode keeps its own. The?markers (closeMenus(except?),setSkills(names, mints?, meta?),showBuyError(msg, fundable?)) declare the arity the legacy callers already used; no parameter was added or removed.host,state,dom,tiers,quoteCard,skeleton,markdown) import nothing from the panel, andformatimports onlystate(explorerTxUrlreads the RPC network, as the legacy function did); feature modules keep their top-level statements insidewire*()and never call each other at module scope;webview.tsis only the HTML shell; the build script, the committed artifact, and the freshness spec each do one thing; layout assertions live in the Chrome pass, behavior assertions in the jsdom pass, contract assertions in the artifact spec.Verification (run locally, 2026-09-04)
vitest runin packages/core: all files green, including the three new specs (artifact 12, jsdom 7, chrome 6).typecheck:panel: clean.tsc --noEmitin packages/core, surfaces/cli, surfaces/localhost: clean.tsupin surfaces/vscode: builds;dist/extension.jsinlines the panel bundle.test:skills: ALL PASS (31 checks). Two section 2 checks ("authored name kept", "authored user-invocable kept") were failing on main already: they asserted unquoted frontmatter scalars whileconvert.tshas quoted plain scalars for codex's strict YAML since f38af25.convert.tsis untouched; the two assertions are now quote-agnostic anchored regexes with a one-line why-comment, the same treatment section 6 got. Test-only and three lines, easy to drop if you would rather keep that failure visible.One shipped latent bug, pinned not fixed
feedImgUrl(legacy W:4233) carried the only single-backslash escape in the whole template region, so the browser has always evaluatedreturn v && /^https?:/with the rest of the line swallowed as a comment: the function returns the regex object for any non-empty value, and the.test(...)gate never ran. It is moved verbatim (no behavior change is the rule of this PR) and the jsdom scenarios do not assume feed cover images filter by protocol. A one-line follow-up restores thehttps?://gate; flagging it here so it is a decision, not a surprise.build:panelthen byte-compare: the committedpanel.generated.tsis identical to a rebuild.