Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/lld/06-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ It uses no jQuery, DataTables, select2 or Font Awesome.

**Opening a question** (`js/question-loader.js`, `fetchLanguageTmplAndSetEditorcode` in `index.html`, `getCodeTemplate` in `common.js`). A question on `/practice?name=<slug>` that has no description or no starter for the chosen language is written by Genie when it is first opened. A card (`#question-loading`, over `.editor-body`) says so from the moment the page loads, not only when the fetch starts: "Getting your question ready…", then "Genie (GPT-6 Luna) is writing “Two Sum” and a starter for Python. This usually takes 5 to 20 seconds." (the model chosen in Genie's model menu), with "Still working. A busy model can take up to a minute." after 20 seconds and a Cancel button throughout (Esc cancels too). The request is dropped after **60 seconds** (`QuestionLoader.settings.timeoutMs`; the proxy's own limit to the model is 90 seconds, `openAITimeout`, and a visitor should not wait for that). Whatever goes wrong, the card turns into an error card (`role=alert`) that says why, with Try again and Close: `getCodeTemplate` and `apiFailure` throw an Error with `.kind` (`timeout`, `offline` when the request could not be made, `limit` for 429, `auth` for 401 and 403, `off` when Genie or the model is switched off (503 `GenieDisabled`, `model_unavailable`; the proxy's own words are shown), `empty` when the model answered with nothing in it (the proxy's 502 `model_empty` for an OpenRouter model, or a reply without content seen by the page; the card says which model and why as far as the reply told, for example that it used its whole answer budget thinking), `format` for an answer that cannot be used, otherwise `server` with the HTTP status), and `describeFailure` turns each into a title, a sentence and, where there is one, the status. Cancel is not an error: the card goes away. Close, and a cancel, leave the editor with the question's header (name, level, description) without the starter code; a description that is not there says so instead of "null". A newer load (another language picked meanwhile) stops the older one without showing an error. A question that is already complete (description and a starter for the language) shows no card. Before this, a failure only wrote to the console, there was no time limit, nothing showed during the 4-second wait, and the description Genie wrote was never saved, so a starter question opened as "Problem Description: null". Tests: `src/js/test/question-loader.test.mjs`.

**The IDE's dropdowns** (`js/select-menu.js`, `.sm-*` and `.select-wrap.has-menu` in `css/ui-refresh.css`). The language picker (`#optionlist`), the editor's syntax mode (`#select-lang`) and the theme (`#select-theme`) carry `data-select-menu`, and `SelectMenu.init()` replaces the list the browser draws with a button and a menu of the IDE's own: dark, with a check on the current choice, a search box when a list has more than 8 options (names, extensions and group names are searched, so `.js` finds both JavaScripts and `dark` the dark themes), a file extension next to each language (`data-ext` on the `<option>`), a colour next to each theme (`data-swatch`) and the theme's Bright and Dark groups. The menu is appended to `body` (the IDE clips its children), sits at `z-index` 10035 (over the Genie panel, under the context menus and the palette), opens under its button or above it when the status bar at the bottom leaves no room, and closes on Esc, a click outside, Tab, a scroll or a resize. Keys: arrows, Home, End, PageUp, PageDown, Enter, and typing to search (or, in a list with no search box, to jump). **The `<select>` stays in the page as the source of truth**, out of sight (1px, `aria-hidden`, `tabindex=-1`): choosing sets it and fires `input` and `change` as the browser would, and the instance's own `value` and `selectedIndex` redraw the button when the page sets them (`select.value = …`, which is what the chips, the URL and the share link do; jQuery's `.val(x)` setter would not be seen, and nothing here uses it), while `focus()` and `showPicker()` open the menu, which is what the command palette and the home page's "All 19" chip call. The tour points at `#optionlist-button`. A touch screen (`(hover: none), (pointer: coarse)`) keeps the browser's own picker. Without the script, or if it throws for a select, that select stays as it was. To add it to another select: `data-select-menu` on the `<select>` inside a `.select-wrap`. Tests: `src/js/test/select-menu.test.mjs` (the list, the search, where the menu goes, the keys); the page part was checked in a browser.

**New question dialog** (`#modal`, same markup on both pages). A topic text field with suggestions from `topics` (a `<datalist>`, so any topic can be typed), level, creativity (the LLM temperature) and extra instructions. `practice-store.js` moves focus into it, keeps Tab inside, closes it on Esc and returns focus, and shows "Generating…" on the button while `common.js` works.

