Skip to content

Commit 4a9803f

Browse files
committed
Merge branch 'feat/web-look' into feat/web-shell
# Conflicts: # docs/WEB.md # extensions/levelcode-ai/agent.js # extensions/levelcode-ai/host.js # scripts/build-web.mjs
2 parents e93bc09 + 3925003 commit 4a9803f

29 files changed

Lines changed: 1395 additions & 220 deletions

‎docs/WEB.md‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ extension, a stylesheet or a build-time edit of the browser build's own copy of
107107

108108
| Piece | Where | What it does, and what it depends on |
109109
| --- | --- | --- |
110-
| 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). |
110+
| 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). The syntax colours are One Dark's and One Light's with every one under 4.5:1 on the editor moved in lightness only, hue kept (the light constants darkened with them, so numbers stay apart from class names); `theme.test.js` holds every syntax colour to 4.5:1. The editor paints no token *backgrounds*, so none is set (`invalid` is red text). |
111111
| 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. |
112112
| 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. |
113113
| 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). |
@@ -121,13 +121,16 @@ extension, a stylesheet or a build-time edit of the browser build's own copy of
121121
**Layouts.** `levelcode.web.layout` is `chatFirst` (the chat alone, side bars closed, conversations on its
122122
left — the way a chat app is used) or `split` (Explorer, files on the left, the chat docked on the right — the
123123
way an editor is used). The status bar item at the left and **LevelCode: Layout: Switch** change the setting
124-
(there is no default key binding: `Cmd+Alt+L` is Find in Selection on macOS); one listener applies it, so Settings
125-
does the same. The first start in a workspace is `chatFirst`. The chat panel is not restored by a reload, so a
124+
(there is no default key binding: `Cmd+Alt+L` is Find in Selection on macOS) where it takes effect — the workspace's
125+
value when `.vscode/settings.json` has one, since that outranks the user's, the user's otherwise; one listener
126+
applies it, so Settings does the same. The first start in a workspace is `chatFirst`. The chat panel is not restored by a reload, so a
126127
`split` layout docks the chat again at start — without revealing the Explorer or moving the focus (that is done
127128
once per workspace, `workspaceState`), so an Explorer the visitor closed stays closed. The start-up and a switch wait
128129
for the chat's tab to exist (`onDidChangeTabs`, 2.5 s) rather than reading the tab model at a guessed time. Only
129130
existing workbench commands are used, so what the visitor then arranges by hand is an ordinary layout. Files that are
130-
open when going back to `chatFirst` stay as tabs beside the chat.
131+
open when going back to `chatFirst` stay open as tabs behind the chat: a group that still has files (a split file
132+
editor, say) is joined into the chat's (`workbench.action.joinAllGroups`, which moves them in inactive), and that one
133+
group is unlocked, because a group the chat was docked in is locked and would send the next file into a new split.
131134

132135
**Widths.** The chat page decides by **its own width**, not the window's: from 1000 px it docks the
133136
conversation list as a 264 px rail; below 760 px it compacts; in between the list is the dialog it is on the
@@ -141,7 +144,15 @@ Constraints worth knowing before changing it:
141144

