Skip to content

feat(web): LevelCode in your browser — the same workbench and AI extension as a static site, signed in with a LevelCode account - #105

Merged
ndemianc merged 27 commits into
developfrom
feat/web-edition
Oct 14, 2026
Merged

ndemianc merged 27 commits into
developfrom
feat/web-edition

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What

LevelCode in your browser. The same workbench and the same levelcode-ai extension as a static site: open the page, sign in with a LevelCode account (or bring a provider key), and chat with the agent — nothing to install, no server of ours that runs code.

Works in a tab Stays in the Mac app
The workbench: explorer, tabs, themes, search, Quick Open A terminal; run_command; auto-verify commands
Chat, inline completion, the agent (read / search / create / edit / delete) MCP servers that start local processes
Keep / Undo review of the agent's edits Installing extensions (no marketplace in the page)
Sign in with a LevelCode account (gateway) or your own provider key Importing another editor's settings, Settings Sync, update check
A scratch workspace saved in the browser (any modern browser); a folder from your computer (File System Access: Chrome, Edge) The Notepad++ and customization packs, Agent Sketch

The agent is only offered the tools a tab can honour — the shell tools are withheld from the model rather than offered and failing, and the system prompt is built from the same lines so it never promises one.

Companion PRs: backend systemu-net/thin.ly#447 (a callback the editor's page may be sent a code at, CORS for exactly its origins, GET /web_editor) and the account site's entry points systemu-net/onetime#98 (/web, "Open in browser", docs). All three are off until the backend is configured.

How it fits