**In the editor** (`/practice?<Lang>&name=<slug>`), `index.html` adds:
Expand Down
2 changes: 1 addition & 1 deletion src/js/src/page/03-run-and-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function StartTour() {
var steps = [
{ title: 'Welcome to OpenREPL', intro: 'A one-minute tour of the workspace. Press Esc at any time to leave.' },
{ element: q('#lang-chips'), intro: 'Pick a language here, or use the picker in the workspace. A fresh REPL starts in its own sandbox.' },
{ element: q('#optionlist'), intro: 'All 19 languages are in this picker.' },
{ element: q('#optionlist-button') || q('#optionlist'), intro: 'All 19 languages are in this picker.' },
{ element: q('#terminal-div'), title: 'The REPL', intro: 'Type a line and press Enter to see the result straight away. Use + to open more terminals.' },
{ element: q('#ide'), title: 'The editor', intro: 'Write longer code here. The status bar has the editor language, theme and font size.' },
{ element: q('.run-split'), title: 'Run', intro: 'Run the editor code with Ctrl+Enter (Cmd+Enter on a Mac). The arrow opens Debug, program arguments and environment variables.' },
Expand Down
127 changes: 127 additions & 0 deletions src/js/test/select-menu.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// The parts of the IDE's dropdown menu (resources/js/select-menu.js) that need no
// page: the list built from a select, the search, where the menu goes, how the
// keys move, and type-ahead. The page part is checked in a browser.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const source = fs.readFileSync(path.join(here, "..", "..", "resources", "js", "select-menu.js"), "utf8");

function load() {
const window = { matchMedia: () => ({ matches: true }) }; // a touch screen: init() enhances nothing
const ctx = { window, document: { readyState: "complete", querySelectorAll: () => [] }, console, Object, HTMLSelectElement: function () {} };
vm.createContext(ctx);
vm.runInContext(source, ctx);
return window.SelectMenu;
}
const SM = load();
// what the script made lives in another realm (the vm): compared as plain data
const plain = (x) => JSON.parse(JSON.stringify(x));

// a select as the page has it: options, some in groups, with data-ext and data-swatch
const opt = (value, text, attrs = {}, disabled = false) => ({
tagName: "OPTION", value, text, disabled,
getAttribute: (k) => (k in attrs ? attrs[k] : null),
});
const group = (label, children) => ({ tagName: "OPTGROUP", children, getAttribute: (k) => (k === "label" ? label : null) });
const select = (children) => ({ children });

const languages = select([
opt("c", "C", { "data-ext": ".c" }), opt("python", "Python", { "data-ext": ".py" }), opt("python2.7", "Python2.7", { "data-ext": ".py" }),
opt("ipython3", "IPython3", { "data-ext": ".py" }), opt("javascript", "JavaScript", { "data-ext": ".js" }), opt("node", "NodeJS", { "data-ext": ".js" }),
]);

test("the list is made of the options, in order, with their extension and colour", () => {
const items = plain(SM.itemsOf(languages));
assert.deepEqual(items.map((i) => i.label), ["C", "Python", "Python2.7", "IPython3", "JavaScript", "NodeJS"]);
assert.deepEqual(items.map((i) => i.index), [0, 1, 2, 3, 4, 5]);
assert.equal(items[1].badge, ".py");
assert.equal(items[1].value, "python");
assert.equal(items[0].swatch, "");
assert.equal(items[0].group, "");
});

test("options in groups keep their group, and count through the groups", () => {
const themes = select([
group("Bright", [opt("a/chrome", "Chrome", { "data-swatch": "#ffffff" }), opt("a/github", "GitHub", { "data-swatch": "#ffffff" })]),
group("Dark", [opt("a/monokai", "Monokai", { "data-swatch": "#272822" })]),
]);
const items = plain(SM.itemsOf(themes));
assert.deepEqual(items.map((i) => [i.label, i.group, i.index, i.swatch]), [["Chrome", "Bright", 0, "#ffffff"], ["GitHub", "Bright", 1, "#ffffff"], ["Monokai", "Dark", 2, "#272822"]]);
});

test("whitespace in an option's text is not shown, and a disabled option is marked", () => {
const items = SM.itemsOf(select([opt("x", " Spaced \n"), opt("y", "Off", {}, true)]));
assert.equal(items[0].label, "Spaced");
assert.equal(items[1].disabled, true);
});

test("typing narrows the list by name, by extension and by group", () => {
const items = SM.itemsOf(languages);
const names = (q) => plain(SM.filterItems(items, q)).map((i) => i.label);
assert.deepEqual(names(""), ["C", "Python", "Python2.7", "IPython3", "JavaScript", "NodeJS"]);
assert.deepEqual(names("py"), ["Python", "Python2.7", "IPython3"], "inside a word too: IPython3");
assert.deepEqual(names("PY"), ["Python", "Python2.7", "IPython3"], "any case");
assert.deepEqual(names(".js"), ["JavaScript", "NodeJS"], "by extension");
assert.deepEqual(names("py 2"), ["Python2.7"], "every word has to match");
assert.deepEqual(names("zzz"), []);
const themes = SM.itemsOf(select([group("Bright", [opt("a", "Chrome")]), group("Dark", [opt("b", "Monokai"), opt("c", "Dracula")])]));
assert.deepEqual(plain(SM.filterItems(themes, "dark")).map((i) => i.label), ["Monokai", "Dracula"], "a group name finds its members");
});

test("the menu goes under its button, at least as wide, and inside the window", () => {
const view = { width: 1200, height: 800 };
const at = SM.placeMenu({ left: 100, right: 260, top: 60, bottom: 96 }, 240, 300, view);
assert.deepEqual({ ...at }, { left: 100, width: 240, up: false, top: 100, bottom: null, maxHeight: 692 });
// a wide button makes a wide menu
assert.equal(SM.placeMenu({ left: 100, right: 500, top: 60, bottom: 96 }, 240, 300, view).width, 400);
// against the right edge it slides left
const right = SM.placeMenu({ left: 1150, right: 1190, top: 60, bottom: 96 }, 240, 300, view);
assert.equal(right.left, 1200 - 240 - 8);
// against the left edge it stays inside
assert.equal(SM.placeMenu({ left: 0, right: 20, top: 60, bottom: 96 }, 240, 300, view).left, 8);
});

test("in the status bar at the bottom the menu opens upwards", () => {
const view = { width: 1200, height: 800 };
const at = SM.placeMenu({ left: 900, right: 1000, top: 760, bottom: 784 }, 240, 300, view);
assert.equal(at.up, true);
assert.equal(at.top, null);
assert.equal(at.bottom, 800 - 760 + 4, "it hangs from the top of the button, so a shorter list stays attached");
assert.equal(at.maxHeight, 760 - 4 - 8);
// with room on neither side it uses the larger, and never less than 120px
const cramped = SM.placeMenu({ left: 10, right: 80, top: 40, bottom: 70 }, 240, 300, { width: 1200, height: 100 });
assert.ok(cramped.maxHeight >= 120);
// a window narrower than the menu
assert.equal(SM.placeMenu({ left: 10, right: 80, top: 60, bottom: 90 }, 240, 100, { width: 200, height: 800 }).width, 184);
});

test("the keys move through the shown items, and skip what is off", () => {
const items = SM.itemsOf(select([opt("a", "A"), opt("b", "B", {}, true), opt("c", "C"), opt("d", "D")]));
const m = (active, key) => SM.moveActive(items, active, key);
assert.equal(m(-1, "ArrowDown"), 0);
assert.equal(m(0, "ArrowDown"), 2, "B is off");
assert.equal(m(3, "ArrowDown"), 3, "the end stays at the end");
assert.equal(m(2, "ArrowUp"), 0);
assert.equal(m(0, "ArrowUp"), 0);
assert.equal(m(-1, "ArrowUp"), 3, "up from nothing is the last");
assert.equal(m(2, "Home"), 0);
assert.equal(m(0, "End"), 3);
assert.equal(m(0, "x"), 0, "any other key leaves it");
assert.equal(SM.moveActive([], -1, "ArrowDown"), -1);
assert.equal(SM.moveActive(items, 0, "PageDown", 2), 2);
});

test("typing in a list with no search box jumps to a name that starts so", () => {
const items = SM.itemsOf(select([opt("c", "Chrome"), opt("cl", "Clouds"), opt("d", "Dawn"), opt("dr", "Dracula")]));
assert.equal(SM.typeAhead(items, "d", 0), 2, "from the one before, the next that starts with d");
assert.equal(SM.typeAhead(items, "d", 2), 3, "the same letter again goes on to the next");
assert.equal(SM.typeAhead(items, "dr", 2), 3, "more letters narrow it");
assert.equal(SM.typeAhead(items, "cl", 0), 1);
assert.equal(SM.typeAhead(items, "z", 0), -1);
assert.equal(SM.typeAhead(items, "", 0), -1);
});
2 changes: 1 addition & 1 deletion src/resources/chat-widget/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const NEW_TERMINAL_WAIT_MS = 12000;
const TARGETS: Record<string, string> = {
editor: ".editor-body",
run: ".run-split",
language: "#optionlist",
language: "#optionlist, #optionlist-button", // the picker is a button and a menu now (js/select-menu.js); the select is out of sight
output: "#terminal-div",
tabs: "#terminal-tabs",
files: "#file-browser",
Expand Down
123 changes: 123 additions & 0 deletions src/resources/css/ui-refresh.css
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,129 @@ body.genie-dock-dragging iframe { pointer-events: none; }
padding: 0 34px 0 12px;
}

/* The menu of our own for the IDE's dropdowns (js/select-menu.js): the <select>
stays in the page, out of sight, and a button and a menu take its place. */
.select-wrap.has-menu > select {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.select-wrap.has-menu > svg:not(.sm-chevron) { display: none; }
.sm-btn {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0;
text-align: left;
font: 500 14px var(--font-ui);
color: var(--paper);
background: var(--ink-750);
border: 1px solid var(--ink-600);
border-radius: 8px;
cursor: pointer;
}
.sm-btn:hover,
.sm-btn:focus-visible,
.sm-btn[aria-expanded="true"] { border-color: var(--muted-3); }
.sm-btn:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
.sm-btn:disabled { opacity: 0.5; cursor: default; }
.sm-value { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.select-wrap--lang .sm-btn { height: 36px; min-width: 170px; padding: 0 34px 0 12px; }
.select-wrap--status .sm-btn {
height: 24px;
padding: 0 24px 0 8px;
font-size: 12px;
color: var(--muted-1);
background: transparent;
border-color: transparent;
}
.select-wrap--status .sm-btn:hover,
.select-wrap--status .sm-btn[aria-expanded="true"] { color: var(--paper); border-color: var(--ink-600); }
.sm-swatch { flex: none; width: 12px; height: 12px; border-radius: 3px; border: 1px solid rgba(255, 255, 255, 0.28); }

.sm-menu {
position: fixed;
z-index: 10035; /* over the Genie panel, under the menus and the palette */
box-sizing: border-box;
display: flex;
flex-direction: column;
max-width: calc(100vw - 16px);
padding: 6px;
color: #E6E4EC;
font: 13px var(--font-ui);
background: var(--ink-850, #1B1B24);
border: 1px solid var(--ink-500, #3A3A48);
border-radius: 10px;
box-shadow: 0 16px 36px -10px rgba(0, 0, 0, 0.7);
}
.sm-menu[hidden] { display: none; }
.sm-search {
display: flex;
align-items: center;
gap: 6px;
flex: none;
margin-bottom: 6px;
padding: 6px 8px;
color: var(--muted-2, #8C8A98);
background: var(--ink-950, #0D0D12);
border: 1px solid var(--ink-600, #34343F);
border-radius: 7px;
}
.sm-search:focus-within { border-color: var(--accent-color); box-shadow: 0 0 0 1px var(--accent-color); }
/* The field is bare: the box around it is the field. (The page styles every text
input with a border, a padding and a focus ring; those are turned off here, or
there are two boxes, one in the other.) */
.sm-menu .sm-search input[type=text],
.sm-menu .sm-search input[type=text]:focus,
.sm-menu .sm-search input[type=text]:focus-visible {
flex: 1;
min-width: 0;
width: auto;
height: auto;
margin: 0;
padding: 0;
font: inherit;
line-height: 1.4;
color: var(--paper);
background: none;
border: 0;
border-radius: 0;
box-shadow: none;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.sm-menu .sm-search input::placeholder { color: var(--muted-2, #8C8A98); opacity: 1; }
/* the page puts a focus ring on every focused thing, with !important; the ring of
this field is the box around it (:focus-within), as in the command palette */
body .sm-menu .sm-search input:focus,
body .sm-menu .sm-search input:focus-visible { outline: none !important; box-shadow: none !important; }
.sm-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; outline: none; scrollbar-width: thin; }
.sm-group { padding: 8px 8px 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted-2, #8C8A98); }
.sm-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; cursor: pointer; color: #E6E4EC; }
.sm-item.is-active { background: var(--ink-600, #2A2A36); }
.sm-item.is-current { color: #fff; }
.sm-item.is-disabled { opacity: 0.45; cursor: default; }
.sm-label { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.sm-badge { flex: none; min-width: 30px; padding: 1px 6px; font: 11px var(--font-code, monospace); text-align: center; color: var(--muted-2, #8C8A98); background: var(--ink-700, #24242E); border-radius: 4px; }
.sm-check { flex: none; color: var(--accent-color); visibility: hidden; }
.sm-item.is-current .sm-check { visibility: visible; }
.sm-empty { margin: 0; padding: 10px 8px; font-size: 12px; color: var(--muted-2, #8C8A98); }
.sm-empty[hidden] { display: none; }
.sm-hint { display: flex; justify-content: space-between; flex: none; margin-top: 6px; padding: 6px 8px 2px; font-size: 11px; color: var(--muted-3, #6f6d7c); border-top: 1px solid var(--ink-700, #24242E); }
@media (prefers-reduced-motion: no-preference) {
.sm-menu.is-open { animation: sm-in 0.1s ease-out; }
@keyframes sm-in { from { opacity: 0; transform: translateY(-3px); } }
.sm-menu.is-open.is-up { animation-name: sm-in-up; }
@keyframes sm-in-up { from { opacity: 0; transform: translateY(3px); } }
}

/* ---------- app bar ---------- */
.app-bar {
display: flex;
Expand Down
Loading
Loading