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
41 changes: 26 additions & 15 deletions assets/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -714,21 +714,22 @@
// rendered, each answer with the entities it cited. Every access is wrapped, because a
// browser with site data blocked throws on the first read and the chat still has to work.
var STORE_KEY = "chat";
// How big the panel is, kept beside the conversation and for the same lifetime: a size is a
// choice about this tab's reading, not a preference to carry to the next visit, and the
// conversation it frames goes when the tab goes. Its own key, because it outlives any one
// conversation: a visitor who sizes the panel, empties it and asks again keeps the size.
// How big the panel is, kept in `localStorage` as `lang` and `theme` are: a size is a choice
// about how this visitor reads the chat on this screen, so it holds in every tab and on the
// next visit, where the conversation it frames goes when its tab goes. Its own key, because it
// is no part of any conversation: a link opened in a new tab finds the panel at the size the
// visitor gave it, with nothing else carried over.
var SIZE_KEY = "chat-size";
var MIN_W = 320, MIN_H = 260, STEP = 24;
function stored(){
try { var raw = sessionStorage.getItem(STORE_KEY); return raw ? JSON.parse(raw) : null; }
catch (e) { return null; }
}
function storedSize(){
try { var raw = sessionStorage.getItem(SIZE_KEY); var v = raw ? JSON.parse(raw) : null; return v && v.w && v.h ? v : null; }
try { var raw = localStorage.getItem(SIZE_KEY); var v = raw ? JSON.parse(raw) : null; return v && v.w && v.h ? v : null; }
catch (e) { return null; }
}
function keepSize(w, h){ try { sessionStorage.setItem(SIZE_KEY, JSON.stringify({ w: w, h: h })); } catch (e) {} }
function keepSize(w, h){ try { localStorage.setItem(SIZE_KEY, JSON.stringify({ w: w, h: h })); } catch (e) {} }

// Where the visitor was reading, kept with the conversation so the back button returns them
// to it and not to the end. A place is a turn and a distance into its bubble, not a pixel
Expand Down Expand Up @@ -1872,9 +1873,10 @@
var introPick = null;
// The question a link carries, waiting: a card the visitor acts on, built from the strings and
// never from markup, so whatever the link says is shown as text. Send hands it to send() as if
// typed, Edit puts it in the input to change, and Discard drops it. Under the notice while the
// intro stands, since that is what the visitor reads first; at the end of a conversation the
// tab already holds, since the intro is then out of sight above it.
// typed, Edit puts it in the input to change, and Discard drops it. It stands right after the
// intro's prompt line, where a question the visitor typed would stand, so the hello, the notice
// and what the panel offers read in the order they always do and the question is the last thing
// in the log; at the end of a conversation the tab already holds, for the same reason.
function askCard(){
var s = strings(langNow()).ask, card = el("div", "rbchat-ask"), row = el("div", "rbchat-ask-row");
card.setAttribute("role", "group"); card.setAttribute("aria-label", s.label);
Expand All @@ -1892,9 +1894,10 @@
askEl = card;
return card;
}
// Wherever the card stands it is the last thing in the log, so the log's end is where it shows.
function showAsk(){
if (!linkAsk || (askEl && askEl.isConnected)) return;
log.appendChild(askCard());
if (!linkAsk) return;
if (!(askEl && askEl.isConnected)) log.appendChild(askCard());
log.scrollTop = log.scrollHeight;
}

