diff --git a/docs/README.md b/docs/README.md index 4fc68cf0..11975b29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ Go code uses a GOPATH layout: the repo root is `GOPATH`, packages live under `sr - **REPL**: an interactive interpreter (Read-Eval-Print Loop), for example `cling` or `python`. - **Master / slave (webtty)**: the *master* is the browser WebSocket; the *slave* is the PTY-backed process. -- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#` and mirrors it through Firebase. +- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#` (a Firebase push key; any other `#anchor` is just an anchor) and mirrors it through Firebase. - **jid**: an encoded PID of a running REPL (`encoder.EncodePID`). Used by **Fork REPL** and by extra terminal tabs to join that REPL's namespaces. - **optionchange / optionrun / optiondebug**: DOM events on a terminal element. They mean, respectively: switch language (start a fresh REPL), run the editor content, and run it in debug mode. - **Demo**: an entry in `demos.xml` describing one REPL (animation, usage, docs, starter code, Compiler script). diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index df44e300..714f45f7 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -101,7 +101,7 @@ flowchart LR ## 4. Live sharing (`FireTTY`) -A page is the **master** when the URL has no `#hash`. It is a **slave** (viewer) when it was opened as `…/#`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`. +A page is the **master** unless it was opened with a share link. It is a **slave** (viewer) when it was opened as `…/#`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`. Only a hash that has the shape of a push key counts (`js/src/share-id.ts`: `-` then 12 to 30 of `A-Za-z0-9_-`; the SDK's keys are 20 long and start with `-` until 2109). Any other hash is an anchor of the page (`#languages`, `#workspace`, `#request`), and the page stays a master: before this rule every hash made a viewer, so using the home page's own links broke the terminal ("master terminal is not available"). The id is read once, when each script loads (the page script, the terminal client and the Genie widget each have it), so clicking an anchor later changes neither a master nor a viewer; a different share link pasted into the address bar of an open page loads the page again. The widget keeps its own copy of the pattern, because it is built apart; `test/share-id.test.ts` compares the two. Tests: `src/js/test/share-id.test.mjs` (the rule, and the page script run with a pretend page). ```mermaid sequenceDiagram @@ -168,6 +168,12 @@ It uses no jQuery, DataTables, select2 or Font Awesome. **Store** (`js/practice-store.js`, `window.PracticeStore`), used by the list and the editor: `all()`, `find(id or slug)`, `isDone`, `setDone`, `add` (what `saveNewQuestions` now calls), `update` (sets `updated`), `remove` (leaves a tombstone), `init()` (starts account sync), `status()`, `onChange(fn)`. Storage keys and sync: LLD 05. Another tab's changes arrive through the `storage` event. +**Starter questions** (`js/dsa.json`, `seed()` in `practice-store.js`). The file lists about 300 DSA problems as `{title, topic, difficulty}`. When either practice page loads, `seed()` fetches it and adds to the visitor's `questions` list every problem that is not there yet: id `s-`, `starter: true`, a readable name ("Best Time to Buy and Sell Stock II"), no description or code, and `added` counting down from 1 Jan 2025 so that they sort after anything generated, in the file's order. They are ordinary entries, so the pages that read `localStorage.questions` directly (the editor, `getCodeTemplate`) find them; a description and code are generated when one is first opened, as for any question. Starters are not counted in the 100 generated questions a visitor keeps (`trimOwn`), and one nobody has opened is not sent to the account (`untouched`); its done mark and a deletion are, in `practiceState`, which is also what keeps a deleted starter from coming back. A problem added to the file later reaches existing visitors on their next visit. The editor waits for `PracticeStore.ready()` before it looks for its question, so a link to a starter works on a first visit. Tests: `src/js/test/practice-seed.test.mjs`. + +**Pages of the list** (`scribbler-misc.js`: `render`, `renderPager`, `pageNumbers`; `#q-pager` in `practice.html`). The list shows one page of rows at a time: 10, 20 (the default), 40 or 100 (`#f-size`, kept with the filters in `practiceFilters`). Under the table are the size, the range ("21–40 of 301", or "301 of 301") and Prev, page numbers and Next, with the first and last page always shown, a window around the current one and `…` for the gaps; the current page has `aria-current="page"`. A search, a filter, the sort or the size takes the list back to page 1; a tick or a delete keeps the page (and moves back to the last one if a delete left fewer). After a page button is pressed the keyboard stays on Prev, Next or the current page, which are drawn again, and the top of the list scrolls into view. With 10 rows or fewer the pager is hidden. On a phone the page buttons wrap under the size. + +**Opening a question** (`js/question-loader.js`, `fetchLanguageTmplAndSetEditorcode` in `index.html`, `getCodeTemplate` in `common.js`). A question on `/practice?name=` 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`. + **New question dialog** (`#modal`, same markup on both pages). A topic text field with suggestions from `topics` (a ``, 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?&name=`), `index.html` adds: diff --git a/docs/lld/07-ai-features.md b/docs/lld/07-ai-features.md index 66bba8fa..3541427b 100644 --- a/docs/lld/07-ai-features.md +++ b/docs/lld/07-ai-features.md @@ -95,6 +95,8 @@ Genie can work on a task in steps in the editor. Full design and decisions: `doc - **A closed panel** (`index.ts`, `renderActivity`): closing the panel hides it and stops nothing. A chat request or a task goes on (the message list and the task card are kept outside the panel's markup, which is built again on each open), and the two Ask Genie buttons (`[data-chat-widget-button]`, `#genie-button`) get `data-genie-state`: `working` (the icon turns; the floating one reads "Genie is working… 3/8" or "Genie is thinking…"), `needs` (amber dot: a permission question or a diff is open, and the task waits for it), `done` or `failed` (green or red dot: a result came while the panel was closed, cleared when it is opened). While the panel is closed the tab title carries the same words ("(1) Task done · …") and a hidden `role=status` element says them to a screen reader. The state is worked out each time from four facts (`needsYou`, `agentRunner.running`, `chatInFlight`, `unread`), which the runner reports through three optional host calls: `attention(on)`, `progress(n, max)` and `ended("done" | "failed" | "stopped")`. On open, the send button is set from the same facts. Only Stop, a reload or leaving the page ends a task; the server's limits (steps, 15 minutes, tasks an hour) are unchanged. - **The REPL guide** (`server/repl_guide.go`): every step names the REPL of the language in use (`repl`, the name the page asks `/demo?q=` for), and `agentBody` puts a second system message after the protocol: the REPL's prompt, its commands, an example session and, where there are any, notes on its habits. All of it comes from the REPL's entry in `resources/meta/demos.xml` (`Codes`, `Usage`, and `Notes`, which is for Genie only and is not in the `/demo` answer), so a REPL added there has a guide, and the text is never the page's: a name without a demo adds nothing, and `repl` is not sent on. gointerpreter has notes (a block runs only with `:r`, `:r` runs the session and not the editor, `:c` after an error), written after a model was seen restarting the terminal and typing `:r` where a line of Go was wanted. A guide is at most 3,600 characters. Chat mode does not get it; the note `knowledge/go-repl.md` answers a user's question there. - **What is left** (`server/usage.go`, `index.ts` `renderUsage`): `GET /chat/usage` and the `X-OpenREPL-Usage` header of every answer of `/chat/completions` carry the same JSON: `left`, `cap`, `perMinute`, `unlimited` (an admin), `signedIn`, and for a signed-in user where agent mode is on `agent: {used, perHour, nextFreeSeconds, maxSteps}`. Asking saves no cookie and charges nothing (`cookie.OpenApiRequestBalance` works the recharge out without saving it; `agentLedger.started` counts the hour's tasks). The panel asks when it opens and reads the header after each answer and each agent step; between them it counts the recharge itself, once a second. It shows one line under the composer ("52 of 60 requests left"; in Agent mode "17 of 20 tasks left this hour"; amber when few are left; "No requests left · next in 40 s" in red at zero; "No limit (admin)"), with the details behind a click, and it is hidden in peer chat. At zero the send button is dimmed and a submit is not sent (the line flashes and the text stays in the box), so the visitor does not meet the proxy's 429. The count shown is the balance rounded up, because the proxy takes a request while the balance is above 0. +- **Peer chat** (`index.ts`: `peerName`, `peerNote`, `PeerSpeaker`, `setupFBListener`). With the Peer chat switch on, the people in a shared session talk to each other through Firebase `chat-list/` instead of asking Genie. Each person is `user-<5 hex>` (`peerId()`, kept in `localStorage` `genie-peer-id`, so the name is the same in every tab and visit of a browser; the same id prefixes their questions to Genie), and each tab also has its own `TAB` id so that two tabs of one browser still show each other's messages (a message is skipped only when `tab` is this tab's; an older page that sends no `tab` is skipped by `uid`). What is on screen depends on who looks: your own messages are the light bubble on the right, labelled "You · user-xxxxx", everybody else's are on the left under their name. A message typed in a peer chat is stored with `peer: true` and `name`; the rest of the mirrored conversation (the owner's Genie questions and its answers) has no `peer` flag and is shown as it always was. Small notes (`.chat-widget__peer-note`) say "user-xxxxx joined the chat", "... left the chat", "... turned peer chat on" (and, to the person who turned it on, "Peer chat is on. You are user-xxxxx."). A viewer who opens a share link writes a `join` event once the listener is up (and tells itself "You joined the chat as ..."), and a `leave` event on `pagehide`/`unload` (best effort: the page may be gone before it is sent; a page restored from the back/forward cache joins again). The owner writes no join event, so a page nobody shares leaves nothing in the database, and says who it is with its first message; the owner's `unload` still removes the whole `chat-list/` entry. Late joiners get the history replayed, notes included. +- **A reply with no answer in it** (`chatproxy.go`: `emptyAnswer`, `modelAnswerEmpty`). OpenRouter can answer 200 and still have nothing to show: an `error` object in the body or in the first choice, no choices, or a message whose content is empty (a free model that is busy, or one that spent its whole budget thinking: `finish_reason: "length"`). For a whole (not streamed) OpenRouter reply the proxy reads the body (up to 4 MB) and, when it finds that, answers 502 with type `model_unavailable` and code `model_empty`: " sent back an empty answer: it used its whole answer budget before writing anything …" / "it wrote nothing" / "it stopped without writing anything ()" / "its host reported an error". The host's own words stay in the log (they can name the account), the request is not charged, and an agent step is given back. A real answer, a tool call, content in parts and anything that is not JSON are passed on byte for byte. The Genie panel shows it as it shows an unavailable model (retry, or another model); the practice editor's card has its own text for it (kind `empty`, LLD 06). A model that keeps answering `length` needs more *room for thinking* in the dashboard (OpenRouter models card). - **Admin:** the Genie settings (`agentDisabled`, `agentOnPractice`, `agentTasksPerHour`, `agentMaxSteps`) are in the Agent mode card of `/admin` (LLD 13) and in `settings.js` (`agentEnabled`, true unless an admin switched it off and only while Genie itself is on, `agentOnPractice`, `agentMaxSteps`). - **Tests:** `agent_test.go` (the ledger: steps, tokens, expiry, the hourly count, giving a step back; the proxy: guest, switched off, practice, charging only the first step, the model failing, admins, the settings and the audit log; and a test that compares the action list with `agent-protocol.ts`); `test/agent-protocol.test.ts` in the chat widget (`npm test` there; Node 22.18 or later), for what the page accepts from the model. diff --git a/src/js/src/firetty.ts b/src/js/src/firetty.ts index ea8632be..028bdbbf 100644 --- a/src/js/src/firetty.ts +++ b/src/js/src/firetty.ts @@ -2,6 +2,7 @@ // instead of bundling a second copy (Firebase 3.9) into gotty-bundle.js (T16). declare var firebase: any; import { Terminal, eventHandler, eventhandlertype, CloserArgs} from "./webtty"; +import { shareIdFrom } from "./share-id"; var dbpath = ""; @@ -26,10 +27,10 @@ export const getExampleRef = () => { return window['dbpath']; } var ref = getrepl_firebasedbref(); - var hash = window.location.hash.replace(/#/g, ''); - if (hash) { - window['dbpath'] = hash; - return hash; + var shared = shareIdFrom(window.location.hash); + if (shared) { + window['dbpath'] = shared; + return shared; } ref = ref.push(); if (ref.key) { diff --git a/src/js/src/gotty.ts b/src/js/src/gotty.ts index a918affc..19c375a3 100644 --- a/src/js/src/gotty.ts +++ b/src/js/src/gotty.ts @@ -2,6 +2,7 @@ import { Xterm } from "./xterm"; import { Terminal, WebTTY, protocols, jidHandler, Icallback, WebTTYFactory, IdeLangKey, IdeContentKey, IdeFileNameKey, CompilerOptionKey, CompilerFlagsKey, EnvFlagsKey, CloserArgs} from "./webtty"; import { ConnectionFactory } from "./websocket"; import { FireTTY, DisableShareBtn } from "./firetty"; +import { shareIdFrom } from "./share-id"; const option2args = { "c":"arg=-xc&arg=-noruntime", @@ -19,13 +20,11 @@ export interface CustomHTMLElement extends HTMLElement { isprimary: boolean; // if this corressponds to primary terminal tab element } +// the share id this page was opened with, read once (share-id.ts): an anchor clicked later is not a new session +const shareId = shareIdFrom(window.location.hash); + function isMaster() : boolean { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return shareId === ""; } function handleTerminalOptions(elem, option, event="optionchange") { diff --git a/src/js/src/page/01-session.js b/src/js/src/page/01-session.js index 60ac13ae..ea2b1185 100644 --- a/src/js/src/page/01-session.js +++ b/src/js/src/page/01-session.js @@ -4,6 +4,8 @@ // into js/scribbler.js. Top-level names are page globals (window.*), as they // were in the single file: index.html, palette.js and the other parts use them. +import { shareIdFrom } from "../share-id"; + window.handleClickOutside = handleClickOutside; window.ismob = ismob; window.isMaster = isMaster; @@ -13,15 +15,24 @@ window.preprocessurl = preprocessurl; window.homedir = ""; // home directory +// The share id of this page, if it was opened as /# (src/share-id.ts). It is +// read once: an anchor such as #languages that is clicked later changes the +// hash, not the session. A different share link pasted into the address bar of +// an open page is a different session, so the page loads again. +const SHARE_ID = shareIdFrom(window.location.hash); +window.addEventListener("hashchange", function () { + const now = shareIdFrom(window.location.hash); + if (now && now !== SHARE_ID) window.location.reload(); +}); + window.getExampleRef = () => { if(window.dbpath) { return window.dbpath; } var ref = firebase.database().ref("openrepl"); - var hash = window.location.hash.replace(/#/g, ''); - if (hash) { - window.dbpath = hash; - return hash; + if (SHARE_ID) { + window.dbpath = SHARE_ID; + return SHARE_ID; } ref = ref.push(); if (ref.key) { @@ -71,12 +82,7 @@ function ismob() { } function isMaster() { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return SHARE_ID === ""; // a viewer was given a share link; an anchor is not one } window.option2cmdMap = { diff --git a/src/js/src/share-id.ts b/src/js/src/share-id.ts new file mode 100644 index 00000000..10af9d35 --- /dev/null +++ b/src/js/src/share-id.ts @@ -0,0 +1,24 @@ +// What in the address of a page is a share id. +// +// A shared session is opened as https://openrepl.com/#, where is the +// Firebase push key of the owner's page: 20 characters, the first of them "-" +// (8 of time and 12 random from the alphabet - 0-9 A-Z _ a-z; the SDK checks +// the length, and the first one stays "-" until the year 2109). The accepted +// length is a little wider than 20, so that a change of the SDK's key could not +// stop sharing. +// +// Anything else after the # is only an anchor of the page: #languages, +// #workspace, #request. It must not turn the visitor into a viewer of a session +// that does not exist, which is what "any hash is a share id" did. +// +// The same rule is in chat-widget/src/index.ts (its own bundle); a test there +// compares the two patterns. Callers read the id once, when their script loads, +// and keep it: clicking an anchor later changes the hash, not the session. + +export const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; + +// The share id in a location.hash ("#-OJVdrF-2UPpWUafqBoU"), or "" if there is none. +export function shareIdFrom(hash: string): string { + const id = String(hash || "").replace(/^#/, ""); + return SHARE_ID_PATTERN.test(id) ? id : ""; +} diff --git a/src/js/test/practice-seed.test.mjs b/src/js/test/practice-seed.test.mjs new file mode 100644 index 00000000..3c64b10c --- /dev/null +++ b/src/js/test/practice-seed.test.mjs @@ -0,0 +1,162 @@ +// The starter questions of the practice page (resources/js/practice-store.js): +// they are put in the visitor's list once, they are not sent to the account +// until opened, they do not count in the 100 generated questions a visitor +// keeps, and a deleted one stays deleted. The store is run as a browser would +// run it, with a pretend page, storage and network. +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 resources = path.join(here, "..", "..", "resources", "js"); +const source = fs.readFileSync(path.join(resources, "practice-store.js"), "utf8"); +const catalog = JSON.parse(fs.readFileSync(path.join(resources, "dsa.json"), "utf8")); + +// a page with its own storage; `network` is what /js/dsa.json and /practice/progress answer +function page({ items = catalog, storage = {}, account = null } = {}) { + const puts = []; + const store = Object.assign({}, storage); + const window = { + addEventListener() {}, + fetch: null, + }; + window.fetch = (url, opts = {}) => { + if (String(url).includes("dsa.json")) return Promise.resolve({ ok: items !== null, status: items === null ? 404 : 200, json: async () => items }); + if (opts.method === "PUT") { + const body = JSON.parse(opts.body); + puts.push(body); + return Promise.resolve({ ok: true, status: 200, json: async () => ({ signedIn: true, questions: body.questions, state: body.state }) }); + } + return Promise.resolve({ ok: true, status: 200, json: async () => (account ? Object.assign({ signedIn: true }, account) : { signedIn: false }) }); + }; + const ctx = { + window, + fetch: window.fetch, + document: { readyState: "complete", getElementById: () => null, addEventListener() {} }, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => void (store[k] = String(v)), + }, + console: { warn() {}, error() {}, log() {} }, + setTimeout, + clearTimeout, + JSON, + }; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return { PS: window.PracticeStore, store, puts, window }; +} + +const question = (n, extra = {}) => ({ id: "q" + n, name: "Q" + n, nameHyphenated: "q" + n, topic: "Arrays", difficulty: "Easy", added: 1800000000000 + n, ...extra }); + +test("the first visit has every starter question, once", async () => { + const p = page(); + assert.equal(await p.PS.ready(), true); + const all = p.PS.all(); + const slugs = new Set(catalog.map((c) => c.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))); + assert.equal(all.length, slugs.size); + assert.ok(all.length > 290, "only " + all.length); + const two = p.PS.find("two-sum"); + assert.equal(two.id, "s-two-sum"); + assert.equal(two.name, "Two Sum"); + assert.equal(two.topic, "Arrays"); + assert.equal(two.difficulty, "Easy"); + assert.equal(two.description, null); + assert.equal(two.starter, true); + // the pages that read the list directly find it too + const raw = JSON.parse(p.store.questions); + assert.ok(raw.find((q) => q.nameHyphenated === "best-time-to-buy-and-sell-stock-ii")); +}); + +test("names read like names, and a second call adds nothing", async () => { + const p = page(); + await p.PS.ready(); + assert.equal(p.PS.find("best-time-to-buy-and-sell-stock-ii").name, "Best Time to Buy and Sell Stock II"); + assert.equal(p.PS.find("lru-cache").name, "LRU Cache"); + const before = p.store.questions; + assert.equal(await p.PS.seed(), true); // the same promise: it ran once + assert.equal(p.store.questions, before); + // a second page load finds them all there + const again = page({ storage: p.store }); + assert.equal(await again.PS.ready(), false); + assert.equal(again.PS.all().length, p.PS.all().length); +}); + +test("the starters come after what the visitor generated, in the order of the file", async () => { + const p = page({ storage: { questions: JSON.stringify([question(1)]) } }); + await p.PS.ready(); + const byNewest = p.PS.all().sort((a, b) => b.added - a.added); + assert.equal(byNewest[0].id, "q1"); + assert.equal(byNewest[1].nameHyphenated, "two-sum"); + assert.equal(byNewest[2].nameHyphenated, "best-time-to-buy-and-sell-stock"); +}); + +test("a question the visitor already has is not added again", async () => { + const mine = question(1, { name: "Two Sum", nameHyphenated: "two-sum", id: "old-random-id", description: "text" }); + const p = page({ storage: { questions: JSON.stringify([mine]) } }); + await p.PS.ready(); + assert.equal(p.PS.all().filter((q) => q.nameHyphenated === "two-sum").length, 1); + assert.equal(p.PS.find("two-sum").id, "old-random-id"); +}); + +test("a starter that was deleted is not put back", async () => { + const first = page(); + await first.PS.ready(); + first.PS.remove("s-two-sum"); + const again = page({ storage: first.store }); + await again.PS.ready(); + assert.equal(again.PS.find("two-sum"), null); + assert.equal(again.PS.all().length, first.PS.all().length); +}); + +test("a starter nobody opened is not sent to the account, and one that was opened is", async () => { + const p = page({ account: { questions: {}, state: {} } }); + await p.PS.ready(); + p.PS.setDone("s-two-sum", true); + await p.PS.sync(); + let sent = p.puts[p.puts.length - 1]; + assert.deepEqual(Object.keys(sent.questions), [], "the starters were sent"); + assert.equal(sent.state["s-two-sum"].done, true, "its done mark is sent"); + // opened: its description was written + const two = p.PS.find("two-sum"); + two.description = "Given an array..."; + p.PS.update(two); + await p.PS.sync(); + sent = p.puts[p.puts.length - 1]; + assert.deepEqual(Object.keys(sent.questions), ["s-two-sum"]); +}); + +test("starters do not count in the 100 generated questions, and a full list keeps them", async () => { + const mine = []; + for (let i = 0; i < 100; i++) mine.push(question(i)); + const p = page({ storage: { questions: JSON.stringify(mine) } }); + await p.PS.ready(); + const starters = p.PS.all().filter((q) => q.starter).length; + const added = p.PS.add(question(500)); + assert.equal(added.error, null); + const all = p.PS.all(); + assert.equal(all.filter((q) => !q.starter).length, 100); + assert.equal(all.filter((q) => q.starter).length, starters, "a starter was dropped to make room"); + assert.equal(p.PS.find("q0"), null, "the oldest generated question stays"); + assert.ok(p.PS.find("q500")); +}); + +test("an unreachable or broken list leaves the practice page as it was", async () => { + for (const items of [null, { not: "a list" }, []]) { + const p = page({ items, storage: { questions: JSON.stringify([question(1)]) } }); + assert.equal(await p.PS.ready(), false); + assert.equal(p.PS.all().length, 1); + } +}); + +test("a visitor's own questions and the account's copy of a starter are kept when signed in", async () => { + const opened = { id: "s-two-sum", name: "Two Sum", nameHyphenated: "two-sum", topic: "Arrays", difficulty: "Easy", description: "from another device", code_templates: {}, added: 1735689600000, updated: 1800000000000, starter: true }; + const p = page({ account: { questions: { "s-two-sum": opened }, state: {} } }); + await p.PS.ready(); + await p.PS.init(); + assert.equal(p.PS.find("two-sum").description, "from another device"); + assert.equal(p.PS.all().filter((q) => q.nameHyphenated === "two-sum").length, 1); +}); diff --git a/src/js/test/question-loader.test.mjs b/src/js/test/question-loader.test.mjs new file mode 100644 index 00000000..cb1dc27f --- /dev/null +++ b/src/js/test/question-loader.test.mjs @@ -0,0 +1,226 @@ +// The loading card of the practice editor (resources/js/question-loader.js): +// what it says for each way a question can fail, the time limit, Cancel, Try +// again, and a newer load taking over from an older one. It is run with a +// pretend card (the `ui` option), so that what the visitor is shown can be read. +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", "question-loader.js"), "utf8"); + +function loaderPage() { + const window = {}; + const ctx = { window, document: { querySelector: () => null, getElementById: () => null }, console: { error() {}, log() {}, warn() {} }, setTimeout, clearTimeout, AbortController, Promise }; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return window.QuestionLoader; +} + +// a card that writes down what it was asked to show +function card() { + const log = []; + const ui = { + log, + last: () => log[log.length - 1], + waiting: (label) => log.push({ s: "waiting", label }), + loading: (o) => log.push({ s: "loading", ...o }), + slow: (o) => log.push({ s: "slow", ...o }), + failed: (d, h) => log.push({ s: "failed", d, h }), + hide: () => log.push({ s: "hidden" }), + }; + return ui; +} +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const failure = (kind, extra = {}) => Object.assign(new Error(kind), { kind }, extra); + +test("a question that loads: the card says it is working, then goes, and the value is handed on", async () => { + const QL = loaderPage(); + const ui = card(); + let got = null; + QL.load({ ui, title: "Two Sum", language: "Python", model: "GPT-6 Luna", work: async () => "TEMPLATE", onReady: (v) => (got = v) }); + assert.equal(ui.last().s, "loading"); + assert.match(ui.last().title, /Getting your question ready/); + assert.match(ui.last().text, /^Genie \(GPT-6 Luna\) is writing \u201cTwo Sum\u201d/); + assert.match(ui.last().text, /Two Sum/); + assert.match(ui.last().text, /starter for Python/); + assert.match(ui.last().text, /5 to 20 seconds/); + await wait(10); + assert.equal(got, "TEMPLATE"); + assert.equal(ui.last().s, "hidden"); +}); + +test("each way it can fail says what went wrong, in words", () => { + const QL = loaderPage(); + const d = (kind, extra) => QL.describeFailure(failure(kind, extra), 60000); + assert.match(d("timeout").text, /within 60 seconds/); + assert.match(d("offline").title, /Couldn't reach OpenREPL/); + assert.match(d("offline").text, /internet connection/); + assert.match(d("limit", { status: 429 }).title, /reached your Genie limit/); + assert.equal(d("limit", { status: 429 }).detail, "HTTP 429"); + assert.match(d("auth", { status: 403 }).text, /Reload the page/); + assert.equal(d("off", { status: 503, serverMessage: "Genie is switched off for now. Please try again later." }).text, "Genie is switched off for now. Please try again later."); + assert.match(d("format").title, /answer couldn't be used/); + const server = d("server", { status: 502, serverMessage: "Bad gateway" }); + assert.match(server.text, /HTTP 502/); + assert.equal(server.detail, "Bad gateway"); + // an Error that says nothing about why is a problem of the service, not a blank card + assert.match(QL.describeFailure(new Error("who knows")).title, /AI service had a problem/); + assert.match(QL.describeFailure(undefined).title, /AI service had a problem/); +}); + +test("a failure shows its reason, and Try again runs the work again", async () => { + const QL = loaderPage(); + const ui = card(); + let calls = 0, got = null; + QL.load({ ui, title: "Two Sum", work: async () => { if (++calls === 1) throw failure("limit", { status: 429 }); return "OK"; }, onReady: (v) => (got = v) }); + await wait(10); + assert.equal(ui.last().s, "failed"); + assert.match(ui.last().d.title, /Genie limit/); + ui.last().h.retry(); + assert.equal(ui.last().s, "loading", "the card goes back to working while it tries again"); + await wait(10); + assert.equal(calls, 2); + assert.equal(got, "OK"); + assert.equal(ui.last().s, "hidden"); +}); + +test("Close on a failure gives up: the editor is told, and gets the question without the starter", async () => { + const QL = loaderPage(); + const ui = card(); + let why = null; + QL.load({ ui, work: async () => { throw failure("server", { status: 500 }); }, onGiveUp: (w) => (why = w) }); + await wait(10); + ui.last().h.close(); + assert.equal(why, "failed"); + assert.equal(ui.last().s, "hidden"); +}); + +test("it gives up after the time limit, stops the request, and says it took too long", async () => { + const QL = loaderPage(); + const ui = card(); + let aborted = false; + QL.load({ + ui, timeoutMs: 40, slowMs: 1000, + work: (signal) => new Promise((_, reject) => signal.addEventListener("abort", () => { aborted = true; reject(Object.assign(new Error("aborted"), { name: "AbortError" })); })), + }); + await wait(90); + assert.equal(aborted, true, "the request was not dropped"); + assert.equal(ui.last().s, "failed"); + assert.match(ui.last().d.title, /taking too long/); +}); + +test("after a while it says it is still working", async () => { + const QL = loaderPage(); + const ui = card(); + QL.load({ ui, timeoutMs: 1000, slowMs: 30, work: () => new Promise(() => {}) }); + assert.equal(ui.last().s, "loading"); + await wait(70); + assert.equal(ui.last().s, "slow"); + assert.match(ui.last().title, /Getting your question ready/); +}); + +test("Cancel stops the request and says nothing more than that it was cancelled", async () => { + const QL = loaderPage(); + const ui = card(); + let aborted = false, why = null; + QL.load({ + ui, onGiveUp: (w) => (why = w), + work: (signal) => new Promise((_, reject) => signal.addEventListener("abort", () => { aborted = true; reject(Object.assign(new Error("aborted"), { name: "AbortError" })); })), + }); + ui.last().onCancel(); + await wait(10); + assert.equal(aborted, true); + assert.equal(why, "cancelled"); + assert.equal(ui.last().s, "hidden", "a cancel is not an error: no failure card"); + assert.ok(!ui.log.some((e) => e.s === "failed")); +}); + +test("a newer load takes over: the older one is stopped and shows nothing", async () => { + const QL = loaderPage(); + const ui = card(); + let firstAborted = false, firstReady = false, secondGot = null; + QL.load({ + ui, title: "First", + work: (signal) => new Promise((_, reject) => signal.addEventListener("abort", () => { firstAborted = true; reject(Object.assign(new Error("aborted"), { name: "AbortError" })); })), + onReady: () => (firstReady = true), + }); + QL.load({ ui, title: "Second", work: async () => "B", onReady: (v) => (secondGot = v) }); + await wait(20); + assert.equal(firstAborted, true); + assert.equal(firstReady, false); + assert.equal(secondGot, "B"); + assert.ok(!ui.log.some((e) => e.s === "failed"), "the older load must not show an error"); + assert.equal(ui.last().s, "hidden"); +}); + +test("a work function that throws at once is a failure on the card, not an uncaught error", async () => { + const QL = loaderPage(); + const ui = card(); + QL.load({ ui, work: () => { throw failure("offline"); } }); + await wait(10); + assert.equal(ui.last().s, "failed"); + assert.match(ui.last().d.title, /Couldn't reach OpenREPL/); +}); + +// ---- reading the model's answer ---------------------------------------------------- + +test("the template is found under the language's key, however the model wrote it", () => { + const QL = loaderPage(); + const t = { template: "int main() {}", multiline_comment_start: "/*", multiline_comment_end: "*/" }; + const read = (answer, lang = "C") => QL.pickTemplate(answer, lang); + assert.equal(read({ description: "d", C: t }).template, "int main() {}"); + assert.equal(read({ description: "d", c: t }).template, "int main() {}", "a lower case key"); + assert.equal(read({ description: "d", "C ": t }).template, "int main() {}", "a key with a space"); + assert.equal(read({ description: "d", templates: { c: t } }).template, "int main() {}", "one level down"); + assert.equal(read({ description: "d", code_templates: { C: t } }).template, "int main() {}"); + assert.equal(read({ description: "d", template: "int main() {}" }).template, "int main() {}", "the template as the whole answer"); + assert.equal(read({ description: "d", c_language: t }).template, "int main() {}", "the only template, under another name"); + assert.equal(read({ description: "d", "C++": t }, "C++").template, "int main() {}"); + // the answer for another language is not the one that was asked for + assert.equal(read({ description: "d", "C++": t }, "C"), null); + assert.equal(read({ description: "d", Python: t }, "C"), null); +}); + +test("a template with no comment marks gets the language's", () => { + const QL = loaderPage(); + assert.deepEqual({ ...QL.pickTemplate({ Python: { template: "def f(): pass" } }, "Python") }, { template: "def f(): pass", multiline_comment_start: '"""', multiline_comment_end: '"""' }); + assert.equal(QL.pickTemplate({ C: { template: "x" } }, "C").multiline_comment_start, "/*"); + assert.equal(QL.pickTemplate({ Ruby: { template: "x", multiline_comment_start: "", multiline_comment_end: "=end" } }, "Ruby").multiline_comment_start, "=begin"); +}); + +test("an answer with no template at all is not one", () => { + const QL = loaderPage(); + for (const answer of [null, undefined, "text", 5, [], {}, { description: "only a description" }, { C: "not an object" }, { C: { template: 5 } }, { a: { template: "x" }, b: { template: "y" } }]) { + assert.equal(QL.pickTemplate(answer, "C"), null, JSON.stringify(answer)); + } +}); + +test("the card names the model that was chosen, and says plain Genie when there is none", async () => { + const QL = loaderPage(); + const ui = card(); + QL.load({ ui, title: "Two Sum", language: "C", work: () => new Promise(() => {}) }); + assert.match(ui.last().text, /^Genie is writing/, "no model, no brackets"); + QL.load({ ui, title: "Two Sum", language: "C", model: "Gemma 4 31B", work: () => new Promise(() => {}) }); + assert.match(ui.last().text, /^Genie \(Gemma 4 31B\) is writing/); + // and a time-out says which model did not answer + assert.match(QL.describeFailure({ kind: "timeout" }, 60000, "GPT-6 Luna").text, /^Genie \(GPT-6 Luna\) didn't answer within 60 seconds/); + assert.match(QL.describeFailure({ kind: "timeout" }, 60000).text, /^Genie didn't answer within 60 seconds/); + assert.match(QL.describeFailure({ kind: "timeout" }, 60000, "X").text, /another model/); +}); + +test("an empty answer says so, which model, and why as far as the reply told", () => { + const QL = loaderPage(); + const fromProxy = QL.describeFailure({ kind: "empty", status: 502, serverMessage: "North mini code (free) sent back an empty answer: it used its whole answer budget before writing anything (a model that thinks first counts its thinking in it)" }, 60000, "North mini code (free)"); + assert.equal(fromProxy.title, "Genie (North mini code (free)) sent back an empty answer"); + assert.match(fromProxy.text, /^It used its whole answer budget before writing anything/); + assert.match(fromProxy.text, /pick another model in Genie's model menu\.$/); + assert.equal(fromProxy.detail, "HTTP 502"); + const fromPage = QL.describeFailure({ kind: "empty", serverMessage: "it wrote nothing" }, 60000, "GPT-4o mini"); + assert.match(fromPage.text, /^It wrote nothing\. Try again/); + assert.match(QL.describeFailure({ kind: "empty" }, 60000).text, /^It wrote nothing\. Try again/); + assert.equal(QL.describeFailure({ kind: "empty" }, 60000).title, "Genie sent back an empty answer"); +}); diff --git a/src/js/test/share-id.test.mjs b/src/js/test/share-id.test.mjs new file mode 100644 index 00000000..3104141e --- /dev/null +++ b/src/js/test/share-id.test.mjs @@ -0,0 +1,105 @@ +// Which part of the address is a share id (src/share-id.ts), and what the page +// script does with it (src/page/01-session.js). The rule is TypeScript, so it is +// compiled here with the compiler the bundle is built with. +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 { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const js = (file) => + ts.transpileModule(fs.readFileSync(path.join(here, "..", "src", file), "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2020, target: ts.ScriptTarget.ES2017 } }).outputText; +const { shareIdFrom, SHARE_ID_PATTERN } = await import("data:text/javascript;base64," + Buffer.from(js("share-id.ts")).toString("base64")); + +// keys of the Firebase SDK's own generator (firebase-database.js), run with the clock of 2014, 2017, 2025 and now +const REAL = ["-JOJiRV-a8RYmm_zDNlx", "-KeGSp--p2fz1h5yyPnM", "-OJVdrF-2UPpWUafqBoU", "-P3WCLWG1hm_ZMkP2Al3"]; + +test("a share link is the Firebase push key after the #", () => { + for (const id of REAL) { + assert.equal(shareIdFrom("#" + id), id); + assert.equal(shareIdFrom(id), id, "location.hash always has the #, but the rule does not need it"); + } +}); + +test("the anchors of the page are not share links", () => { + for (const anchor of ["#languages", "#repl", "#workspace", "#request", "#faq", "#", "", "#-", "#-Nx3kQ7"]) { + assert.equal(shareIdFrom(anchor), "", anchor); + } +}); + +test("anything that is not the shape of a key is left out", () => { + for (const bad of ["#-OJVdrF-2UPpWUafqBoU?x=1", "#-OJVdrF-2UPpWUafqBoU/", "# -OJVdrF-2UPpWUafqBoU", "#--" + "a".repeat(40), "#OJVdrF-2UPpWUafqBoU", "#-ab cd"]) { + assert.equal(shareIdFrom(bad), "", bad); + } + assert.equal(shareIdFrom(null), ""); + assert.equal(shareIdFrom(undefined), ""); + assert.ok(SHARE_ID_PATTERN.test("-" + "x".repeat(12)) && !SHARE_ID_PATTERN.test("-" + "x".repeat(11)) && !SHARE_ID_PATTERN.test("-" + "x".repeat(31))); +}); + +// ---- the page script, run with a pretend page ---------------------------------- + +function page(hash) { + const listeners = {}; + const location = { hash, reloads: 0, reload() { this.reloads++; } }; + const window = { location, innerWidth: 1200, innerHeight: 800, addEventListener: (t, f) => (listeners[t] = f) }; + const source = fs + .readFileSync(path.join(here, "..", "src", "page", "01-session.js"), "utf8") + .replace(/^import \{ shareIdFrom \} from "..\/share-id";$/m, "") + .replace(/^export \{\};.*$/m, ""); + const ctx = { + window, + shareIdFrom, + firebase: { database: () => ({ ref: () => ({ push: () => ({ key: "-NEWKEYFROMPUSH12345" }) }) }) }, + document: {}, + $: () => ({ on() {} }), + console, + }; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return { window, location, change: (h) => { location.hash = h; listeners.hashchange && listeners.hashchange(); } }; +} + +test("a page with no hash, or with an anchor, is the master and makes its own session", () => { + for (const hash of ["", "#languages", "#workspace", "#request", "#repl"]) { + const p = page(hash); + assert.equal(p.window.isMaster(), true, "hash " + JSON.stringify(hash)); + assert.equal(p.window.getExampleRef(), "-NEWKEYFROMPUSH12345", "it must start a session of its own, not read the anchor"); + } +}); + +test("a page opened with a share link is a viewer of that session", () => { + const p = page("#" + REAL[2]); + assert.equal(p.window.isMaster(), false); + assert.equal(p.window.getExampleRef(), REAL[2]); +}); + +test("clicking an anchor does not change what the page is", () => { + const master = page(""); + master.change("#languages"); + assert.equal(master.window.isMaster(), true); + assert.equal(master.location.reloads, 0); + + const viewer = page("#" + REAL[0]); + viewer.change("#languages"); + assert.equal(viewer.window.isMaster(), false, "the viewer's session must not turn into a master's"); + assert.equal(viewer.window.getExampleRef(), REAL[0]); + assert.equal(viewer.location.reloads, 0); +}); + +test("another share link pasted into the address bar loads that session", () => { + const viewer = page("#" + REAL[0]); + viewer.change("#" + REAL[1]); + assert.equal(viewer.location.reloads, 1); + const master = page(""); + master.change("#" + REAL[3]); + assert.equal(master.location.reloads, 1); + // the same link again is not news + const same = page("#" + REAL[0]); + same.change("#" + REAL[0]); + assert.equal(same.location.reloads, 0); +}); diff --git a/src/resources/about.html b/src/resources/about.html index 6907a753..2b8ce265 100644 --- a/src/resources/about.html +++ b/src/resources/about.html @@ -68,7 +68,7 @@

