Skip to content

Commit 17f85ca

Browse files
committed
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).
1 parent 54b479f commit 17f85ca

24 files changed

Lines changed: 2345 additions & 17 deletions

‎CLAUDE.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ extensions/levelcode-updater/ notify-only update checker (polls the update fe
4242
patches/levelcode-core.patch our core source edits, applied on bootstrap
4343
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
4444
tools/ dependency-free reference servers: sync-server (/v1 Settings-Sync), update-server (/api/update feed)
45-
web/ LevelCode in a browser tab: page entry + callback, scratch-workspace extension, browser build of levelcode-ai, dev server, tests (docs/WEB.md)
45+
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)
4646
vscode/ GITIGNORED upstream Code-OSS checkout (generated)
4747
```
4848

@@ -101,6 +101,12 @@ the desktop app exactly as it was:
101101
- `web/main.js` is a plain ES module with a **top-level `await`**: a `const` declared lower in the file is not
102102
initialised until that await finishes (this cleared a saved session once). Declare constants above the
103103
`try`, and use function declarations for helpers it calls.
104+
- **The look is web-only and lives in `web/`** (theme package, `chrome.css`, the chat skin, the layout commands;
105+
`docs/WEB.md` "The look and the layout"). The chat skin is cut into the browser build's **copy** of
106+
`media/chat.html` by `web/ai-extension/skin.js`; never edit the desktop file for it (its suites pin the page).
107+
Every skin rule is under `body.lc-web`. An extension's `configurationDefaults` outrank the embedder's, so
108+
`levelcode-themes`' pinned default theme is stripped from the staged copy (`withoutConfigurationDefaults`).
109+
`node web/test/e2e-look.mjs --dist dist-web --stub-port <port>` is the check; look at its screenshots (`--shots`).
104110
- The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a
105111
Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot.
106112
- Build: `node scripts/build-web.mjs --account <origin>` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`.

‎docs/WEB.md‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ run. For the product's own page and copy see the account site (`/web`, `/docs/br
1010

