From 12609c29ed3482b498546f1ff1bed24af4a445f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Tue, 25 Aug 2026 17:50:48 -0300 Subject: [PATCH 1/3] feat(review-tutor): choose a harness in the tutor page The Harness select is now populated from discovery, Pi first, and the line under it carries the rest of the truth: how many harnesses are available, or why one is not and that a Pi restart re-discovers. Model and Thinking refill from the selected harness's models, so a model id from another harness can never be submitted. The model chosen per harness and the last harness are remembered in sessionStorage, and the answer header and Learning log entries name the harness and model that produced the answer. /api/state harness entries carry their models so the page never parses a model id. Refs #63 --- packages/review-tutor/README.md | 2 + packages/review-tutor/src/page-script.ts | 100 +++++++++-- packages/review-tutor/src/page-styles.ts | 4 + packages/review-tutor/src/page.ts | 4 +- packages/review-tutor/src/server-session.ts | 2 +- packages/review-tutor/src/server.ts | 1 + packages/review-tutor/test/page.test.ts | 162 +++++++++++++++++- .../test/server-extension.test.ts | 9 +- 8 files changed, 261 insertions(+), 23 deletions(-) diff --git a/packages/review-tutor/README.md b/packages/review-tutor/README.md index 0da4d63..1cf6f88 100644 --- a/packages/review-tutor/README.md +++ b/packages/review-tutor/README.md @@ -50,6 +50,8 @@ A harness connector owns model discovery, isolated invocation, and stream parsin The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead. +The Harness select in the configuration rail lists the harnesses discovery found, Pi first. The line under it reports the rest: how many are available, or why one is not. Discovery runs once at server start, so restart Pi to re-discover. Choosing a harness refills Model and Thinking with that harness's models and restores the model you last used there. + The Codex connector discovers models through `codex app-server`, invokes reviews with `codex exec --json`, and relies on Codex's existing local authentication. ## Local data diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index f439036..bd7e171 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -84,6 +84,8 @@ export const pageScript = String.raw` let railCollapsed = sessionStorage.getItem("reviewTutorRailCollapsed") === "1"; let restoreComposerOnDesktop = false; const QUIZ_IDS_KEY = "reviewTutorQuizEntryIds"; + const HARNESS_KEY = "reviewTutorHarness"; + const MODEL_BY_HARNESS_KEY = "reviewTutorModelByHarness"; const MAX_QUIZ_IDS = 100; const MAX_SELECTED_BYTES = 16 * 1024; const MAX_CONTEXT_BYTES = 32 * 1024; @@ -1357,6 +1359,7 @@ export const pageScript = String.raw` element("answer").classList.remove("streaming"); setAnswerTail(""); setAnswer(""); + setAnswerAttribution(""); setAskLabel("Ask"); clearHistoryState(); updateActions(); @@ -1414,6 +1417,7 @@ export const pageScript = String.raw` element("question").value = item.entry.question; element("question-state").textContent = "answered"; setAnswer(item.entry.answer); + setAnswerAttribution(item.entry.modelId); setAnswerTail("Saved answer · " + new Date(item.entry.createdAt).toLocaleString()); const pager = element("history-pager"); pager.hidden = historyEntries.length < 2; @@ -1801,6 +1805,7 @@ export const pageScript = String.raw` composerSelectionKey = selectionIdentity(); setAnswerTail(""); setAnswer(""); + setAnswerAttribution(element("model").value); element("ask").setAttribute("aria-busy", "true"); setAskLabel("Sending", true); announce("Question sent. Waiting for the tutor."); @@ -1898,6 +1903,8 @@ export const pageScript = String.raw` "-" + (entry.selection.endLine || entry.selection.startLine), ); + const identity = attribution(entry.modelId); + if (identity) parts.push(identity); parts.push(new Date(entry.createdAt).toLocaleString()); return parts.join(" · "); } @@ -2129,13 +2136,7 @@ export const pageScript = String.raw` select.addEventListener("change", updateMatches); } state = await api("/api/state"); - fill( - element("model"), - state.models.map((model) => model.id), - ); - for (let i = 0; i < state.models.length; i++) - element("model").options[i].textContent = state.models[i].label; - updateThinking(); + updateHarnesses(); currentSource = state.input; if (currentSource) acceptSource(currentSource); updateActions(); @@ -2232,11 +2233,82 @@ export const pageScript = String.raw` entry.selection.startLine === expected.startLine && entry.selection.endLine === expected.endLine; } + function harnesses() { + return state?.harnesses || []; + } + function harnessModels(id) { + return harnesses().find((harness) => harness.id === id)?.models || []; + } + function harnessLabel(id) { + return harnesses().find((harness) => harness.id === id)?.label || id; + } + function attribution(modelId) { + for (const harness of harnesses()) + for (const model of harness.models || []) + if (model.id === modelId) return harness.label + " · " + model.label; + return modelId ? String(modelId) : ""; + } + function setAnswerAttribution(modelId) { + element("answer-attribution").textContent = attribution(modelId); + } + function readStoredModels() { + try { + const stored = JSON.parse(sessionStorage.getItem(MODEL_BY_HARNESS_KEY) || "{}"); + return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}; + } catch { + return {}; + } + } + function rememberSelection() { + const stored = readStoredModels(); + stored[element("harness").value] = element("model").value; + try { + sessionStorage.setItem(MODEL_BY_HARNESS_KEY, JSON.stringify(stored)); + sessionStorage.setItem(HARNESS_KEY, element("harness").value); + } catch {} + } + function harnessHelper() { + const unavailable = harnesses().filter((harness) => !harness.available); + if (unavailable.length) + return unavailable.map((harness) => harness.reason).join(" ") + " Restart Pi to re-discover."; + return harnesses().length === 1 + ? harnesses()[0].label + " is the only connected harness." + : harnesses().length + " harnesses available."; + } + function updateHarnesses() { + const available = harnesses().filter((harness) => harness.available); + const select = element("harness"); + select.replaceChildren(); + for (const harness of available) select.append(option(harness.id, harness.label)); + let remembered = null; + try { + remembered = sessionStorage.getItem(HARNESS_KEY); + } catch {} + select.value = available.some((harness) => harness.id === remembered) + ? remembered + : (available[0]?.id || ""); + element("harness-helper").textContent = harnessHelper(); + updateModels(); + } + function updateModels() { + const models = harnessModels(element("harness").value); + fill(element("model"), models.map((model) => model.id)); + for (let index = 0; index < models.length; index++) + element("model").options[index].textContent = models[index].label; + const remembered = readStoredModels()[element("harness").value]; + element("model").value = models.some((model) => model.id === remembered) + ? remembered + : (models[0]?.id || ""); + updateThinking(); + } function updateThinking() { - const model = state?.models.find( + const previous = element("thinking").value; + const model = harnessModels(element("harness").value).find( (candidate) => candidate.id === element("model").value, ); - fill(element("thinking"), model?.thinkingLevels || []); + const levels = model?.thinkingLevels || []; + fill(element("thinking"), levels); + element("thinking").value = levels.includes(previous) ? previous : (levels[0] || ""); } function acceptSource(source) { currentSource = source; @@ -2257,7 +2329,15 @@ export const pageScript = String.raw` updateActions(); } element("kind").addEventListener("change", updateSourceFields); - element("model").addEventListener("change", updateThinking); + element("harness").addEventListener("change", () => { + updateModels(); + rememberSelection(); + announce("Harness: " + harnessLabel(element("harness").value)); + }); + element("model").addEventListener("change", () => { + updateThinking(); + rememberSelection(); + }); for (const id of ["view-diff", "view-structure", "view-log"]) { element(id).addEventListener("click", () => setView(id.replace("view-", ""))); element(id).addEventListener("keydown", handleViewKey); diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 071dd25..9cece94 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -244,6 +244,10 @@ margin-top:12px;padding-top:10px;border-top:1px solid var(--hairline)} display:flex;align-items:center;gap:8px;margin-bottom:8px} .answer-label { display:block;font:600 10px var(--font-mono);letter-spacing:.18em;text-transform:uppercase;color:var(--muted)} +.answer-attribution { +font:11px var(--font-mono);color:var(--subtle)} +.answer-attribution:empty { +display:none} .history-pager { display:flex;align-items:center;gap:5px;margin-left:auto;color:var(--muted);font:10px var(--font-mono)} .history-pager button { diff --git a/packages/review-tutor/src/page.ts b/packages/review-tutor/src/page.ts index ffd3376..53c7392 100644 --- a/packages/review-tutor/src/page.ts +++ b/packages/review-tutor/src/page.ts @@ -40,8 +40,8 @@ export const pageHtml = `
Tutor