What you can do

  • Fork a terminal to run a server and a client side by side.
  • Keep files in your workspace, and sign in to keep them between visits.
  • Ask Genie, the AI helper, about your code and your terminal output, review its suggestions as a diff, let it carry out a task in Agent mode when you are signed in (it can also work in your terminal, open terminal tabs and manage your files), or right-click selected code to explain, fix, comment or test it. Genie keeps working when you close its panel, and the Ask Genie button tells you when it is done or needs you. Pin it to dock it beside the editor.
  • -
  • Practice with generated interview questions, with a coach for hints, a solution review and complexity.
  • +
  • Practice DSA with 300+ ready-made interview problems (and your own generated ones), with a coach for hints, a solution review and complexity.

  • diff --git a/src/resources/chat-widget/src/index.ts b/src/resources/chat-widget/src/index.ts index f05190d3..7c28b710 100644 --- a/src/resources/chat-widget/src/index.ts +++ b/src/resources/chat-widget/src/index.ts @@ -51,8 +51,34 @@ function fetchEditorContent(): string { return ""; } +// Who said a message in a peer chat: the name the others see, and whether it is this person. +type PeerSpeaker = { name: string; mine: boolean }; + let chatfirebasedbref: firebase.database.Reference | null = null; -const UID = generateFiveCharUUID(); +// The id of this person in peer chat (and in the questions they send Genie): five +// characters, one for each browser: every tab and every visit of it is the same +// person, so the name the others see does not change. Where storage is blocked +// it is new on each load. +function peerId(): string { + try { + const kept = localStorage.getItem("genie-peer-id"); + if (kept && /^[0-9a-f]{5}$/.test(kept)) return kept; + } catch (e) { + // a new one below + } + const id = generateFiveCharUUID(); + try { + localStorage.setItem("genie-peer-id", id); + } catch (e) { + // not kept + } + return id; +} +const UID = peerId(); +// This tab. Two tabs of one browser are one person, but each has to be told what the other one said. +const TAB = generateFiveCharUUID(); +// what the others in a peer chat call this person +const peerName = (uid: string) => "user-" + uid; let peerchatmode: boolean = false; export type WidgetConfig = { @@ -275,18 +301,42 @@ function addMessageToHistory(role: string, content: string, uid: string=UID): vo } } +// A page is a viewer only when it was opened with a share link, /#. +// An anchor of the page (#languages, #workspace) is not one. The same pattern is in +// js/src/share-id.ts, which has the reasons; test/share-id.test.ts compares the two. +// Read once: clicking an anchor later changes the hash, not the session. +const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; +const sharedSession = SHARE_ID_PATTERN.test(window.location.hash.replace(/^#/, "")); + function isMaster() : boolean { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return !sharedSession; +} + +// A viewer going away says so. The page may be gone before it is sent, so it is +// tried on pagehide (which browsers still run) as well as on unload, once; a page +// that comes back from the back/forward cache joins again (pageshow). +let toldLeft = false; +function sayLeft() { + if (toldLeft || !sharedSession || !chatfirebasedbref) return; + toldLeft = true; + try { + chatfirebasedbref.push({ eventT: "leave", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); + } catch (e) { + // nobody to tell + } } +window.addEventListener("pagehide", sayLeft); +window.addEventListener("pageshow", (e: PageTransitionEvent) => { + if (!e.persisted || !toldLeft || !sharedSession || !chatfirebasedbref) return; + toldLeft = false; + chatfirebasedbref.push({ eventT: "join", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); +}); let cleanup = () => { if (isMaster() && chatfirebasedbref) { chatfirebasedbref.remove(); + } else { + sayLeft(); } console.log("cleanup done."); }; @@ -301,6 +351,13 @@ let peerchatSwitchlistener = (e: Event) => { console.log('PeerChat switch is OFF'); peerchatmode=false; } +// A page is a viewer only when it was opened with a share link, /#. +// An anchor of the page (#languages, #workspace) is not one. The same pattern is in +// js/src/share-id.ts, which has the reasons; test/share-id.test.ts compares the two. +// Read once: clicking an anchor later changes the hash, not the session. +const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; +const sharedSession = SHARE_ID_PATTERN.test(window.location.hash.replace(/^#/, "")); + refreshChip(); if (chatfirebasedbref) { // push event to firebase db @@ -308,8 +365,11 @@ let peerchatSwitchlistener = (e: Event) => { eventT: "peerchatmode", val: peerchatmode, uid: UID, + tab: TAB, + name: peerName(UID), }); } + if (peerchatmode) peerNote(`Peer chat is on. You are ${peerName(UID)}.`); } else { console.error("peerchatSwitchlistener: an unexpected error occurred: null element."); } @@ -330,24 +390,42 @@ const setupFBListener = () => { return; } let d = data.val(); - if (d.uid==UID) { + // what this very tab sent is already on its screen (an older page sends no tab: then by person) + if (d.tab ? d.tab === TAB : d.uid == UID) { console.log("chat event triggered by me only, skipping.."); return; } + const me = d.uid == UID; // another tab of this browser: the same person + if (d.eventT === "join" || d.eventT === "leave") { + if (me) return; // not news + peerNote(`${d.name || peerName(d.uid)} ${d.eventT === "join" ? "joined" : "left"} the chat`, d.timestamp); + return; + } if (d.eventT && d.eventT=="peerchatmode") { console.log("received an peerchatmode event: ", d); // its a event message peerchatmode=d.val; + if (!me) peerNote(`${d.name || peerName(d.uid)} turned peer chat ${d.val ? "on" : "off"}`, d.timestamp); refreshChip(); const peerchatSwitchElem = document.getElementById("peerchat-switch") as HTMLInputElement; if (peerchatSwitchElem) { peerchatSwitchElem.checked=peerchatmode; } } else { - createNewMessageEntry(d.message, d.timestamp, d.from, true); + // a message typed in a peer chat has the sender's name; the rest (what the owner asked Genie, and its answers) is shown as it was + const speaker: PeerSpeaker | null = d.peer ? { name: d.name || peerName(d.uid), mine: me } : null; + createNewMessageEntry(d.message, d.timestamp, d.from, true, "", null, speaker); addMessageToHistory(d.from, d.message, d.uid); // remote uid needed here as its not my chat } }); + // A viewer who opened a share link joins the chat: the others are told, and + // the viewer is told who they are. The owner is in it from the start, and + // says so with the first message. (Nothing is written for a page that + // nobody shares: that would leave an entry behind for every visit.) + if (sharedSession) { + peerNote(`You joined the chat as ${peerName(UID)}.`); + chatfirebasedbref.push({ eventT: "join", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); + } } catch(error) { console.error("Error setup firebase handle: ", error); } @@ -1749,13 +1827,17 @@ async function createNewMessageEntry( from: "system" | "user", skipdbpush: boolean = false, meta: string = "", - used: ContextNote[] | null = null + used: ContextNote[] | null = null, + peer: PeerSpeaker | null = null ) { message = message.trim(); //console.log("message: ", message) if (!skipdbpush && chatfirebasedbref) { // push to firebase db first chatfirebasedbref.push ({ + // a message typed in a peer chat says so, and who from, so that the others can show the name + ...(peer && peer.mine ? { peer: true, name: peerName(UID) } : {}), + tab: TAB, message: message, timestamp: timestamp, from: from, @@ -1764,10 +1846,21 @@ async function createNewMessageEntry( } + // In a peer chat what is on screen depends on who is looking, not on who owns + // the session: your own messages are the bubble on the right, everybody + // else's are on the left under their name. + const shown: "system" | "user" = peer ? (peer.mine ? "user" : "system") : from; const messageElement = document.createElement("div"); messageElement.classList.add("chat-widget__message"); - messageElement.classList.add(`chat-widget__message--${from}`); - messageElement.id = `chat-widget__message--${from}--${timestamp}`; + messageElement.classList.add(`chat-widget__message--${shown}`); + messageElement.id = `chat-widget__message--${shown}--${timestamp}`; + if (peer) { + messageElement.classList.add("chat-widget__message--peer"); + const who = document.createElement("p"); + who.classList.add("chat-widget__peer-name"); + who.textContent = peer.mine ? `You · ${peer.name}` : peer.name; + messageElement.appendChild(who); + } const messageText = document.createElement("div"); messageText.classList.add("chat-widget__message-text"); @@ -1798,6 +1891,17 @@ async function createNewMessageEntry( messagesHistory.prepend(messageElement); } +// A small line in the conversation that is not a message: someone joined or left +// the peer chat, turned it on or off. +function peerNote(text: string, timestamp: number = Date.now()) { + const note = document.createElement("div"); + note.classList.add("chat-widget__peer-note"); + note.setAttribute("role", "status"); + note.id = `chat-widget__peer-note--${timestamp}-${messagesHistory.children.length}`; + note.textContent = text; + messagesHistory.prepend(note); +} + const handleErrorResponse = async (errData: any) => { chatFailed = true; console.error("Chat Widget: Server error: ", errData); @@ -1987,7 +2091,7 @@ async function submit(e: Event) { } addMessageToHistory(myrole, msg); - await createNewMessageEntry(msg, Date.now(), myrole); + await createNewMessageEntry(msg, Date.now(), myrole, false, "", null, peerchatmode ? { name: peerName(UID), mine: true } : null); target.reset(); autoGrow((target.elements as any).message as HTMLTextAreaElement); if (peerchatmode) { diff --git a/src/resources/chat-widget/src/widget.css b/src/resources/chat-widget/src/widget.css index 14a7310a..775dae7c 100644 --- a/src/resources/chat-widget/src/widget.css +++ b/src/resources/chat-widget/src/widget.css @@ -397,6 +397,31 @@ color: var(--cw-on-accent); background: var(--cw-accent); } +/* Peer chat: who said it, and the small notes (someone joined, left, turned it on) */ +.chat-widget__peer-name { + margin: 0; + font-size: 11.5px; + font-weight: 500; + color: var(--cw-muted); +} +.chat-widget__message--peer.chat-widget__message--system .chat-widget__message-text { + color: var(--cw-text); + background: var(--cw-chip-bg); + border-radius: 12px 12px 12px 4px; + padding: 8px 12px; +} +.chat-widget__peer-note { + align-self: center; + max-width: 90%; + padding: 3px 10px; + font-size: 11.5px; + line-height: 1.4; + text-align: center; + color: var(--cw-muted); + background: var(--cw-chip-bg); + border-radius: 999px; + animation: cw-fade 0.25s ease-out; +} .chat-widget__message-meta { margin: 4px 0 0; font-size: 11.5px; diff --git a/src/resources/chat-widget/test/share-id.test.ts b/src/resources/chat-widget/test/share-id.test.ts new file mode 100644 index 00000000..6a46bdc4 --- /dev/null +++ b/src/resources/chat-widget/test/share-id.test.ts @@ -0,0 +1,29 @@ +// node --test test/ (from src/resources/chat-widget; Node 22.18 or later runs TypeScript as it is) +// +// The widget keeps its own copy of the rule that tells a share link (/#) from an anchor of the page (/#languages), because it is built apart +// from the page script (js/src/share-id.ts). They must not drift. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const patternIn = (file: string) => { + const m = fs.readFileSync(file, "utf8").match(/SHARE_ID_PATTERN\s*=\s*(\/\^[^\n]*?\$\/)/); + assert.ok(m, "no SHARE_ID_PATTERN in " + file); + return m![1]; +}; + +test("the widget and the page script use the same rule for a share id", () => { + const widget = patternIn(path.join(here, "..", "src", "index.ts")); + const page = patternIn(path.join(here, "..", "..", "..", "js", "src", "share-id.ts")); + assert.equal(widget, page); +}); + +test("the widget's rule takes a share link and leaves the anchors of the page", () => { + const rule = new RegExp(patternIn(path.join(here, "..", "src", "index.ts")).slice(1, -1)); + for (const id of ["-JOJiRV-a8RYmm_zDNlx", "-KeGSp--p2fz1h5yyPnM", "-OJVdrF-2UPpWUafqBoU", "-P3WCLWG1hm_ZMkP2Al3"]) assert.ok(rule.test(id), id); + for (const anchor of ["languages", "repl", "workspace", "request", "faq", "", "-Nx3kQ7", "-" + "a".repeat(40)]) assert.ok(!rule.test(anchor), anchor); +}); diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index f6bced1d..0641391b 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -925,6 +925,63 @@ body.files-collapsed .files-panel { display: none; } min-height: 0; } .editor-body #loader { border-color: var(--ink-700); border-top-color: var(--accent-color); } + +/* The question of the practice editor while Genie writes it, and what went + wrong when it could not (js/question-loader.js). It covers the editor. */ +.q-loading { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(11, 11, 15, 0.94); +} +.q-loading[hidden] { display: none; } +.q-loading__card { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: 380px; + text-align: center; + color: var(--paper); +} +.q-loading__ring { + width: 34px; + height: 34px; + box-sizing: border-box; + border: 3px solid var(--ink-700); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: q-loading-spin 0.9s linear infinite; +} +.q-loading[data-state="failed"] .q-loading__ring { display: none; } +.q-loading__title { margin: 4px 0 0; font-size: 16px; font-weight: 600; color: var(--paper); } +.q-loading__text { margin: 0; font-size: 14px; line-height: 1.5; color: #B9B7C4; } +.q-loading__text:empty { display: none; } +.q-loading__hint { margin: 0; font-size: 13px; line-height: 1.5; color: var(--muted-2, #8C8A98); } +.q-loading__hint[hidden], +.q-loading__btn[hidden] { display: none; } +.q-loading__actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 8px; } +.q-loading__btn { + min-height: 36px; + padding: 0 16px; + font: inherit; + font-size: 14px; + color: var(--paper); + background: transparent; + border: 1px solid var(--ink-500); + border-radius: 8px; + cursor: pointer; +} +.q-loading__btn:hover { background: var(--ink-700); } +.q-loading__btn:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; } +.q-loading__btn--primary { color: var(--on-accent-color, #15151C); background: var(--accent-color); border-color: var(--accent-color); font-weight: 600; } +.q-loading__btn--primary:hover { background: var(--accent-color); filter: brightness(1.08); } +@keyframes q-loading-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .q-loading__ring { animation-duration: 3s; } } .editor-status { display: flex; align-items: center; @@ -2971,6 +3028,52 @@ body.palette-open { overflow: hidden; } .practice-filter--sort { margin-left: auto; } .practice-count { margin: 18px 0 10px; font-size: 14px; color: var(--text-2); } +/* One page of the list at a time: rows per page, the range, and the pages. */ +.q-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + margin-top: 14px; + font-size: 14px; + color: var(--text-2); +} +.q-pager[hidden] { display: none; } +.q-pager__size { display: flex; align-items: center; gap: 8px; } +.practice-page .q-pager__size select { + min-width: 0; + width: auto; + padding-right: 28px; +} +.q-pager__range { margin: 0; } +.q-pager__pages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-left: auto; +} +.q-page { + min-width: 36px; + height: 36px; + padding: 0 10px; + font: inherit; + font-size: 14px; + color: var(--text); + background: transparent; + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; +} +.q-page:hover:not(:disabled) { border-color: var(--text-2); } +.q-page:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; } +.q-page.is-current { color: var(--on-accent-color, #15151C); background: var(--accent-color); border-color: var(--accent-color); font-weight: 600; } +.q-page:disabled { opacity: 0.4; cursor: default; } +.q-page-gap { padding: 0 4px; color: var(--text-2); } +@media screen and (max-width: 700px) { + .q-pager__pages { margin-left: 0; width: 100%; } +} + .q-table { width: 100%; margin: 0; diff --git a/src/resources/index.html b/src/resources/index.html index 288538ac..86912a1a 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -166,7 +166,7 @@