142145
- The chat webview's CSP has no `font-src`: the skin uses the system font stack only.
143146
- A group that is emptied by moving the chat out of it is closed, so `split` opens a file in the first group
144-
**before** it moves the chat (`applyLayout`, pinned by `workspace.test.js`).
147+
**before** it moves the chat — the README, or an empty untitled document when there is none and the chat is the
148+
group's only tab (`applyLayout`, pinned by `workspace.test.js`).
149+
- The move and join commands act on the **active editor**, and `levelcode.ai.focus` returns before the reveal has
150+
reached the tab model. The window is read only once the chat is in front (until then an editor that was in front
151+
of it, such as a settings editor in a group of its own, still counts as a group). Each move or join then runs only
152+
when the tab model says the chat is the active editor, read immediately
153+
before it (after waiting up to 1.5 s for that); otherwise it is skipped, as is every later one in that arrangement,
154+
and so is one that a newer layout request has overtaken. A skipped move leaves the window as it was; a guessed one
155+
would move the file beside the chat.
145156
- The rail asks the page for its conversations with `listSessions`; it must never call `openSessions()`,
146157
which seeds the list with sample conversations when nothing has arrived yet.
147158
- Text drawn in the theme's accent uses `textLink.foreground`, not `focusBorder` (which is translucent and a line
@@ -157,28 +168,39 @@ The extension's existing PKCE flow, with the browser's pieces in place of the OS
157168
1 extension accountSignIn(): verifier → SecretStorage; builds
158169
<account>/ai/login?redirect_uri=<callback>&code_challenge=<S256>
159170
2 extension host.openAuth(uri) → command levelcode.web.openAuthUrl (registered by main.js)
160-
3 main.js checks the address is <account>/ai/…, marks the tab (sessionStorage), location.assign
171+
3 main.js checks the address is <account>/ai/…, notes where the tab is (sessionStorage), location.assign
161172
4 account already signed in there → mints a one-time code bound to the challenge, 302 to
162173
<editor>/callback.html?vscode-reqid=N&vscode-scheme=levelcode
163174
&vscode-authority=levelcode.levelcode-ai&vscode-path=%2Fauth%2Fcallback&code=…
164175
(not signed in → the login page first)
165-
5 callback writes localStorage "vscode-web.url-callbacks[N]" (dated), sees the mark → goes back to /
176+
5 callback checks it is a sign-in to levelcode.levelcode-ai/auth/callback, writes localStorage
177+
"vscode-web.url-callbacks[N]" (dated, the code only), goes back to where the tab was
166178
6 main.js at start-up, once the workbench is listening, delivers a fresh result as
167179
levelcode://levelcode.levelcode-ai/auth/callback?code=… to the URL service
168-
7 extension handleAuthCallback → POST /api/levelcode/v1/auth/exchange {code, verifier} → tokens
180+
7 extension handleAuthCallback: a sign-in is pending (a verifier is stored) →
181+
POST /api/levelcode/v1/auth/exchange {code, verifier} → tokens
169182
```
170183

171184
Why the tab leaves instead of opening a pop-up: a window opened from a chain that began in a
172185
webview is the first thing a strict browser (Safari, a phone) refuses. Leaving the tab and
173186
returning needs no permission. The PKCE verifier lives in SecretStorage, so it survives the
174-
navigation.
187+
navigation — and where the browser lets nothing persist (no IndexedDB or WebCrypto, or a refused
188+
write), the tab's secrets ride through that one navigation in sessionStorage and are taken back,
189+
and removed, when the editor starts again. The tab comes back to the address it left: a folder or
190+
workspace opened from the computer is opened again, not the scratch workspace.
175191

176192
- **`?signin=1`** (the account site's "Open LevelCode" link) makes step 1 happen by itself, once:
177193
`main.js` removes the parameter, then delivers `levelcode://…/launch`, which focuses the chat and
178194
starts the sign-in only when there is no session.
179195
- **A result is delivered only if it is fresh** (written in the last two minutes — the one-time
180196
code lives for a minute) and is removed either way. One left by a callback page whose editor
181197
tab is gone is delivered when the editor next starts.
198+
- **`callback.html` is a page anyone can link to**, and what it leaves is delivered at the next start.
199+
So it passes on a LevelCode sign-in and nothing else: for `levelcode.levelcode-ai/auth/callback`
200+
only, and only the one-time `code` — never a token in the link (the old flow, which the desktop
201+
still accepts) or the rest of the query. The extension then takes a code only while a sign-in of
202+
its own is pending (`host.caps.unaskedSignIn` is off in a browser): a link nobody asked for cannot
203+
sign the user in to someone else's account.
182204
- **`levelcode.web.openAuthUrl` is an open-redirect if it is loose**, and any extension in the page
183205
can call it. It goes only to `<account origin>/ai/…`.
184206
- The address handed over is `encodeURI(uri.toString(true))` — the string the editor's own opener
@@ -257,7 +279,7 @@ node web/serve.mjs --dist dist-web # http://127.0.0.1:8800
257279
| `--webview-origin 'https://{{uuid}}.view.example.com'` (`LEVELCODE_WEB_WEBVIEW_ORIGIN`) | Self-host the webview origin. Without it the page uses Code-OSS's CDN at this build's own commit. Needs wildcard DNS and TLS — see below. |
258280
| `--ext-host-origin 'https://{{uuid}}.ext.example.com'` (`LEVELCODE_WEB_EXTHOST_ORIGIN`) | Run the extension host on an origin of its own. Optional while no third-party code is loaded. |
259281
| `--static <dir>` | Use a prebuilt client (skips the gulp build). |
260-
| `--id <id>` | Override the build id (default: a fingerprint of the client, the page and the extension). |
282+
| `--id <id>` | Override the build id (default: a hash of everything the build put under `/_/<id>/`, staged first and named after it). |
261283