Ask the tutor

Select code. Ask anything.

No code selected
-

Load a source and enter a question.

+

Load a source and enter a question.

Load a source to begin reviewing.

- + `; diff --git a/packages/review-tutor/src/server-session.ts b/packages/review-tutor/src/server-session.ts index 87e74b5..9be7acd 100644 --- a/packages/review-tutor/src/server-session.ts +++ b/packages/review-tutor/src/server-session.ts @@ -24,7 +24,7 @@ interface SessionOptions { canonicalRepo: string; registry: ConnectorRegistry; models: ModelChoice[]; - harnesses: Array<{ id: string; label: string; available: boolean; reason?: string }>; + harnesses: Array<{ id: string; label: string; available: boolean; models: ModelChoice[]; reason?: string }>; execFile: ExecFile; } diff --git a/packages/review-tutor/src/server.ts b/packages/review-tutor/src/server.ts index a5f0413..78060a2 100644 --- a/packages/review-tutor/src/server.ts +++ b/packages/review-tutor/src/server.ts @@ -162,6 +162,7 @@ export async function startReviewTutorServer(options: ServerOptions): Promise | Response | unknown | Error return value instanceof Promise || value instanceof Response ? value : json(value); } +function seedStorage(window: Window, options: { + storedPageId?: string; + storedQuizIds?: string; + storedStructureMode?: string; + storedStructureNeighbours?: string; + storedHarness?: string; + storedModelByHarness?: string; + railCollapsed?: boolean; +}) { + const seeds: Array<[string, string | undefined]> = [ + ["reviewTutorPageId", options.storedPageId], + ["reviewTutorQuizEntryIds", options.storedQuizIds], + ["reviewTutorStructureMode", options.storedStructureMode], + ["reviewTutorStructureNeighbours", options.storedStructureNeighbours], + ["reviewTutorHarness", options.storedHarness], + ["reviewTutorModelByHarness", options.storedModelByHarness], + ["reviewTutorRailCollapsed", options.railCollapsed ? "1" : undefined], + ]; + for (const [key, value] of seeds) if (value !== undefined) window.sessionStorage.setItem(key, value); +} + function blockStructureStorageReads(window: Window, options: { throwStructureModeRead?: boolean; throwStructureNeighboursRead?: boolean }) { if (!options.throwStructureModeRead && !options.throwStructureNeighboursRead) return; const getItem = window.sessionStorage.getItem.bind(window.sessionStorage); @@ -134,6 +175,9 @@ async function boot(options: { throwStructureModeRead?: boolean; storedStructureNeighbours?: string; throwStructureNeighboursRead?: boolean; + harnesses?: unknown[]; + storedHarness?: string; + storedModelByHarness?: string; stateResponses?: unknown[]; structureResponses?: Array | Response | unknown | Error>; failHeartbeat?: boolean; @@ -142,11 +186,7 @@ async function boot(options: { FakeEventSource.instances = []; const window = new Window({ url: "http://127.0.0.1:43123/?session=secret-token" }); Object.defineProperty(window, "innerWidth", { value: options.width ?? 1440, writable: true, configurable: true }); - if (options.storedPageId !== undefined) window.sessionStorage.setItem("reviewTutorPageId", options.storedPageId); - if (options.storedQuizIds !== undefined) window.sessionStorage.setItem("reviewTutorQuizEntryIds", options.storedQuizIds); - if (options.storedStructureMode !== undefined) window.sessionStorage.setItem("reviewTutorStructureMode", options.storedStructureMode); - if (options.storedStructureNeighbours !== undefined) window.sessionStorage.setItem("reviewTutorStructureNeighbours", options.storedStructureNeighbours); - if (options.railCollapsed) window.sessionStorage.setItem("reviewTutorRailCollapsed", "1"); + seedStorage(window, options); blockStructureStorageReads(window, options); const script = pageHtml.match(/