Skip to content

chat panel: move the inline webview script into real modules (#215) - #218

Merged
zo-sol merged 1 commit into
IQCoreTeam:mainfrom
RemilioNubilio:feat/panel-modules
Sep 7, 2026
Merged

chat panel: move the inline webview script into real modules (#215)#218
zo-sol merged 1 commit into
IQCoreTeam:mainfrom
RemilioNubilio:feat/panel-modules

Conversation

@RemilioNubilio

Copy link
Copy Markdown
Contributor

chat panel: move the inline webview script into real modules (#215)

What changed

The 4,248-line inline <script> template literal in packages/core/src/chat/ui/webview.ts (W:2147-6394 at 6ee005e2) is now 30 ES modules under packages/core/src/chat/ui/panel/ (including state.ts, host.ts, dom.ts; three unit specs sit beside them). They are typechecked against the DOM lib by pnpm typecheck:panel (own tsconfig.json, non-strict on purpose: the script was never typed; flipping strict per file is the follow-up ratchet), bundled by packages/core/scripts/buildPanel.ts (tsup's programmatic IIFE build, esbuild 0.27.7 from the lockfile, treeshake: false because tsup's rollup pass rewrote data in the experiment), and inlined by chatHtml() from the committed artifact.

Build order: the panel script is now a generated, committed artifact (packages/core/src/chat/ui/panel.generated.ts, produced by pnpm build:panel from packages/core/src/chat/ui/panel/), and chatHtml() inlines it at call time exactly as it already inlines mdLibs.generated.ts. The surface builds and the publish path (tsup in 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 running pnpm build:panel and committing the result, and CI's panel.generated.spec.ts rebuilds 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, no arguments aliasing, no octal literals, no unqualified delete, no top-level this, no block-level function declarations read outside their block) and found none; implicit globals would fail typecheck: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, as casts, ? markers on parameters the legacy callers already omitted), the S.<name> rewrite of the 75 top-level lets 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 in main.ts verbatim, after the wire*() calls in the legacy statement order, so the boot post order (ownedSkills, getSkillShopping, getRpcStatus, ready, wallet, getBalance) and the registration order of the shared document listeners are unchanged.

surfaces/vscode/package.json, surfaces/vscode/tsup.config.ts, install-guide/vscode.md: untouched.

Bundle size, before and after

  • Shipped panel script before: 286,147 bytes / 4,301 lines (the second <script> body of chatHtml() at 6ee005e2, 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).
  • Shipped panel script after: 236,379 bytes / 5,281 lines (PANEL_SCRIPT; the committed panel.generated.ts is 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 const to var, 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)

Deleted Replaced by
panel 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"; appendQuoteText iterates splitQuoteRefs(text) (text segments -> text nodes, ref segments -> marker + card); the per-view accounting (quoteSeen/quoteSlots) stays panel-side
the "change BOTH" mirror comment in notes/quoteRefs.ts one line: the panel imports this module; new export splitQuoteRefs (no cap, no dedupe: those are extractQuoteRefs' job)
AVATAR_SCRIPT (a JS string) and the "string injected into its <script>" header in avatar.ts real hashSeed / avatarSvg exports (AVATAR_SVG kept: the React surface imports it)
SKILL_SIGIL_SCRIPT + the _sk prefix collision paragraph in skillSigil.ts real skillSigilSvg export with module-private hash/rng
panel safeExternalUrl / 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's repo|pull|commit|blob to the panel's Repo|PR|Commit|File (the artifact spec asserts the four strings)
the 11 template interpolations (${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}) and MODEL_OPTIONS_JSON / SLASH_COMMANDS_JSON / INSTALL_COMMANDS_JSON in webview.ts direct imports (MODELS = CHAT_MODEL_OPTIONS etc. keep their local names so the code below them is untouched)
WAND_SVG, PAPERCLIP_SVG, LAYERS_SVG in webview.ts moved to chat/ui/icons.ts, shared by the HTML shell and market.ts
the 4,248-line script <script>${PANEL_SCRIPT}</script>
test-skills.ts "no compile-time typecheck" rationale the check stays, quote-agnostic (esbuild emits double quotes)

Explicitly 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); CLI agoShort vs panel fdAgo; the React walletAvatar.ts / skillSigil.ts re-implementations; typing the panel's message unions with chat/marketMessages.ts; flipping strict in the panel tsconfig.

Tests

  • Goldens captured from the string-shipped code BEFORE it was deleted: avatar.spec.ts, skillSigil.spec.ts (byte-for-byte). quoteRefs.spec.ts gains the splitQuoteRefs cases 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, every CHAT_MODEL_OPTIONS engine and model present), contract (all 53 outbound and 52 inbound types, inbound chain order, boot posts after the listener), shell (two scripts in order, every dom.ts id present).
  • panel.jsdom.spec.ts (CI gate): the real chatHtml() output executed in jsdom with acquireVsCodeApi stubbed 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 through getBlogPost/blogPost, deadlink, QUOTE_REFS_MAX cap, 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 platform WebSocket (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=1 makes a missing browser a failure.
  • CI: PANEL_CHROME_REQUIRED=1 on 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 actual chatHtml() string (both <script> tags, document order, beforeParse to define acquireVsCodeApi before the shim runs) without a browser binary, deterministically on CI. happy-dom was rejected for weaker script and HTML fidelity; @vitest/browser and playwright are heavier than the Chrome pass, which needs nothing. @types/jsdom is needed because the root tsc --noEmit typechecks src/**/*.spec.ts. Nothing else: no esbuild (tsup's API; 0.27.7 already pinned), no ws (global WebSocket), no vitest config file.

The five rules, against the diff

  1. No meaningless wrappers. The wire*() functions are not wrappers: each holds the module's former top-level statements so main.ts can run them in the legacy order (ESM would otherwise order them by import graph, which would reorder listener registration). bundlePanel() and renderPanelModule() 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_CMDS are the legacy local names kept so every call site below them stays untouched; they are aliases with readers, not pass-throughs (the legacy INSTALL_CMDS had no reader left once shell.ts imported the core table, so it is gone). markdownLibs() is untouched.
  2. Reuse before adding. The panel now imports core's quoteRefs, github, avatar, skillSigil, modelOptions, slashCommands, engineInstall in place of its pinned copies. The additions are the minimum the reuse needed: splitQuoteRefs (the renderer needs the segments; extractQuoteRefs caps 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.
  3. No single-use type aliases. None were added. S is a value, the panel tsconfig is non-strict, and the specs type inline; the jsdom spec's Page interface is used by every helper and scenario, the Chrome spec's Cdp class by every step.
  4. No non-essential parameters. 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.
  5. Separated responsibilities. One module per legacy section; leaf modules (host, state, dom, tiers, quoteCard, skeleton, markdown) import nothing from the panel, and format imports only state (explorerTxUrl reads the RPC network, as the legacy function did); feature modules keep their top-level statements inside wire*() and never call each other at module scope; webview.ts is 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 run in packages/core: all files green, including the three new specs (artifact 12, jsdom 7, chrome 6).
  • typecheck:panel: clean. tsc --noEmit in packages/core, surfaces/cli, surfaces/localhost: clean.
  • tsup in surfaces/vscode: builds; dist/extension.js inlines 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 while convert.ts has quoted plain scalars for codex's strict YAML since f38af25. convert.ts is 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 evaluated return 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 the https?:// gate; flagging it here so it is a decision, not a surprise.

  • build:panel then byte-compare: the committed panel.generated.ts is identical to a rebuild.

…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>
@RemilioNubilio

Copy link
Copy Markdown
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.

@zo-sol
zo-sol merged commit 3026aca into IQCoreTeam:main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants