From 17f85cae47d6ef6b81161ab7c37517545abd4d78 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 10 Oct 2026 13:37:32 -0400 Subject: [PATCH 1/2] web: a chat-first look, light and dark themes, and a layout switch 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). --- CLAUDE.md | 8 +- docs/WEB.md | 51 +- scripts/build-web.mjs | 21 +- web/ai-extension/build.mjs | 6 +- web/ai-extension/copy.js | 5 + web/ai-extension/skin.js | 49 ++ web/ai-extension/skin/chat.css | 117 ++++ web/ai-extension/skin/chat.js | 89 +++ web/chrome.css | 19 + web/lib/config.mjs | 26 +- web/lib/extensions.mjs | 16 + web/main.js | 36 ++ web/test/e2e-look.mjs | 183 ++++++ web/test/e2e.mjs | 8 +- web/test/unit/copy.test.js | 14 + web/test/unit/main.test.js | 45 ++ web/test/unit/skin.test.js | 103 +++ web/test/unit/theme.test.js | 134 ++++ web/test/unit/workspace.test.js | 112 +++- web/theme/levelcode-web-theme/package.json | 28 + .../levelcode-web-theme/themes/web-dark.json | 596 ++++++++++++++++++ .../levelcode-web-theme/themes/web-light.json | 566 +++++++++++++++++ web/workspace/extension.js | 107 +++- web/workspace/package.json | 23 +- 24 files changed, 2345 insertions(+), 17 deletions(-) create mode 100644 web/ai-extension/skin.js create mode 100644 web/ai-extension/skin/chat.css create mode 100644 web/ai-extension/skin/chat.js create mode 100644 web/chrome.css create mode 100644 web/test/e2e-look.mjs create mode 100644 web/test/unit/skin.test.js create mode 100644 web/test/unit/theme.test.js create mode 100644 web/theme/levelcode-web-theme/package.json create mode 100644 web/theme/levelcode-web-theme/themes/web-dark.json create mode 100644 web/theme/levelcode-web-theme/themes/web-light.json diff --git a/CLAUDE.md b/CLAUDE.md index 1bc3149..50090b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ extensions/levelcode-updater/ notify-only update checker (polls the update fe patches/levelcode-core.patch our core source edits, applied on bootstrap scripts/ bootstrap.sh, apply-branding.mjs, run-dev.sh, editor-identity.mjs, build-macos.sh, make-dmg.sh, make-icon.sh, build-web.mjs; atom (CLI launcher) + install-level.sh tools/ dependency-free reference servers: sync-server (/v1 Settings-Sync), update-server (/api/update feed) -web/ LevelCode in a browser tab: page entry + callback, scratch-workspace extension, browser build of levelcode-ai, dev server, tests (docs/WEB.md) +web/ LevelCode in a browser tab: page entry + callback, scratch-workspace extension, layout, themes + chat skin, browser build of levelcode-ai, dev server, tests (docs/WEB.md) vscode/ GITIGNORED upstream Code-OSS checkout (generated) ``` @@ -101,6 +101,12 @@ the desktop app exactly as it was: - `web/main.js` is a plain ES module with a **top-level `await`**: a `const` declared lower in the file is not initialised until that await finishes (this cleared a saved session once). Declare constants above the `try`, and use function declarations for helpers it calls. +- **The look is web-only and lives in `web/`** (theme package, `chrome.css`, the chat skin, the layout commands; + `docs/WEB.md` "The look and the layout"). The chat skin is cut into the browser build's **copy** of + `media/chat.html` by `web/ai-extension/skin.js`; never edit the desktop file for it (its suites pin the page). + Every skin rule is under `body.lc-web`. An extension's `configurationDefaults` outrank the embedder's, so + `levelcode-themes`' pinned default theme is stripped from the staged copy (`withoutConfigurationDefaults`). + `node web/test/e2e-look.mjs --dist dist-web --stub-port ` is the check; look at its screenshots (`--shots`). - The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot. - Build: `node scripts/build-web.mjs --account ` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`. diff --git a/docs/WEB.md b/docs/WEB.md index f0e8efa..81cbcb1 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -10,6 +10,7 @@ run. For the product's own page and copy see the account site (`/web`, `/docs/br - [What it is, and what it is not](#what-it-is-and-what-it-is-not) - [How it fits together](#how-it-fits-together) +- [The look and the layout](#the-look-and-the-layout) - [Signing in](#signing-in) - [Build and run](#build-and-run) - [Deploying](#deploying) — origins, headers, backend settings @@ -64,6 +65,8 @@ the same lines so it never promises one (`host.caps`, see below). | `web/ai-extension/` | The browser build of `levelcode-ai`: `build.mjs` (esbuild, `platform: browser`), `shims/` (`fs`, `path`, `os`, `crypto`, `child_process`, `process`), `manifest.js` (the desktop manifest minus what a tab cannot do), `copy.js` (the few strings that are true on a Mac and false in a tab). | | `extensions/levelcode-ai/host.js` | The one place the desktop/browser difference lives in the extension (see below). | | `web/workspace/` | The `levelcode-web` extension: the **scratch workspace** (`levelcode-scratch:` file system in IndexedDB, with file and text search), and the commands to open a local folder or the scratch. | +| `web/theme/levelcode-web-theme/`, `web/chrome.css` | The two colour themes (dark and light) and the stylesheet that tidies the workbench's chrome. See [the look](#the-look-and-the-layout). | +| `web/ai-extension/skin.js`, `skin/chat.css`, `skin/chat.js` | The chat's browser skin, cut into the browser build's copy of `media/chat.html` at build time. | | `web/serve.mjs` | Dev / end-to-end server. Serves a build (`--dist`) with its own `_headers`, or the pieces (`--static`, `--extensions`). | | `web/test/` | Unit tests (`unit/`) and the end-to-end checks (`e2e*.mjs`) with a hand-written CDP driver and a stand-in backend. | @@ -88,6 +91,45 @@ The two rules that keep the desktop app exactly as it was: - **Nothing outside `host.js` asks "am I in a browser?".** A call site asks for a capability (`host.caps.shell`) or calls a function that already knows. +## The look and the layout + +The first thing a visitor sees is the chat, not an IDE: a centred greeting with four starters and one rounded +composer, with the list of conversations docked on its left when there is room, and the Explorer and the files one +click away in the status bar. It is made **without forking the workbench** — every piece is a setting, a built-in +extension, a stylesheet or a build-time edit of the browser build's own copy of a file. + +| Piece | Where | What it does, and what it depends on | +| --- | --- | --- | +| Colour themes | `web/theme/levelcode-web-theme/` (staged as a built-in extension by `build-web.mjs`) | "LevelCode Web Dark" / "LevelCode Web Light": neutral chrome with the editor one step lighter, hairline borders, one violet accent. They carry the `agentsPanel.*` keys the floating cards use. Follows the system (`window.autoDetectColorScheme` + the two preferred-theme settings). | +| First paint | `initialColorTheme` in `web/lib/config.mjs`, picked by `pickInitialTheme` in `web/main.js` | The colours painted for the half second before the theme extension has loaded, so a dark visitor does not see a light flash. | +| Default theme | `withoutConfigurationDefaults` in `web/lib/extensions.mjs` | An extension's `configurationDefaults` **outrank** the embedder's. `levelcode-themes` pins its own default theme, so the staged copy of it has that key removed (its two themes stay selectable). The desktop source is untouched. | +| Panels, activity bar | `workbench.experimental.modernUI`, `workbench.activityBar.location: top`, `workbench.layoutControl.type: toggles` | Code-OSS's own switches. `modernUI` is **experimental**: if a bump renames or drops it the editor is still fine, only plainer (`e2e-look.mjs` still passes its theme and layout checks; its screenshots are the thing to look at). | +| Chrome | `web/chrome.css`, linked by `main.js` **after** the workbench's stylesheet | Hides source control, run and extensions (this edition has none of them), the Outline and Timeline headers and the keyboard-layout status item. The selectors are workbench class names: when a bump stops matching them `e2e-look.mjs` fails on "not offered" rather than the icons quietly coming back. | +| The workbench's own chat | `hideBuiltInChat` in `main.js` | Code-OSS's Copilot chat view appears in the secondary side bar on the web even with `chat.disableAIFeatures`; it is told it is disabled through an internal context key. Look for a "Chat" tab on the right on a bump. | +| Welcome page | `web/ai-extension/copy.js` | The desktop opens its walkthrough once, in front of everything. In a tab the chat is the first thing there is; the walkthrough stays in the Command Palette. | +| The chat page | `web/ai-extension/skin.js` + `skin/chat.css` + `skin/chat.js` | Three exact insertions into the **embedded copy** of `media/chat.html`: a style block, `class="lc-web"` on ``, a nonce'd script. Every rule is under `body.lc-web`; the script sends only messages the page already sends. The build **fails** if chat.html stops having the places the skin is cut into. The desktop's `chat.html` is never edited for this (the desktop suites pin it). | + +**Layouts.** `levelcode.web.layout` is `chatFirst` (the chat alone, side bars closed, conversations on its +left — the way a chat app is used) or `split` (Explorer, files on the left, the chat docked on the right — the +way an editor is used). The status bar item at the left, **LevelCode: Layout: Switch** and `Cmd/Ctrl+Alt+L` +change the setting; one listener applies it, so Settings does the same. The first start is `chatFirst`. The +chat panel is not restored by a reload, so a `split` layout is applied again at start. Only existing workbench +commands are used, so what the visitor then arranges by hand is an ordinary layout. + +**Widths.** The chat page decides by **its own width**, not the window's: from 1000 px it docks the +conversation list as a 264 px rail; below 760 px it compacts; in between the list is the dialog it is on the +desktop. In `split` the chat is a narrower column, so it will usually be in the dialog form. The *workbench* is +not responsive: below about 760 px the Explorer, tabs and status bar are the desktop's, and a phone is not a +supported size for this edition yet. + +Constraints worth knowing before changing it: + +- The chat webview's CSP has no `font-src`: the skin uses the system font stack only. +- A group that is emptied by moving the chat out of it is closed, so `split` opens a file in the first group + **before** it moves the chat (`applyLayout`, pinned by `workspace.test.js`). +- The rail asks the page for its conversations with `listSessions`; it must never call `openSessions()`, + which seeds the list with sample conversations when nothing has arrived yet. + ## Signing in The extension's existing PKCE flow, with the browser's pieces in place of the OS's: @@ -257,8 +299,9 @@ secret store then falls back to memory for the session. | Check | Command | What it proves | | --- | --- | --- | -| Unit gate | `./scripts/test-extensions.sh` | Includes `web/test/unit/*.test.js`: the shims against Node, `host.js` on both sides, `main.js`'s functions (address check, delivery, the shared secret store, `?signin=1`), the manifest, the copy rewrites. Runs in CI on every PR (`test.yml`). Also run on Linux / Node 18 in a container with the network refused. | +| Unit gate | `./scripts/test-extensions.sh` | Includes `web/test/unit/*.test.js`: the shims against Node, `host.js` on both sides, `main.js`'s functions (address check, delivery, the shared secret store, `?signin=1`), the manifest, the copy rewrites, the chat skin (what it inserts and that every rule is scoped), the themes (required colours, contrast of the text pairs, the page's defaults naming themes that exist) and the layout commands. Runs in CI on every PR (`test.yml`). Also run on Linux / Node 18 in a container with the network refused. | | End to end | `node web/test/e2e.mjs --dist dist-web --stub-port ` | Real headless Chrome: boot from static files, the extension activates in the worker, sign in through the real callback page, agent tool calls (write / list / search / read / edit) against the scratch workspace, Quick Open, Keep and Undo, a reload that keeps the session and files. | +| The look | `node web/test/e2e-look.mjs --dist dist-web --stub-port ` | The skinned chat (greeting, starters, composer in the reading column, the rail at 1440 px and the dialog at 900 px), light and dark following the system with this build's own colours (also in the chat's webview), no Welcome page, no second chat, source control / run / extensions not offered, the layout switch both ways, and the docked layout docked again after a reload. Release builds only. | | Same-tab sign-in | `node web/test/e2e-signin-tab.mjs --dist dist-web --stub-port ` | No second window; verifier survives the reload; no repeat; no sign-in for a signed-in editor; a second tab uses the same session; a stranded result is delivered, an old one dropped. | | Bring your own key | `node web/test/e2e-byok.mjs --static … --extensions …` | The request goes from the tab to the provider with the user's key and nothing of LevelCode's. | @@ -315,6 +358,12 @@ the other repository's internals and is not part of this repo's checks. Say these out loud rather than discover them in production: - **A real browser other than headless Chrome.** Safari and Firefox are untested; so are phones. +- **The look in Safari, Firefox and on a phone.** `e2e-look.mjs` and the screenshots are headless Chrome at 1440, + 1100, 900 and 420 px wide. The chat page is responsive down to 420 px; the workbench around it is not (see + [widths](#the-look-and-the-layout)). No screen-reader pass was done on the skin; the rail is a labelled + landmark at wide widths and the dialog it already was below them. +- **`workbench.experimental.modernUI` across a Code-OSS bump.** It is the workbench's own experimental flag; the + pinned checkout was the only one run. - **The operating system's folder picker itself.** It needs a person. Everything after it — the command, the workbench's `file` provider on a real `FileSystemDirectoryHandle`, the agent's list, read and write — is run (`web/test/e2e.mjs` section 5), with the origin-private file system standing in for the picker. Permission diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 84d2b38..baffad3 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -22,7 +22,7 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { webConfig, renderIndex } from '../web/lib/config.mjs'; -import { stageDeclarativeExtensions, copyExtension } from '../web/lib/extensions.mjs'; +import { stageDeclarativeExtensions, copyExtension, withoutConfigurationDefaults } from '../web/lib/extensions.mjs'; import { policy, toHeadersFile, toNginx } from '../web/lib/headers.mjs'; import { hashTree } from '../web/lib/fingerprint.mjs'; @@ -68,13 +68,14 @@ for (const must of ['out/vs/workbench/workbench.web.main.internal.js', 'out/vs/w /* ----- 2. identity of this build ---------------------------------------------------------------- */ const fingerprint = createHash('sha256'); fingerprint.update(fs.readFileSync(path.join(staticDir, 'out/vs/workbench/workbench.web.main.internal.js'))); -for (const f of ['main.js', 'boot.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); } +for (const f of ['main.js', 'boot.css', 'chrome.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); } // Everything that goes into /_// and is not the web client itself: the extension (its sources, media and skills), // its browser build, the scratch workspace, and the page's own configuration code. const aiDir = path.join(REPO, 'extensions', 'levelcode-ai'); hashTree(fingerprint, aiDir, { skip: ['test', 'node_modules', 'scripts', '.DS_Store'] }); hashTree(fingerprint, path.join(WEB, 'ai-extension'), { skip: ['assets.generated.js', 'node_modules', '.DS_Store'] }); hashTree(fingerprint, path.join(WEB, 'workspace'), { skip: ['node_modules', '.DS_Store'] }); +hashTree(fingerprint, path.join(WEB, 'theme'), { skip: ['.DS_Store'] }); hashTree(fingerprint, path.join(WEB, 'lib'), { skip: ['.DS_Store'] }); const id = String(args.id || fingerprint.digest('hex').slice(0, 12)); let gitHead = 'unknown'; @@ -110,12 +111,19 @@ if (!declarative.includes('levelcode-themes')) { const themes = path.join(REPO, 'extensions', 'levelcode-themes'); if (fs.existsSync(themes)) { copyExtension(themes, path.join(extOut, 'levelcode-themes')); declarative.push('levelcode-themes'); } } -const extensions = ['levelcode-ai', 'levelcode-web', ...declarative.sort()]; -log(`${extensions.length} extensions: levelcode-ai, levelcode-web and ${declarative.length} declarative (${declarative.slice(0, 6).join(', ')}, ...)`); +// The browser edition's own colour themes. The desktop's default theme is pinned by levelcode-themes' manifest +// (an extension's defaults outrank the page's), so that one is dropped here; its themes stay selectable. +const themeOut = path.join(extOut, 'levelcode-web-theme'); +copyExtension(path.join(WEB, 'theme', 'levelcode-web-theme'), themeOut); +fs.writeFileSync(path.join(themeOut, 'package.nls.json'), '{}\n'); +const desktopThemes = path.join(extOut, 'levelcode-themes', 'package.json'); +if (fs.existsSync(desktopThemes)) { withoutConfigurationDefaults(desktopThemes); } +const extensions = ['levelcode-ai', 'levelcode-web', 'levelcode-web-theme', ...declarative.sort()]; +log(`${extensions.length} extensions: levelcode-ai, levelcode-web, levelcode-web-theme and ${declarative.length} declarative (${declarative.slice(0, 6).join(', ')}, ...)`); log('writing the page'); // main.js and boot.css are addressed by the build id like everything else; the page names them. -for (const f of ['main.js', 'boot.css']) { fs.copyFileSync(path.join(WEB, f), path.join(root, f)); } +for (const f of ['main.js', 'boot.css', 'chrome.css']) { fs.copyFileSync(path.join(WEB, f), path.join(root, f)); } const productConfiguration = { // No gallery: nothing is installed from the Internet into the page's own origin. @@ -166,7 +174,8 @@ if (!m) { die('index.html has no configuration'); } JSON.parse(m[1]); if (/\{\{[A-Z]+\}\}/.test(html)) { die('index.html still has an unfilled placeholder'); } for (const must of [`${prefix}/main.js`, `${prefix}/boot.css`, `${prefix}/static/out/nls.messages.js`, `${prefix}/extensions/levelcode-ai/extension.web.js`, - `${prefix}/extensions/levelcode-ai/package.json`, `${prefix}/extensions/levelcode-web/extension.js`]) { + `${prefix}/extensions/levelcode-ai/package.json`, `${prefix}/extensions/levelcode-web/extension.js`, + `${prefix}/chrome.css`, `${prefix}/extensions/levelcode-web-theme/package.json`]) { if (!fs.existsSync(path.join(outDir, must))) { die('missing ' + must); } } log(`done: ${outDir} ${mb(sizeOf(outDir))} (client ${mb(sizeOf(path.join(root, 'static')))}, extensions ${mb(sizeOf(extOut))})`); diff --git a/web/ai-extension/build.mjs b/web/ai-extension/build.mjs index 94c4156..43de92b 100644 --- a/web/ai-extension/build.mjs +++ b/web/ai-extension/build.mjs @@ -37,6 +37,7 @@ const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'b if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); } const { applyCopy } = createRequire(import.meta.url)('./copy.js'); +const { skinChat } = createRequire(import.meta.url)('./skin.js'); const outDir = path.join(outRoot, 'levelcode-ai'); fs.rmSync(outDir, { recursive: true, force: true }); @@ -52,7 +53,10 @@ function collect(rel, filter) { if (e.isDirectory()) { walk(r); continue; } if (filter && !filter(r)) { continue; } const buf = fs.readFileSync(path.join(SRC, r)); - out[r] = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : { base64: buf.toString('base64') }; + let text = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : null; + // The browser's look for the chat goes into this embedded copy and nowhere else (skin.js). + if (text !== null && r === 'media/chat.html') { text = skinChat(text); } + out[r] = text !== null ? text : { base64: buf.toString('base64') }; } }; if (fs.existsSync(path.join(SRC, rel))) { walk(rel); } diff --git a/web/ai-extension/copy.js b/web/ai-extension/copy.js index 4d733e6..86b53f9 100644 --- a/web/ai-extension/copy.js +++ b/web/ai-extension/copy.js @@ -19,6 +19,11 @@ const REPLACEMENTS = [ ['extension.js', "\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait vscode.env.openExternal(vscode.Uri.parse(url));", "\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait host.openAuth(vscode.Uri.parse(url));"], + // The desktop opens its Welcome walkthrough once, in front of everything. In a tab the first thing a visitor + // should see is the chat; the walkthrough stays in the Command Palette (Help: Welcome). + ['extension.js', + "\tif (!context.globalState.get('levelcode.ai.didShowWelcome')) {", + "\tif (false) { // browser edition: the chat is the first thing a visitor sees (web/ai-extension/copy.js)"], ['media/chat.html', 'Bring your own API key, stored in your OS keychain.', 'Bring your own API key, stored encrypted in this browser.'], diff --git a/web/ai-extension/skin.js b/web/ai-extension/skin.js new file mode 100644 index 0000000..c32e53f --- /dev/null +++ b/web/ai-extension/skin.js @@ -0,0 +1,49 @@ +// The browser's look for the chat — applied to the copy of media/chat.html that goes into the browser bundle, and +// to nothing else. The desktop app packages extensions/* alone, so nothing under web/ can reach it, and chat.html +// itself stays exactly as the desktop's suites pin it (a third \n'; + +const read = (name) => fs.readFileSync(path.join(__dirname, 'skin', name), 'utf8'); + +/** + * @param {string} html the desktop chat.html (after copy.js has rewritten its desktop-only words) + * @returns {string} the same page with the browser skin + * @throws if the page is not the shape this expects, or a skin file could end its own block early + */ +function skinChat(html) { + const css = read('chat.css'); + const js = read('chat.js'); + for (const [name, text] of [['chat.css', css], ['chat.js', js]]) { + if (/<\/(?:script|style)/i.test(text) || text.includes('