262284
**Dev server** (`web/serve.mjs`): `--static <vscode-web dir> --extensions <dir> --extension-names
263285
levelcode-ai,levelcode-web --account <origin>`, plus `--product <json>` and `--config-defaults
@@ -365,7 +387,9 @@ causing the effect). Use another registrable domain.
365387
| Editor settings, layout | the workbench's own storage | editor origin |
366388

367389
Clearing the browser's site data removes all of it. A private window may discard it on close; the
368-
secret store then falls back to memory for the session.
390+
secret store then falls back to memory for the session. The key the secrets are sealed with is created
391+
once per browser profile: two tabs opened together on a fresh profile store it in one IndexedDB
392+
transaction, so both end with the same key.
369393

370394
## Keeping the desktop and the browser in step
371395

@@ -392,7 +416,7 @@ secret store then falls back to memory for the session.
392416

393417
| Check | Command | What it proves |
394418
| --- | --- | --- |
395-
| 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. |
419+
| 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, every syntax colour at 4.5:1 on the editor, 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. |
396420
| The shell | `./scripts/test-extensions.sh` (the unit suites `web/test/unit/shell-*.test.js`) | The engine: a differential against GNU bash 5.1 (905 cases, a shrink-only table of the ones that differ), fuzzing, every patch of the vendored code with its anchors, the build; the runtime: the commit and its refusals, kill and deadline against a **real engine in a worker thread**, limits; the terminal: key bytes, the line editor against real xterm.js 6.1 in its headless build, width, the output filter, history. With the Code-OSS checkout present the real-xterm halves run; `LEVELCODE_REQUIRE_XTERM=1` and `LEVELCODE_REQUIRE_WORKER=1` make a skip a failure, which is what CI sets. |
397421
| Mutations | `node web/test/helpers/mutation/mutate.mjs` | 371 edits to the runtime, the commit and the sanitiser, each of which a suite must catch (a tool, not a suite). |
398422
| The shell, in a browser | `node web/test/e2e-shell.mjs --static … --extensions …` | Real headless Chrome and the real nested Worker: nothing of the shell is fetched until it is used; a terminal from the palette; typing, files, a working folder that persists, 127, Ctrl+C on `sleep 60`, a link to `path:line:col` that opens the file at that line, a pasted three-line buffer through a resize; hostile output that does not type into the prompt or rename the terminal; the agent offered `run_command` without background, a file written through the review (Keep/Undo), a `.vscode` write refused whole, a timeout that applies nothing, the mirror tab. |

‎extensions/levelcode-ai/agent.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ async function scanSearch(term, root) {
218218
const out = [];
219219
let size = 0;
220220
let uris = [];
221-
try { uris = await host.findFiles('**/*', FILE_EXCLUDES, 2000); } catch { return ''; }
221+
// Scoped to `root` BEFORE the cap: across the whole workspace, another folder's files could use up all 2,000.
222+
try { uris = await host.findFiles(new vscode.RelativePattern(host.uriFor(root), '**/*'), FILE_EXCLUDES, 2000); } catch { return ''; }
222223
const rootPrefix = root.endsWith('/') ? root : root + '/';
223224
for (const uri of uris) {
224225
if (size >= 6000) { break; }

‎extensions/levelcode-ai/extension.js‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,8 @@ async function contentHits(terms, dir) {
931931
if (!dir) { return hits; }
932932
const prefix = dir.endsWith('/') ? dir : dir + '/';
933933
let uris = [];
934-
try { uris = await host.findFiles('**/*', '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/*.map,**/*.min.*}', 400); } catch { return hits; }
934+
// Scoped to `dir` BEFORE the cap: across the whole workspace, another folder's files could use up all 400.
935+
try { uris = await host.findFiles(new vscode.RelativePattern(host.uriFor(dir), '**/*'), '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/*.map,**/*.min.*}', 400); } catch { return hits; }
935936
const needles = terms.map((t) => t.toLowerCase());
936937
const deadline = Date.now() + 4000; // the same budget rgFiles gives ripgrep
937938
for (const uri of uris) {
@@ -1094,7 +1095,10 @@ function recordDiagramStat(ev) {
10941095
const diagramFolders = () => (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath }));
10951096
/** This conversation's diagrams. One for the life of the extension; reset with the conversation. */
10961097
const diagrams = createDiagrams({
1097-
resolveLink: (link) => diagramLinks.resolveLink(link, diagramFolders()),
1098+
// A diagram's links are checked as it is drawn, and that cannot wait. Where files can only be looked at
1099+
// asynchronously (LevelCode in a browser) they are checked against the files the extension has indexed
1100+
// (buildFileIndex); the desktop reads the disk, as before. The click checks the file itself.
1101+
resolveLink: (link) => diagramLinks.resolveLink(link, diagramFolders(), { fs: host.knownFilesView([...workspaceFiles.values()].map((u) => u.path)) || undefined }),
10981102
onStat: recordDiagramStat
10991103
});
11001104
/** True once a diagram's spec has left the model's context (a compaction, or a resume that did not fit) —
@@ -1117,7 +1121,7 @@ async function openDiagramLink(id, nodeId) {
11171121
const node = record && record.spec ? record.spec.nodes.find((n) => n.id === String(nodeId)) : null;
11181122
if (!node || !node.link) { dbg('diagram.link.unknown', { id }); return; }
11191123
// Checked again at the moment of the click: the file may have moved, or become a link, since it was drawn.
1120-
const r = diagramLinks.resolveLink(node.link, diagramFolders());
1124+
const r = await host.withStats((fs) => diagramLinks.resolveLink(node.link, diagramFolders(), { fs }));
11211125
if (!r.ok) { vscode.window.showWarningMessage('LevelCode: that link cannot be opened — ' + r.reason + '.'); dbg('diagram.link.refused', { reason: r.reason }); return; }
11221126
recordDiagramStat({ type: 'link' });
11231127
try {
@@ -3567,10 +3571,16 @@ async function handleAuthCallback(uri) {
35673571
try {
35683572
const q = new URLSearchParams(uri.query || '');
35693573
const code = q.get('code');
3574+
const verifier = ctx ? await ctx.secrets.get(ACCOUNT_VERIFIER_KEY) : null;
3575+
// Where the callback is a page anyone can link to (LevelCode in a browser), a link nobody asked for must not
3576+
// sign the user in to someone else's account: only a code that answers a sign-in this editor started is taken.
3577+
if (!host.caps.unaskedSignIn && (!code || !verifier)) {
3578+
dbg('account.callback', { ignored: code ? 'no sign-in pending' : 'not a sign-in code' });
3579+
return;
3580+
}
35703581
// Preferred: PKCE code → exchange for tokens using the verifier we stashed at sign-in.
35713582
if (code) {
35723583
const endpoint = cloudApiUrl(); // token exchange is a BACKEND (Rails) call, not the website
3573-
const verifier = ctx ? await ctx.secrets.get(ACCOUNT_VERIFIER_KEY) : null;
35743584
try {
35753585
const res = await fetch(endpoint + '/api/levelcode/v1/auth/exchange', {
35763586
method: 'POST',

0 commit comments

Comments
 (0)