docs/WEB.md is the reference (architecture, sign-in sequence, build, the three origins, headers, security model, what was not run). In short:

  • scripts/build-web.mjs → one static directory: Code-OSS's web client (vscode-web-min-ci), the browser build of levelcode-ai, the scratch-workspace extension, the declarative built-ins, index.html with its configuration baked in, _headers and an nginx fragment. Everything but the two HTML pages is under a build-addressed /_/<id>/ and cached for a year.
  • web/ — the page (main.js: the embedder), the sign-in callback, the scratch workspace (levelcode-scratch: over IndexedDB, with file and text search), the browser build of the extension (esbuild + shims), the dev server, the tests.
  • The extension is the same source. Its Node-only assumptions are handled in two places: small shims for fs/path/os/crypto/child_process (private state only), and extensions/levelcode-ai/host.js for the workspace (async reads through the editor's file system, capability flags). On the desktop every host function does what the call site did before, behind await; nothing outside host.js asks "am I in a browser?".
  • Sign-in leaves the tab and comes back to it (a pop-up from a webview is what Safari and phones refuse): ?signin=1 / "Sign in" → <account>/ai/login with the existing PKCE flow → /callback.html → back to the editor, which delivers the result to the extension. The PKCE verifier lives in SecretStorage, so it survives the reload.
  • The session store belongs to every tab. The refresh token is rotated on use; a tab that cached a snapshot would present a token another tab had rotated away and end the session the other just renewed. Reads go to storage every time; a change is read-modify-write of one key under a Web Lock. (Found while writing the docs; it has its own tests.)

Where to look

  • Desktop-affecting (review these first): extensions/levelcode-ai/{agent,extension,reviewSession}.js, providers/anthropic.js, new host.js — 399 insertions, 67 deletions. The changes are workspace reads and writes becoming await host.…, a capability filter on the tool list, and host.openAuth. Anthropic gets its browser-access header only when __LEVELCODE_BROWSER_HOST__ is set.
  • Security-relevant: web/main.js (leaveForSignIn — the one command the page offers an extension, only to <account>/ai/…; the secret store; drain), web/callback.js, web/lib/headers.mjs (the CSP), and docs/WEB.md#security-model.
  • scripts/test-extensions.sh now also runs web/test/unit/*.test.js, so test.yml gates them on every PR.

Verified (commit c6d838b)

Check Result
Unit gate, macOS (scripts/test-extensions.sh) 70 test files pass
Unit gate, Linux, Node 18, container with the network refused 70 test files pass (it found two real gaps macOS hid, both fixed)
End to end, headless Chrome, a release served with its own _headers, against a stand-in account site 28/28 (boot from static files; sign in through the real callback page; agent write / list / search / read / edit against the scratch workspace; no shell offered; Quick Open; Keep and Undo; reload keeps the session and files; a folder opened through a real FileSystemDirectoryHandle, listed, read and written by the agent)
Same-tab sign-in, release 20/20 (no second window; verifier survives the reload; no repeat; ?signin=1 on a signed-in editor does nothing; a second tab uses the same session; a stranded result is delivered, an old one dropped)
Webview and extension host on origins of their own (dev server, --product) 28/28 and 20/20
Bring your own key 5/5: straight from the tab to the provider, with the user's key and no LevelCode call
The real stack (below) 23/23
Mutation checks each guard in main.test.js and workspace.test.js goes red when its code is put back

The real stack, run by hand-driven script in a real headless Chrome: Rails from thin.ly#447 in test mode (email never sent, Stripe answered locally, a throwaway Postgres database and Redis db), the real /ai/login page from onetime#98 (its dev server), and a release of this editor built with --account http://localhost:5199 --api-url https://…:

  • not signed in anywhere → ?signin=1 → the real login page ("Connect the editor to your account…") → email code typed → back in the editor, clean address, "Signed in to LevelCode.", real exchange 200, real profile and models 200;
  • signed in on the account site first → its "Open in browser" link → straight back signed in, nothing typed (POST /ai/authorize_editor, then the exchange);
  • a chat message through the real gateway and model ("ready", gpt-oss-120b, under 0.1 credits) — the request crossed the CORS preflight from the editor's origin;
  • the agent creating hello.txt in the scratch workspace with the real model, both gateway calls 200;
  • a folder opened through the editor's command (the origin-private file system standing in for the OS picker), the agent reading main.py and writing copy.py beside it.

What running it for real found, all fixed in this PR or the companions:

  1. "Open Folder from Your Computer" did nothing useful from the scratch workspace. The workbench picks its own file browser or the browser's picker by the scheme of the default location, which in the scratch workspace is ours. The command now asks from the file scheme (5393933).
  2. The build id did not cover the scratch-workspace extension, the shims, media or skills, so a changed file would have been served stale for a year under an "immutable" prefix (9c86c94).
  3. The account app's dev server answered POST /ai/authorize_editor with the SPA shell, so "Open in browser" could not work under npm run dev (onetime#98); and the login page treated the editor's callback page as a custom-scheme app launch, with a fallback that says the editor "may not be installed on this device" (onetime#98).

The editor's own end-to-end checks use a stand-in backend (web/test/stub-backend.mjs) that applies the redirect and CORS rules the real one must; the real rules are thin.ly#447's specs (785 examples, CI green). The real-stack script needs the other repositories' internals and is not part of this repo; docs/WEB.md has the steps to do it by hand.

A cold first load fetches about 20 MB uncompressed (5 MB gzip) over 147 files from the editor's origin, plus the webview host page; the release directory is about 195 MB.

Review round (633d0a8, 2026-10-14)

All 16 review threads are answered in place and resolved. The changes in behaviour:

  • Sign-in links. The callback page used to pass every parameter on, token= included. A crafted link, followed by opening the editor, could sign the user in to someone else's account. The page now passes on only a LevelCode sign-in's one-time code. In a tab, the extension exchanges a code only while a sign-in it started is pending. The desktop is unchanged.
  • Where a sign-in comes back to. A tab that leaves for the sign-in returns to the folder or workspace it left, not the scratch workspace.
  • Secrets without IndexedDB. When there is no persistent store, the tab's secrets survive the sign-in navigation.
  • Two tabs at once. Two tabs starting together share one sealing key instead of each making its own.
  • Search and the agent in a tab. Search stays inside the folder searched. Diagram links and inline completions work in the scratch workspace. Keep/Undo cards read back after a reload reach the chat.
  • The scratch workspace. A rename or copy over a folder replaces it whole. "Open a folder from your computer" is there even when storage is refused.
  • Hosting. The build id is now a hash of everything the build staged. The nginx block serves /.

Verified: the unit gate passes, 75 files on Node 24 and on Node 18. Headless Chrome against a release: e2e 28/28, same-tab sign-in 20/20, bring your own key 5/5. Not run: a real nginx (Docker was unavailable), Safari, Firefox.

These fixes are merged up into feat/web-look (#106) and feat/web-shell (#107).

Not run

  • The desktop app with this branch (./scripts/run-dev.sh): the suites that run agent.js and reviewSession.js against stand-ins pass, but I did not drive the real editor. Please do before merging.
  • Safari, Firefox, phones — Chrome only.
  • The operating system's folder picker itself (it needs a person), and the browser's permission prompt for a persisted folder handle after a reload.
  • A deployed account site, another provider's CORS, and production hosting (DNS, wildcard certificate, CDN, the real headers).
  • A long session across the 8-hour access-token boundary in a tab: the logic is the desktop's, unchanged.
  • A .github/workflows job for the web build and its end-to-end checks: not added (a Linux bootstrap and Chrome in CI is a separate piece, and I would not ship one I had not run).

Decisions that are yours

  1. The editor's domain — not a subdomain of the account host (the account cookie is SameSite=Lax, a site boundary; see docs/WEB.md#deploying). Which registrable domain, and who holds it.
  2. The webview origin — keep Code-OSS's CDN (no setup; a dependency on its hosting of this build's published page) or self-host a wildcard origin (*.view.example.com + certificate).
  3. Isolating the extension host — worth it before any third-party extension is ever allowed; unnecessary while the page loads only LevelCode's.
  4. Where entry points point — every "Open LevelCode" link carries ?signin=1.
  5. The support statement — Chrome/Edge for folders, scratch everywhere else, phones unstated until run.

… can do

The agent read and wrote the workspace with synchronous fs and assumed it could start
programs. LevelCode is about to run in a browser tab, where the project is whatever the
editor's file system provider says it is and there is no shell.

host.js is the one place that difference lives. On the desktop every function does what the
call site did before, behind await; capabilities (shell, mcpStdio, ripgrep) are all true.
Where a capability is missing the tool is withheld rather than offered and left to fail, the
system prompt says what is missing, and content search falls back to a bounded scan.

resolveWorkspacePath stays (the suites pin it); resolveWorkspacePathAsync is the same
resolution for hosts that can only ask asynchronously. A workspace rooted at '/' now
contains its files.

All 63 suites pass on macOS.
…ch workspace and the browser build of levelcode-ai

A static Code-OSS web build plus three things of ours:

- web/main.js: the embedder entry a static host needs (Code-OSS's own workbench.ts is written
  for its server). Same URL-callback and workspace-URL contracts; secrets sealed with a
  non-extractable AES-GCM key in IndexedDB; opens the scratch workspace when no folder is given.
- web/workspace: the scratch workspace, a FileSystemProvider over IndexedDB shared by every tab.
- web/ai-extension: levelcode-ai bundled for the web worker extension host by esbuild with
  small shims (path, os, crypto incl. a synchronous SHA-256, child_process that refuses, and an
  in-memory fs made durable in IndexedDB). The desktop extension stays plain JS.

web/test/e2e.mjs drives a real headless Chrome: boot, sign in through the real PKCE flow and
callback page against a stand-in backend that applies the same redirect and CORS rules, ask the
agent to create a file, reload.
…heck covers the agent's file tools

Quick Open, the Search view and workspace.findFiles (the agent's list_files) wait forever for a
provider registered for the folder's scheme. The scratch workspace now registers a file and a
text search provider (proposed APIs, declared by this built-in extension).

e2e: list, search, read and edit through the real tool loop, Quick Open, and a wait for the run
to finish between prompts (the send button is the stop button while a run is active).
…nd unit suites for the stand-ins

- web/ai-extension/manifest.js: the desktop manifest minus the commands, settings and welcome
  copy that need a shell, a disk or a local process; a key the browser lets through (Ctrl+Shift+I
  is developer tools on Windows and Linux).
- unit suites in the gate (scripts/test-extensions.sh now also runs web/test/unit): path and
  crypto are compared with Node over many inputs (the comparison found three differences in the
  first path shim), fs for the behaviours and error codes the extension relies on, host.js for
  both modes, the manifest for dangling references.
- the e2e also runs with webviews and the extension host on origins of their own, where the
  extension's requests come from a per-session v--<hash> origin.
…t's HTTP policy

Builds Code-OSS's web client (or takes a prebuilt one), bundles levelcode-ai for the web
worker extension host, stages the scratch workspace and the declarative built-ins, and lays it
all out under a build-addressed prefix (/_/<id>) so it caches for a year. index.html is
rendered with its configuration baked in. _headers and an nginx fragment carry the same CSP
and cache policy; web/serve.mjs --dist serves a release with them, and the end-to-end check
runs under that policy.

Findings that shaped it, each checked in a real browser:
- the checkout's web packaging emits only the ~20 extensions with a browser entry, so every
  declarative one (grammars, themes, icons) is staged here and loaded as an additional built-in;
- a marketplace is on by default and would install third-party code into the page's origin:
  the release sets extensionsGallery to null;
- each webview needs its own subdomain, so a self-hosted webview origin has to be a wildcard;
  the default is Code-OSS's CDN at this build's own commit (pinned insider commit is a service
  worker version behind);
- the Copilot status item is created before the web-only hide rule runs: hidden by CSS;
- the scratch workspace is /scratch, so the window and Explorer are not called "/".
…d check

- web/ai-extension/copy.js: the three desktop sentences about the OS keychain and 'your
  machine' are rewritten for a tab at build time, and the build fails if one is no longer in
  the source, so the browser never ships a stale claim about where a secret lives.
- web/test/e2e-byok.mjs: set a key through the real command, ask the agent, and see the request
  go from the tab straight to the provider with that key and no LevelCode account call.
- the stand-in backend gains an OpenAI-compatible provider endpoint with the CORS real ones send.
…gainst a release

Undo of a file the agent created removes it, and so does everything else still pending from the
session; Keep clears the bar. The account card that sign-in leaves open is closed the way a user
would, before the chat is clicked.
A pop-up opened from a chain that begins in a webview is the first thing a strict browser (Safari, a
phone) refuses. The editor now takes its own tab to the account site's sign-in and the sign-in
returns to the same tab:

- host.openAuth: in the browser the extension asks the page to navigate (levelcode.web.openAuthUrl,
  an embedder command) with the address the editor's own opener would have used; without the command
  it opens as before. The desktop is untouched.
- web/main.js registers the command. It only goes to the account site's /ai/ pages: any extension in
  the page can call it, and it must not be an open redirect.
- callback.html returns the tab to the editor when this tab left it (a sessionStorage mark), and
  otherwise says to close the tab and offers a link in.
- At start-up the editor delivers a result the sign-in left in localStorage (a callback.html result
  written in the last two minutes: the one-time code lives for one), and ?signin=1 — the account
  site's "Open in browser" — starts the sign-in once and removes itself from the address.

Tests: unit (host.openAuth; main.js's leaveForSignIn, drain and start-up, mutation-checked) and an
end-to-end in headless Chrome (web/test/e2e-signin-tab.mjs): no second window, the verifier survives
the reload, no repeat, no sign-in for a signed-in editor, a result waiting for a gone editor is
delivered and an old one is dropped.
…ted it

The refresh token is rotated on use. The browser secret store loaded everything once and rewrote
the whole thing on every change, so a second tab held the token the first had already rotated away:
its renewal was refused, ended the session the first tab had just renewed, and wrote its
start-up snapshot back over the first tab's tokens.

Now nothing is cached. A read is a read of what is stored; a change reads, changes ONE key and writes,
under a lock shared by the tabs (Web Locks; without it a tab still applies its own changes one at a
time). A browser that refuses the write keeps the session in memory for that tab rather than
completing a sign-in that is not there.

Tests: unit (two tabs over one store, the rotated token, concurrent writers, unreadable store, no
key, refused write — each mutation-checked) and a second tab in the end-to-end.
Comment thread extensions/levelcode-ai/agent.js Fixed
Comment thread web/test/e2e-byok.mjs Fixed
Comment thread web/test/e2e.mjs Fixed
Comment thread web/test/e2e.mjs Fixed
…cker

Run for the first time against a real browser session and the real login page, the command did nothing useful from
the default scratch workspace: the workbench chooses between its own file browser and the browser's picker by the
scheme of the default location, and in the scratch workspace that scheme is ours — so it showed the scratch
workspace's own folders. The command now asks the dialog from the `file` scheme, which the web workbench serves with
the File System Access handles the user picks, and opens what came back in this tab.

Tests: a unit test of the command against a stand-in for vscode (mutation-checked), and a section of the
end-to-end check that opens a folder through a real FileSystemDirectoryHandle (the origin-private file system
stands in for the OS picker) and has the agent list, read and write there.
A release puts everything under /_/<id>/ and tells browsers it never changes. The id was a hash of the client, the
page and the extension's top-level .js files, so a changed scratch-workspace extension, shim, media file or skill
kept its id — and would have been served stale, from cache, for a year. The hash now covers the extension's whole
tree, its browser build, the scratch workspace and the page's configuration code.
The extension already supports an API host that is not the account origin (levelcode.cloud.apiUrl); the build can
now bake it in, which is what a local account site with the backend behind a tunnel needs. docs/WEB.md says how to
run the real stack locally and what to look for, and what was and was not run.
The browser edition now opens on the chat the way a chat app does, and one click
gives the editor layout (Explorer, files, chat docked on the right):

- web/theme/levelcode-web-theme: "LevelCode Web Dark" / "LevelCode Web Light",
  following the system; initialColorTheme paints the same colours before the
  theme has loaded. levelcode-themes' pinned default is stripped from the staged
  copy (an extension's configurationDefaults outrank the embedder's).
- web/ai-extension/skin*: a centred greeting with starters, a pill composer in a
  768px reading column and the conversation list docked as a rail from 1000px,
  cut into the browser build's COPY of media/chat.html at build time and scoped
  under body.lc-web. The desktop page and its suites are untouched.
- web/chrome.css, config defaults: floating panels, activity bar as a row, no
  source control / run / extensions, no Outline / Timeline, no minimap; the
  workbench's own chat view is told it is disabled; no Welcome walkthrough.
- web/workspace: levelcode.web.layout (chatFirst | split), a status-bar switch,
  three commands, Cmd/Ctrl+Alt+L. A docked layout is docked again after a reload.

Tests: skin, theme, layout and copy unit suites (mutation-checked), main.js's
new helpers, and web/test/e2e-look.mjs (21 checks, headless Chrome).
An agent review that RAN the build (44 findings, each independently
verified) found real defects in the first commit. Fixed:

Layout (web/workspace)
- The switch read the tab model before the extension host had the chat's
  tab, so closing the chat and clicking "Editor + chat" left it undocked:
  wait on onDidChangeTabs, and for the chat to be the active editor.
- A later choice now stops an earlier arrangement still waiting (the
  start-up one resumed and put the chat back on the right).
- A reload's restored empty group is closed on the way to chat-first;
  columns, not list order, say which group is on the left.
- Start-up no longer reveals the Explorer or moves focus after the first
  time in a workspace (workspaceState, not globalState).
- The layout commands and the folder command are registered before the
  store is opened, so a browser that refuses IndexedDB still has them.
- Opening a folder from the computer sets the editor layout.
- No default key binding (Cmd+Alt+L is Find in Selection on macOS).

Chat skin
- Card actions revealed on hover only where there is hover; a tap landed
  on rename/delete. Enter/Space resume a card. aria-modal off the docked
  list; a /sessions-opened overlay no longer reappears as a modal.
- Accent text and the focus ring use the link colour (was 2.2-2.7:1).
- The footer row is no longer dimmed with opacity (it dimmed its popover).
- The rail and the account card take the theme's palette; the sign-in
  dialog scrolls in a short window; the empty state is not clipped; the
  chat paints its own ground; starters make sense with no file open.

Workbench
- Code-OSS's own Chat view is hidden for real: create() returns a
  disposable, so workbench.commands was undefined and the first attempt
  never ran. The gate key is set through the module's commands, again
  after the gate's own write at 5 s. The title-bar toggle is hidden.
- editor.padding.top: 24 (the scroll shadow washed out line one).
- No page-level color-scheme: stock webviews (Markdown preview) were
  white under a dark theme.
- The build id covers the declarative built-ins' sources.

Tests: unit 73 files on macOS and Linux Node 18; e2e-look 26 checks, ten
runs in a row; the other end-to-end checks unchanged. The test helper
retries F1 (a key sent while the chat takes the focus is lost).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved authentication, persistence, workspace, and hosting defects prevent safe merging.

12 open findings
What changed in this PR

Adds a static-site browser edition of LevelCode, reusing the Code-OSS workbench and AI extension with browser-compatible storage, authentication, and workspace access.

Changes:

  • Adds browser startup, account sign-in, scratch workspaces, and local-folder access.
  • Adapts AI workspace operations and withholds unsupported shell tools.
  • Adds release packaging, hosting policies, documentation, and browser tests.
File Description
web/​workspace/​package.json Declares the scratch-workspace extension.
web/​workspace/​extension.js Implements persistent scratch files, search, and folder commands.
web/​test/​unit/​workspace.test.js Tests local-folder selection.
web/​test/​unit/​shims.test.js Tests Node compatibility shims.
web/​test/​unit/​manifest.test.js Tests browser manifest adaptations.
web/​test/​unit/​host.test.js Tests desktop and browser host behavior.
web/​test/​unit/​fingerprint.test.js Tests asset fingerprinting.
web/​test/​unit/​copy.test.js Tests browser-specific text replacements.
web/​test/​stub-backend.mjs Supplies a test authentication and AI backend.
web/​test/​e2e-signin-tab.mjs Tests same-tab sign-in and session reuse.
web/​test/​e2e-byok.mjs Tests direct provider-key requests.
web/​test/​cdp.mjs Adds a headless Chrome driver.
web/​serve.mjs Serves development and release assets.
web/​manifest.webmanifest Defines browser app metadata.
web/​main.js Initializes the workbench, callbacks, and secret storage.
web/​lib/​headers.mjs Generates hosting and security headers.
web/​lib/​fingerprint.mjs Hashes asset trees.
web/​lib/​extensions.mjs Stages declarative built-in extensions.
web/​lib/​config.mjs Generates shared page configuration.
web/​index.html Adds the editor entry page.
web/​icon.svg Adds the browser icon.
web/​callback.js Delivers sign-in results to the editor.
web/​callback.html Adds the sign-in return page.
web/​callback.css Styles the callback page.
web/​boot.css Styles startup and hides unused Copilot UI.
web/​ai-extension/​shims/​process.js Supplies browser process compatibility.
web/​ai-extension/​shims/​path.js Implements POSIX path utilities.
web/​ai-extension/​shims/​os.js Supplies browser host metadata.
web/​ai-extension/​shims/​fs.js Persists private extension files in IndexedDB.
web/​ai-extension/​shims/​crypto.js Supplies hashing and randomness.
web/​ai-extension/​shims/​child_process.js Rejects unsupported process execution.
web/​ai-extension/​manifest.js Removes desktop-only contributions.
web/​ai-extension/​inject.js Injects compatible Node globals.
web/​ai-extension/​entry.js Prepares browser storage before activation.
web/​ai-extension/​copy.js Adapts browser messaging and sign-in opening.
web/​ai-extension/​build.mjs Bundles the browser AI extension.
scripts/​test-extensions.sh Includes browser unit suites in the gate.
scripts/​build-web.mjs Assembles the static release.
README.md Introduces the browser edition.
extensions/​levelcode-ai/​test/​diagramHost.test.js Adds host-aware diagram test fixtures.
extensions/​levelcode-ai/​reviewSession.js Adapts edit review to asynchronous workspace access.
extensions/​levelcode-ai/​providers/​anthropic.js Enables browser-specific API headers.
extensions/​levelcode-ai/​host.js Centralizes host capabilities and workspace access.
extensions/​levelcode-ai/​extension.js Adapts retrieval, links, and attachments.
extensions/​levelcode-ai/​agent.js Adds browser file operations and capability filtering.
CLAUDE.md Documents browser architecture and conventions.
.gitignore Excludes generated browser artifacts.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread web/callback.js Outdated
Comment thread web/lib/headers.mjs Outdated
Comment thread extensions/levelcode-ai/extension.js Outdated
Comment thread extensions/levelcode-ai/extension.js
Comment thread extensions/levelcode-ai/reviewSession.js
Comment thread web/main.js
Comment thread web/main.js
Comment thread web/main.js Outdated
Comment thread web/workspace/extension.js Outdated
Comment thread web/workspace/extension.js
…in; search, diagram links, completions and storage work in a tab

Sixteen findings: four from the code-quality check, twelve from Copilot. All held up against the code; each
substantive one is fixed with a test that fails on the code as reviewed.

Signing in
- callback.js forwarded every parameter of the link — `token=` and `refresh=` included — to the extension,
  whose old token-in-the-link branch stored them, and the page's start-up delivers what callback.html left even
  when no sign-in was pending. A crafted link to callback.html, followed by opening the editor, signed the user
  in to the link-maker's account. callback.html now passes on a sign-in to levelcode.levelcode-ai/auth/callback
  and only its one-time code; and in a browser (new capability `host.caps.unaskedSignIn`, off there) the
  extension takes a code only while a sign-in of its own is pending — never a token in a link. The desktop is
  unchanged.
- A sign-in started from a folder or workspace opened from the computer came back to `/`, the scratch
  workspace. The editor notes the address it leaves (minus `signin=1`); callback.html returns there after
  checking it is the editor page of this site.
- With no persistent secret store (no IndexedDB or WebCrypto, or a refused write), the PKCE verifier lived in
  memory and was gone when the tab came back from the sign-in. The tab's secrets now ride through that one
  navigation in sessionStorage and are taken back, and removed, at the next start.
- Two tabs opened at once on a fresh profile could each create and store a different sealing key; each then
  read the other's sealed session as garbage and cleared it. The key is stored in one readwrite transaction,
  only if none is there, and every tab uses the key that is.

The agent in a tab
- The content scans that stand in for ripgrep (agent.js scanSearch, extension.js contentHits) capped the file
  list across the whole workspace before filtering to the folder searched: in a two-folder workspace the other
  folder could use up the cap. Both now search under the folder (RelativePattern) and cap that.
- Diagram links were checked with fs.statSync/realpathSync, which in the browser build reach a shim with no
  workspace in it, so every link was dropped. Drawing now checks against the files the extension has indexed;
  a click checks the file system itself (host.withStats, the async-loader pattern of host.withReads).
- Pending Keep/Undo reviews are read back asynchronously in a browser; a chat that asked for its cards first
  was sent none and never got them. They are sent once they are read.
- Inline completion refused every document that is not `file` or `untitled` — all of the scratch workspace.
  It now asks host.isUserDocument, which in a browser also accepts the workspace folders' own schemes.

The scratch workspace
- A rename or copy over an existing folder deleted only the folder's own entry: its files stayed under the new
  name, visible to search. The whole destination subtree goes in the same transaction; a move over a folder
  that contains the source is refused.
- With IndexedDB unavailable, activation returned before registering "Open a folder from your computer" — the
  one way out the warning names. It is registered first.

Hosting
- The generated nginx block answered 404 at `/`: `try_files $uri` looks for a file, and an exact location keeps
  the request from the fallback. The root rule names /index.html.
- The build id hashed a list of inputs and missed the rest — the declarative built-ins (themes, grammars), the
  web client's CSS, workers and nls — so a changed file was served stale under the same immutable URL. The
  build is now staged first and named after a hash of everything staged. Checked by building: two builds give
  the same id; a change to the client's CSS alone gives a new one (the reviewed script gave the same id).

Code quality: an unused SYSTEM_TOKENS_EST (unused on develop too), an unused import and two unused helpers in
the browser tests.

Tests: new suites callback, signin, headers, search-scope and review-rehydrate; main, host and workspace extend
theirs (fake-idb.js is a small IndexedDB stand-in for them). The unit gate passes, 75 files, on Node 24 and
Node 18. In Chrome against a build with these changes: e2e 28/28, e2e-signin-tab 20/20 (its left-behind-code
case now expects the code to be delivered and NOT exchanged), e2e-byok 5/5. Not run: a real nginx (Docker was
unavailable), Safari, Firefox.
… does

The merge of feat/web-edition replaced the build's list of inputs with a hash of the staging folder.
The test of that list now stages the extensions and hashes the result the same way, and COPY_SKIP —
exported only for the list — goes.
… read in both themes

Both themes' syntax palettes (One Dark / One Light) had colours below WCAG AA's 4.5:1 on their own
editor background. Each one is moved in HSL lightness only (hue and saturation kept, within 2 degrees
after rounding) to the first value that reaches 4.5:1. theme.test.js now holds every tokenColors and
semanticTokenColors foreground of both themes to 4.5:1 on the theme's editor.background (an alpha
colour composited first); it fails on the previous themes, listing each colour.

Dark (editor #1b1c22), review comment 4274117784:
- comments, markup quotes, doc comments / doctype  #5c6370 2.81 -> #7b8493 4.50

Light (editor #ffffff), review comment 4274117904:
- comments, markup quotes                          #a0a1a7 2.58 -> #75767e 4.52
- strings, inherited class, inline/fenced code     #50a14f 3.21 -> #428541 4.50
- regexp, escapes, support types                   #0184bc 4.18 -> #017eb4 4.52
- variables, properties, tags, headings, JSON keys #e45649 3.67 -> #df3425 4.51
- functions, decorators, links                     #4078f2 4.05 -> #326ef1 4.53
- classes, types, CSS tags                         #c18401 3.20 -> #9f6d01 4.50
- constants, attribute names, bold                 #986801 4.87 -> #765101 7.12
  (passed already; darkened with the class colour, which shares its hue and would otherwise be
  indistinguishable from it: CIELAB delta-E 2.9, against 16.2 before and 16.9 now)

Token backgrounds: the editor does not paint them ("Token background colors are currently not
supported", colorThemeSchema.ts), so the light theme's `invalid` scope (white on a #e45649 background)
was white text on the white editor, 1.00:1. `invalid` is now the theme's red text in both themes
(light #df3425 4.51, dark #e06c75 5.32) with no background, and the test fails on any token rule that
sets one.
…of the chat, fills the window in both layouts, and works where a workspace sets it

Review comment 4274117959 (the moves act on whatever is active): whenChatActive resolved on its
timeout as well, so the move commands ran whether or not the chat was the active editor and could move
the file beside it; and in split the second levelcode.ai.focus, after README.md was opened (and became
the editor on show), was followed by the move with no wait at all. Each command that acts on the active
editor (the two moves, and the join below) now goes through one guard: the tab model is read
immediately before it, after waiting up to 1.5 s for the reveal to land; one that cannot be confirmed
is skipped, as is every later one in that arrangement, and so is one overtaken by a newer layout
request while it waited. whenChatActive resolves true/false. The wait for the chat to come to the
front before the window is read at all stays: when the layout is changed by saving the setting from
a settings editor opened in front of the chat, that editor's group is in the tab model until the chat
is focused, and read at once the window looks like two groups already, so nothing is docked (found in
a browser run of this change before it was committed; pinned by a unit case). If that wait times out,
nothing is moved. Tests: chatFirst with the chat never
active does not move; split waits after the README until the chat is active again and does not move if
it never is (also on the reload path); a split waiting to move is dropped when chatFirst is chosen
meanwhile.

Review comment 4274118020 (chatFirst left file groups visible): only empty groups were closed, so a
group with files (a split file editor, files put on the right) kept part of the window. Such groups are
now joined into the chat's group with workbench.action.joinAllGroups, which merges into the active group
and moves the editors in inactive, so no document is closed and the chat stays on show. The remaining
group is then unlocked: a group the chat was docked in is auto-locked, and a lone locked group sends
the next file into a new split. The usual split -> chatFirst switch (the chat alone on the right) is
unchanged. Test: three arrangements with files outside the chat's group are joined and unlocked; no
join when the chat is not active.

Review comment 4274118061 (split in a folder without README.md): with the chat as the only tab nothing
kept the first group, so the move closed it and left one group, auto-locked. An empty untitled document
is opened in column one first (preserveFocus, not a preview) when there is no README and no other tab;
if even that cannot be opened the chat is not moved. The existing test that pinned the old sequence for
this case is changed to the new one.

Review comment 4274118118 (the switch wrote only the user setting): with
levelcode.web.layout in .vscode/settings.json the workspace value wins, so the status item and the
commands did nothing. writeLayout() inspects the setting and updates the workspace value when there is
one, the user's otherwise. A folder-level value is not a third target: the setting is window-scoped,
which VS Code does not read from a multi-root folder (FOLDER_SCOPES), and in a single-folder window
the folder's settings are the workspace's. Tests drive activate() and the registered commands.

The vscode stand-in now always has activeTabGroup: a file shown by showTextDocument is on show until
the chat's reveal lands (a test can hold the reveal back), otherwise the chat is; plus
openTextDocument, inspect() and a workspace layer of settings. On the previous extension.js the 8 new
or changed cases fail and the other 32 pass.
web: a chat-first look, light/dark themes and a layout switch for the browser edition
Comment thread web/test/unit/skin.test.js Fixed
Two conflicts, both the same kind: develop (#103) and this branch each
added to the same lines.

- scripts/test-extensions.sh: develop gates modules/*/test/*.test.js, this
  branch web/test/unit/*.test.js. The gate now discovers all three trees,
  and its header and its "no suites matched" error name all three.
- CLAUDE.md: the scripts/ line lists extension-signature.mjs and
  build-web.mjs; both new sections are kept, "Extensions are verified" and
  then "LevelCode in a tab"; the commit line names modules/ and web/, and
  dist-web/ as never committed.

One break git could not see: extensionSignature.test.js pinned the gate's
loop line exactly, so with the web glob added it failed and stopped the
gate. It now checks what it is there for — modules/*/test/*.test.js is one
of the loop's patterns — and still refuses a loop without it. release.yml's
comment on the gate names the web suites too.

The gate passes on the merged tree on macOS with Node 24: 79 test files
(63 extension, 1 module, 15 web), 1,500 cases. Not run on Linux here; the
pull request's CI is that run.
…ring with undefined

`head` is always a string, so `head !== undefined` was always true and the
condition was only ever "depth 0 or 1". Said plainly now. The check behaves
as before: run alone against samples, it still fails on an unscoped rule at
the top level and inside @media, and passes the real stylesheet.
@ndemianc
ndemianc merged commit 9e63850 into develop Oct 14, 2026
2 checks passed
@ndemianc
ndemianc deleted the feat/web-edition branch October 14, 2026 19:19
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