Expand All @@ -1919,11 +1922,11 @@
hello.appendChild(el("span", "rbchat-h2", lines[1]));
introEl.appendChild(hello);
notice = el("p", "rbchat-notice"); introEl.appendChild(notice); writeNotice();
if (linkAsk && !messages.length) introEl.appendChild(askCard());
var groups = el("div", "rbchat-groups"); introEl.appendChild(groups);
var promptLine = el("p", "rbchat-prompt-line", "› " + s.prompt + " ");
// The block cursor waits here while the intro plays, and hands over to the command line.
promptLine.appendChild(el("span", "rbchat-cur")); introEl.appendChild(promptLine);
if (linkAsk && !messages.length) introEl.appendChild(askCard());
log.insertBefore(introEl, log.firstChild);
if (!play || still()) finishIntro();
facts(function(f){
Expand Down Expand Up @@ -1956,6 +1959,8 @@
// A restored conversation has moved past the intro: its menus are spent, and the rows a
// number picks stay those of the menu after the last answer.
if (messages.length) spendIntro(); else { menuRows = rows.concat(picked).map(function(r){ return r[0]; }); keysLine(); }
// The menus are drawn above a waiting question and would push it out of sight.
if (askEl && askEl.parentNode === introEl) log.scrollTop = log.scrollHeight;
if (!introEl.classList.contains("rbchat-still")) playIntro();
});
});
Expand Down Expand Up @@ -2080,7 +2085,13 @@
for (var b = 0; b < labels.length; b++) labels[b].textContent = strings(answerLang(turns, langNow())).askNext;
}
// A language switch redraws the intro in the new language, finished, where the log holds one.
if (introEl && log) { var keep_ = log.scrollTop; introEl.parentNode && introEl.parentNode.removeChild(introEl); introEl = null; intro(false); log.scrollTop = keep_; }
// A waiting question is redrawn with it, and the button that held the focus holds it again:
// a page sets its language after the panel has opened, and Enter still has to send.
if (introEl && log) {
var keep_ = log.scrollTop, held = askEl && askEl.contains(document.activeElement) ? Array.prototype.indexOf.call(askEl.querySelectorAll("button"), document.activeElement) : -1;
introEl.parentNode && introEl.parentNode.removeChild(introEl); introEl = null; intro(false); log.scrollTop = keep_;
if (held >= 0 && askEl) askEl.querySelectorAll("button")[held].focus({ preventScroll: true });
}
// The chips standing under an answer are drawn again from the titles they were picked as, so a
// model question reads in the new language and a number key sends what its row now shows.
if (qBox && qPicked && qPicked.length) { var was_ = qPicked.slice(); hideQuestions(); offer(was_); }
Expand Down Expand Up @@ -2111,7 +2122,7 @@
// something to clear: an empty panel shows no control for emptying it. A conversation has no
// length at which it must start over, so this is the only door; its reset takes the turns,
// the log and the tab's copy together; the panel's size stays, being a choice
// about this tab's reading rather than part of the conversation.
// about how the visitor reads rather than part of the conversation.
newBtn = el("button", "rbchat-new"); newBtn.type = "button"; newBtn.hidden = true;
// The glyph is an arrow come back round, not a plus: a plus beside the cross read as "add",
// and what the button does is start over. The note under it names the action for a pointer
Expand Down Expand Up @@ -2206,7 +2217,7 @@
// Double-click gives the stylesheet's size back and forgets the stored one, as the stage's
// gutter does: otherwise the only way back is to drag until it looks right again.
grip.addEventListener("dblclick", function(){
try { sessionStorage.removeItem(SIZE_KEY); } catch (e) {}
try { localStorage.removeItem(SIZE_KEY); } catch (e) {}
unsize();
});
grip.addEventListener("keydown", function(ev){
Expand Down
9 changes: 5 additions & 4 deletions blocks/theme-boot.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* ─── theme boot · v5 · {{variant}} ────────────────────────────────────
/* ─── theme boot · v6 · {{variant}} ────────────────────────────────────
Set the theme before anything paints. Generated from @robertblust/design —
editing it here does nothing, because the next `npm run design` overwrites it.

Expand Down Expand Up @@ -41,13 +41,14 @@
try {
if (window.top !== window && /[?&]embed(&|$)/.test(location.search)) document.documentElement.setAttribute("data-embed", "");
} catch (e) {}
// A conversation the tab keeps open: the keys are chat.js's, `chat` and `chat-size`, read
// the way it reads them. Never in a frame, which takes no chat of its own.
// A conversation the tab keeps open: the keys are chat.js's, `chat` in the tab's storage and
// `chat-size` in the browser's, read the way it reads them. Never in a frame, which takes no
// chat of its own.
try {
var r = document.documentElement, c = JSON.parse(sessionStorage.getItem("chat") || "null");
if (c && c.open && window.top === window) {
r.setAttribute("data-chat-waiting", location.host);
var z = JSON.parse(sessionStorage.getItem("chat-size") || "null");
var z = JSON.parse(localStorage.getItem("chat-size") || "null");
if (z && z.w && z.h) { r.style.setProperty("--rbchat-kept-w", z.w + "px"); r.style.setProperty("--rbchat-kept-h", z.h + "px"); }
}
} catch (e) {}
Expand Down
2 changes: 1 addition & 1 deletion docs/design-system/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ A scrollbar inside the panel is the terminal's too: `--t-dim` on no track, set o

A fresh conversation opens on the intro, and its notice is the one line there that is not a menu: it carries a rail in `--t-part` ([Tokens](tokens.md#token-families)) and opens, in the panel's ink, with the sentence that an AI answers and not a person, which the EU AI Act asks a chat to say before the first message; where the message goes and the privacy page follow in the dim of a comment. The sentences are the widget's own in both languages, in `chat.js`.

An address can ask for the chat, so a post can send its reader to the panel or to one answer: `?chat=open` opens the panel, and `?ask=` opens it with a question waiting, with `?chat=open` or without it. The page reads either once and takes it out of the address, so a reload or a shared address does not ask again. Nothing is sent on arrival, as the intro's notice says: the question waits in `.rbchat-ask`, a card under that notice and above what the panel offers to try, or at the end of the log where the tab already holds a conversation, and Send hands it to the chat as if typed, Edit puts it in the input and Discard drops it. On a desk Send takes the focus, so Enter sends; on a touch screen nothing is focused, so no keyboard rises over the page. The question is trimmed and set as text, never as markup, and one that is empty, too long for a message or not decodable opens the panel without a card.
An address can ask for the chat, so a post can send its reader to the panel or to one answer: `?chat=open` opens the panel, and `?ask=` opens it with a question waiting, with `?chat=open` or without it. The page reads either once and takes it out of the address, so a reload or a shared address does not ask again. Nothing is sent on arrival, as the intro's notice says: the question waits in `.rbchat-ask`, a card right after the intro's prompt line, where a typed question would stand, or at the end of the log where the tab already holds a conversation, and Send hands it to the chat as if typed, Edit puts it in the input and Discard drops it. On a desk Send takes the focus, so Enter sends; on a touch screen nothing is focused, so no keyboard rises over the page. The question is trimmed and set as text, never as markup, and one that is empty, too long for a message or not decodable opens the panel without a card.

Where the chat service checks its answers, a claim the evidence does not carry is marked once the answer has finished, and only where its probability passes the threshold the service sends: a dotted underline, `--t-part` for a claim carried in part and `--t-bad` for the rest. Hover or keyboard focus shows its note in the panel's own tooltip box, the one under the header's buttons, with a name and one line; a tap does not, and on a phone the line under the answer says how many statements are not fully backed. With no threshold nothing is marked.

Expand Down
51 changes: 45 additions & 6 deletions test/chat-ask.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// A link that asks the chat a question, in Chromium: a page carrying chat.js is loaded with
// ?ask=, the panel opens with the question waiting under the notice, and nothing reaches the
// ?ask=, the panel opens with the question waiting after the prompt line, and nothing reaches the
// chat endpoint until the visitor sends it. The pure part, linkQuestion(), is in chat.test.mjs.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
Expand All @@ -15,6 +15,12 @@ const asset = (f) => fs.readFileSync(path.join(PKG, "assets", f));

const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><style>${TERMINAL}</style><link rel="stylesheet" href="/chat.css"></head>
<body><p>A page.</p><script src="/chat.js" data-chat="/chat" data-model="/model/" defer></script></body></html>`;
// A page whose model fills both of the intro's menus, as a site's does.
const MODEL = { commit: "ffb11a52dc8a5ff2a46cbbd43ab8be8930797f6f", repo: "companygraph/mental-model", entities: [
{ id: "processes/answering", type: "process", name: "Answering" },
...["A", "B", "C"].map((n) => ({ id: "kpis/" + n, type: "kpi", name: n })),
...["What is an owner?", "How does the chat answer a question?", "Which rules does every instance pass?"].map((n, i) => ({ id: "questions/" + i, type: "question", name: n }))
], edges: [] };

// What each POST to the chat carried, so a test can say nothing was sent, or exactly what was.
const posted = [];
Expand All @@ -33,7 +39,7 @@ before(async () => {
});
return;
}
const files = { "/": ["text/html", PAGE], "/chat.js": ["text/javascript", asset("chat.js")], "/chat.css": ["text/css", asset("chat.css")] };
const files = { "/": ["text/html", PAGE], "/full": ["text/html", PAGE.replace(" defer>", ' data-questions="/model.json" defer>')], "/model.json": ["application/json", JSON.stringify(MODEL)], "/chat.js": ["text/javascript", asset("chat.js")], "/chat.css": ["text/css", asset("chat.css")] };
const hit = files[url];
if (!hit) { res.writeHead(404); res.end(); return; }
res.writeHead(200, { "content-type": hit[0] }); res.end(hit[1]);
Expand All @@ -45,7 +51,7 @@ before(async () => {
after(async () => { await browser.close(); await new Promise((r) => server.close(r)); });

const Q = "show me the organization of CompanyGraph";
const link = (q, before = "") => `/${before ? before + "&" : "?"}ask=${encodeURIComponent(q)}`;
const link = (q, before = "", at = "/") => `${at}${before ? before + "&" : "?"}ask=${encodeURIComponent(q)}`;

async function arrive(address, options = {}) {
posted.length = 0;
Expand All @@ -63,18 +69,18 @@ const card = (page) => page.evaluate(() => {
open: !!panel && !panel.hidden,
shown: !!c,
question: c ? c.querySelector(".rbchat-ask-q").textContent : null,
order: kids.filter((k) => ["rbchat-notice", "rbchat-ask", "rbchat-groups"].includes(k)),
order: kids.filter((k) => ["rbchat-notice", "rbchat-ask", "rbchat-groups", "rbchat-prompt-line"].includes(k)),
focused: document.activeElement && document.activeElement.className,
address: location.pathname + location.search + location.hash,
};
});

test("a link with ?ask= opens the panel with the question waiting under the notice, sends nothing, and drops it from the address", async () => {
test("a link with ?ask= opens the panel with the question waiting after the prompt line, sends nothing, and drops it from the address", async () => {
const { page, context } = await arrive(link(Q));
await page.waitForSelector(".rbchat-ask");
const s = await card(page);
assert.deepEqual([s.open, s.shown, s.question], [true, true, Q]);
assert.deepEqual(s.order, ["rbchat-notice", "rbchat-ask", "rbchat-groups"], "under the notice that says nothing is sent, above what the panel offers");
assert.deepEqual(s.order, ["rbchat-notice", "rbchat-groups", "rbchat-prompt-line", "rbchat-ask"], "after the prompt line, where a typed question would stand");
assert.equal(s.address, "/");
assert.match(s.focused, /rbchat-ask-go/, "on a desk Send has the focus, so Enter sends");
await page.waitForTimeout(300);
Expand All @@ -85,6 +91,39 @@ test("a link with ?ask= opens the panel with the question waiting under the noti
await context.close();
});

test("the question stands after the prompt line once the menus are drawn above it, and in view", async () => {
const { page, context } = await arrive(link(Q, "", "/full"));
await page.waitForSelector(".rbchat-intro.rbchat-still .rbchat-row");
await page.waitForSelector(".rbchat-ask");
const s = await page.evaluate(() => {
const intro = document.querySelector(".rbchat-intro"), c = document.querySelector(".rbchat-ask").getBoundingClientRect(), l = document.querySelector(".rbchat-log").getBoundingClientRect();
return { rows: intro.querySelectorAll(".rbchat-row").length, last: intro.lastElementChild.className.split(" ")[0], before: intro.lastElementChild.previousElementSibling.className.split(" ")[0], inView: c.top >= l.top - 1 && c.bottom <= l.bottom + 1 };
});
assert.ok(s.rows >= 4, `both menus drawn: ${s.rows}`);
assert.deepEqual([s.before, s.last], ["rbchat-prompt-line", "rbchat-ask"]);
assert.equal(s.inView, true, "the menus drawn above the card pushed it out of sight");
await context.close();
});

test("a language switch redraws the card and leaves the focus where it was: on Send on a desk, nowhere on a phone", async () => {
const german = async (page) => {
await page.evaluate(() => { document.documentElement.lang = "de"; });
await page.waitForFunction(() => /^Der Link/.test(document.querySelector(".rbchat-ask-from").textContent));
return page.evaluate(() => { const a = document.activeElement; return a === document.querySelector(".rbchat-ask-go") ? "send" : a.tagName; });
};
const desk = await arrive(link(Q));
await desk.page.waitForSelector(".rbchat-ask");
assert.equal(await german(desk.page), "send", "the redrawn card lost the focus, so Enter no longer sends");
await desk.page.keyboard.press("Enter");
await desk.page.waitForSelector(".rbchat-assistant");
assert.deepEqual(posted.map((x) => x.messages), [[{ role: "user", content: Q }]]);
await desk.context.close();
const phone = await arrive(link(Q), { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } });
await phone.page.waitForSelector(".rbchat-ask");
assert.equal(await german(phone.page), "BODY");
await phone.context.close();
});

test("the question keeps the address's other parameters, with or without ?chat=open", async () => {
for (const [address, rest] of [[link(Q, "?lang=de") + "#x", "/?lang=de#x"], [`/?chat=open&ask=${encodeURIComponent(Q)}&theme=dark`, "/?theme=dark"]]) {
const { page, context } = await arrive(address);
Expand Down
Loading
Loading