1111
- [What it is, and what it is not](#what-it-is-and-what-it-is-not)
1212
- [How it fits together](#how-it-fits-together)
13+
- [The look and the layout](#the-look-and-the-layout)
1314
- [Signing in](#signing-in)
1415
- [Build and run](#build-and-run)
1516
- [Deploying](#deploying) — origins, headers, backend settings
@@ -64,6 +65,8 @@ the same lines so it never promises one (`host.caps`, see below).
6465
| `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). |
6566
| `extensions/levelcode-ai/host.js` | The one place the desktop/browser difference lives in the extension (see below). |
6667
| `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. |
68+
| `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). |
69+
| `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. |
6770
| `web/serve.mjs` | Dev / end-to-end server. Serves a build (`--dist`) with its own `_headers`, or the pieces (`--static`, `--extensions`). |
6871
| `web/test/` | Unit tests (`unit/`) and the end-to-end checks (`e2e*.mjs`) with a hand-written CDP driver and a stand-in backend. |
6972

@@ -88,6 +91,45 @@ The two rules that keep the desktop app exactly as it was:
8891
- **Nothing outside `host.js` asks "am I in a browser?".** A call site asks for a capability
8992
(`host.caps.shell`) or calls a function that already knows.
9093

94+
## The look and the layout
95+
96+
The first thing a visitor sees is the chat, not an IDE: a centred greeting with four starters and one rounded
97+
composer, with the list of conversations docked on its left when there is room, and the Explorer and the files one
98+
click away in the status bar. It is made **without forking the workbench** — every piece is a setting, a built-in
99+
extension, a stylesheet or a build-time edit of the browser build's own copy of a file.
100+
101+
| Piece | Where | What it does, and what it depends on |
102+
| --- | --- | --- |
103+
| 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). |
104+
| 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. |
105+
| 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. |
106+
| 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). |
107+
| 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. |
108+
| 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. |
109+
| 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. |
110+
| 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 `<body>`, 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). |
111+
112+
**Layouts.** `levelcode.web.layout` is `chatFirst` (the chat alone, side bars closed, conversations on its
113+
left — the way a chat app is used) or `split` (Explorer, files on the left, the chat docked on the right — the
114+
way an editor is used). The status bar item at the left, **LevelCode: Layout: Switch** and `Cmd/Ctrl+Alt+L`
115+
change the setting; one listener applies it, so Settings does the same. The first start is `chatFirst`. The
116+
chat panel is not restored by a reload, so a `split` layout is applied again at start. Only existing workbench
117+
commands are used, so what the visitor then arranges by hand is an ordinary layout.
118+
119+
**Widths.** The chat page decides by **its own width**, not the window's: from 1000 px it docks the
120+
conversation list as a 264 px rail; below 760 px it compacts; in between the list is the dialog it is on the
121+
desktop. In `split` the chat is a narrower column, so it will usually be in the dialog form. The *workbench* is
122+
not responsive: below about 760 px the Explorer, tabs and status bar are the desktop's, and a phone is not a
123+
supported size for this edition yet.
124+
125+
Constraints worth knowing before changing it:
126+
127+
- The chat webview's CSP has no `font-src`: the skin uses the system font stack only.
128+
- A group that is emptied by moving the chat out of it is closed, so `split` opens a file in the first group
129+
**before** it moves the chat (`applyLayout`, pinned by `workspace.test.js`).
130+
- The rail asks the page for its conversations with `listSessions`; it must never call `openSessions()`,
131+
which seeds the list with sample conversations when nothing has arrived yet.
132+
91133
## Signing in
92134

93135
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.
257299

258300
| Check | Command | What it proves |
259301
| --- | --- | --- |
260-
| 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. |
302+
| 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. |
261303
| End to end | `node web/test/e2e.mjs --dist dist-web --stub-port <port the build used>` | 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. |
304+
| The look | `node web/test/e2e-look.mjs --dist dist-web --stub-port <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. |
262305
| Same-tab sign-in | `node web/test/e2e-signin-tab.mjs --dist dist-web --stub-port <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. |
263306
| 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. |
264307

@@ -315,6 +358,12 @@ the other repository's internals and is not part of this repo's checks.
315358
Say these out loud rather than discover them in production:
316359

317360
- **A real browser other than headless Chrome.** Safari and Firefox are untested; so are phones.
361+
- **The look in Safari, Firefox and on a phone.** `e2e-look.mjs` and the screenshots are headless Chrome at 1440,
362+
1100, 900 and 420 px wide. The chat page is responsive down to 420 px; the workbench around it is not (see
363+
[widths](#the-look-and-the-layout)). No screen-reader pass was done on the skin; the rail is a labelled
364+
landmark at wide widths and the dialog it already was below them.
365+
- **`workbench.experimental.modernUI` across a Code-OSS bump.** It is the workbench's own experimental flag; the
366+
pinned checkout was the only one run.
318367
- **The operating system's folder picker itself.** It needs a person. Everything after it — the command, the
319368
workbench's `file` provider on a real `FileSystemDirectoryHandle`, the agent's list, read and write — is run
320369
(`web/test/e2e.mjs` section 5), with the origin-private file system standing in for the picker. Permission

‎scripts/build-web.mjs‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import path from 'node:path';
2222
import { createHash } from 'node:crypto';
2323
import { fileURLToPath } from 'node:url';
2424
import { webConfig, renderIndex } from '../web/lib/config.mjs';
25-
import { stageDeclarativeExtensions, copyExtension } from '../web/lib/extensions.mjs';
25+
import { stageDeclarativeExtensions, copyExtension, withoutConfigurationDefaults } from '../web/lib/extensions.mjs';
2626
import { policy, toHeadersFile, toNginx } from '../web/lib/headers.mjs';
2727
import { hashTree } from '../web/lib/fingerprint.mjs';
2828

@@ -68,13 +68,14 @@ for (const must of ['out/vs/workbench/workbench.web.main.internal.js', 'out/vs/w
6868
/* ----- 2. identity of this build ---------------------------------------------------------------- */
6969
const fingerprint = createHash('sha256');
7070
fingerprint.update(fs.readFileSync(path.join(staticDir, 'out/vs/workbench/workbench.web.main.internal.js')));
71-
for (const f of ['main.js', 'boot.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); }
71+
for (const f of ['main.js', 'boot.css', 'chrome.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); }
7272
// Everything that goes into /_/<id>/ and is not the web client itself: the extension (its sources, media and skills),
7373
// its browser build, the scratch workspace, and the page's own configuration code.
7474
const aiDir = path.join(REPO, 'extensions', 'levelcode-ai');
7575
hashTree(fingerprint, aiDir, { skip: ['test', 'node_modules', 'scripts', '.DS_Store'] });
7676
hashTree(fingerprint, path.join(WEB, 'ai-extension'), { skip: ['assets.generated.js', 'node_modules', '.DS_Store'] });
7777
hashTree(fingerprint, path.join(WEB, 'workspace'), { skip: ['node_modules', '.DS_Store'] });
78+
hashTree(fingerprint, path.join(WEB, 'theme'), { skip: ['.DS_Store'] });
7879
hashTree(fingerprint, path.join(WEB, 'lib'), { skip: ['.DS_Store'] });
7980
const id = String(args.id || fingerprint.digest('hex').slice(0, 12));
8081
let gitHead = 'unknown';
@@ -110,12 +111,19 @@ if (!declarative.includes('levelcode-themes')) {
110111
const themes = path.join(REPO, 'extensions', 'levelcode-themes');
111112
if (fs.existsSync(themes)) { copyExtension(themes, path.join(extOut, 'levelcode-themes')); declarative.push('levelcode-themes'); }
112113
}
113-
const extensions = ['levelcode-ai', 'levelcode-web', ...declarative.sort()];
114-
log(`${extensions.length} extensions: levelcode-ai, levelcode-web and ${declarative.length} declarative (${declarative.slice(0, 6).join(', ')}, ...)`);
114+
// The browser edition's own colour themes. The desktop's default theme is pinned by levelcode-themes' manifest
115+
// (an extension's defaults outrank the page's), so that one is dropped here; its themes stay selectable.
116+
const themeOut = path.join(extOut, 'levelcode-web-theme');
117+
copyExtension(path.join(WEB, 'theme', 'levelcode-web-theme'), themeOut);
118+
fs.writeFileSync(path.join(themeOut, 'package.nls.json'), '{}\n');
119+
const desktopThemes = path.join(extOut, 'levelcode-themes', 'package.json');
120+
if (fs.existsSync(desktopThemes)) { withoutConfigurationDefaults(desktopThemes); }
121+
const extensions = ['levelcode-ai', 'levelcode-web', 'levelcode-web-theme', ...declarative.sort()];
122+
log(`${extensions.length} extensions: levelcode-ai, levelcode-web, levelcode-web-theme and ${declarative.length} declarative (${declarative.slice(0, 6).join(', ')}, ...)`);
115123

116124
log('writing the page');
117125
// main.js and boot.css are addressed by the build id like everything else; the page names them.
118-
for (const f of ['main.js', 'boot.css']) { fs.copyFileSync(path.join(WEB, f), path.join(root, f)); }
126+
for (const f of ['main.js', 'boot.css', 'chrome.css']) { fs.copyFileSync(path.join(WEB, f), path.join(root, f)); }
119127

120128
const productConfiguration = {
121129
// 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'); }
166174
JSON.parse(m[1]);
167175
if (/\{\{[A-Z]+\}\}/.test(html)) { die('index.html still has an unfilled placeholder'); }
168176
for (const must of [`${prefix}/main.js`, `${prefix}/boot.css`, `${prefix}/static/out/nls.messages.js`, `${prefix}/extensions/levelcode-ai/extension.web.js`,
169-
`${prefix}/extensions/levelcode-ai/package.json`, `${prefix}/extensions/levelcode-web/extension.js`]) {
177+
`${prefix}/extensions/levelcode-ai/package.json`, `${prefix}/extensions/levelcode-web/extension.js`,
178+
`${prefix}/chrome.css`, `${prefix}/extensions/levelcode-web-theme/package.json`]) {
170179
if (!fs.existsSync(path.join(outDir, must))) { die('missing ' + must); }
171180
}
172181
log(`done: ${outDir} ${mb(sizeOf(outDir))} (client ${mb(sizeOf(path.join(root, 'static')))}, extensions ${mb(sizeOf(extOut))})`);

‎web/ai-extension/build.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'b
3737
if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); }
3838

3939
const { applyCopy } = createRequire(import.meta.url)('./copy.js');
40+
const { skinChat } = createRequire(import.meta.url)('./skin.js');
4041

4142
const outDir = path.join(outRoot, 'levelcode-ai');
4243
fs.rmSync(outDir, { recursive: true, force: true });
@@ -52,7 +53,10 @@ function collect(rel, filter) {
5253
if (e.isDirectory()) { walk(r); continue; }
5354
if (filter && !filter(r)) { continue; }
5455
const buf = fs.readFileSync(path.join(SRC, r));
55-
out[r] = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : { base64: buf.toString('base64') };
56+
let text = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : null;
57+
// The browser's look for the chat goes into this embedded copy and nowhere else (skin.js).
58+
if (text !== null && r === 'media/chat.html') { text = skinChat(text); }
59+
out[r] = text !== null ? text : { base64: buf.toString('base64') };
5660
}
5761
};
5862
if (fs.existsSync(path.join(SRC, rel))) { walk(rel); }

‎web/ai-extension/copy.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const REPLACEMENTS = [
1919
['extension.js',
2020
"\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait vscode.env.openExternal(vscode.Uri.parse(url));",
2121
"\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait host.openAuth(vscode.Uri.parse(url));"],
22+
// The desktop opens its Welcome walkthrough once, in front of everything. In a tab the first thing a visitor
23+
// should see is the chat; the walkthrough stays in the Command Palette (Help: Welcome).
24+
['extension.js',
25+
"\tif (!context.globalState.get('levelcode.ai.didShowWelcome')) {",
26+
"\tif (false) { // browser edition: the chat is the first thing a visitor sees (web/ai-extension/copy.js)"],
2227
['media/chat.html',
2328
'Bring your own API key, stored in your OS keychain.',
2429
'Bring your own API key, stored encrypted in this browser.'],

0 commit comments

Comments
 (0)