From cae21013456a708d2ea04a5cf44f209b163a0cbd Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:31:53 -0400 Subject: [PATCH 01/17] =?UTF-8?q?refactor(profile):=20institution=20name/l?= =?UTF-8?q?ogo=20lookup=20extracted=20to=20institution-lookup.ts=20?= =?UTF-8?q?=E2=80=94=20shared=20by=20the=20About-You=20card=20and=20the=20?= =?UTF-8?q?onboarding=20wizard;=20card=20keeps=20debounce/sequencing/race?= =?UTF-8?q?=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/home-cards.tsx | 63 +++++-------------- packages/ui/src/amicode/institution-lookup.ts | 58 +++++++++++++++++ 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 packages/ui/src/amicode/institution-lookup.ts diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 0df447cc60..cf392906ed 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,5 +1,11 @@ import { For, Show, createEffect, createMemo, on, type JSX, createSignal, onCleanup } from "solid-js" import { Mark } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" // AMICODE: home-screen card strip (the "central screen" Aaron wanted the H-bot // and useful practitioner info on). Two identity heroes — MEET AMICO (who your @@ -397,14 +403,8 @@ function AboutYouCard(props: { }) setEditing(true) } - // Institution logos ride Google's favicon service — clearbit's logo CDN is - // sunset (autocomplete lives on for name+domain, which is all we need). - const institutionLogoUrl = (domain: string) => - `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(domain)}&size=256` - - // LinkedIn-style institution lookup: Clearbit's free autocomplete (no key, - // CORS-open) → name + domain + logo. Picking a suggestion fills affiliation - // AND its logo; free text still saves as a plain affiliation. + // Institution lookup lives in institution-lookup.ts (shared with the + // onboarding wizard); this card owns debounce, sequencing, and signals. let searchTimer: ReturnType | undefined let searchSeq = 0 // out-of-order guard: a slow "har" response must not clobber "harvard"'s onCleanup(() => { @@ -419,55 +419,24 @@ function AboutYouCard(props: { } searchTimer = setTimeout(() => { const seq = ++searchSeq - fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(q.trim())}`) - .then((r) => (r.ok ? r.json() : [])) - .then((rows: any) => { - if (seq === searchSeq) setSuggestions(Array.isArray(rows) ? rows.slice(0, 5) : []) - }) - .catch(() => { - if (seq === searchSeq) setSuggestions([]) - }) + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) }, 200) } // Counter, not boolean: pick A then B while A's Wikidata round-trip is in // flight — A's finally must not re-enable Save while B still resolves (the // boolean version re-introduced the save-races-logo bug it claimed to fix). const [resolvingLogo, setResolvingLogo] = createSignal(0) - const wikiJson = (url: string) => - fetch(url) - .then((r) => (r.ok ? r.json() : undefined)) - .catch(() => undefined) - const pickInstitution = async (sug: { name: string; domain: string; logo: string }) => { - // Instant favicon mark, then resolve the real BRAND logo: Wikidata P154 - // ("logo image" — e.g. the purple NYU torch, not the seal) rasterized by - // Commons at 512px → crisp at any tile size. Fallbacks: Wikipedia page - // image, then the favicon. Save is held while resolving so the upgraded - // URL is what gets persisted (the old async upgrade lost a race with Save). + const pickInstitution = async (sug: InstitutionSuggestion) => { + // Instant favicon mark, then resolve the real BRAND logo (Wikidata P154 → + // Wikipedia pageimage → favicon; see institution-lookup.ts). Save is held + // while resolving so the upgraded URL is what gets persisted. setDraft({ ...draft(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) setSuggestions([]) setResolvingLogo((n) => n + 1) try { - let logo: string | undefined - const found = await wikiJson( - `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(sug.name)}&language=en&format=json&origin=*`, - ) - const qid = found?.search?.[0]?.id - if (qid) { - const claims = await wikiJson( - `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P154&format=json&origin=*`, - ) - const file = claims?.claims?.P154?.[0]?.mainsnak?.datavalue?.value - if (typeof file === "string" && file) { - logo = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=512` - } - } - if (!logo) { - const page = await wikiJson( - `https://en.wikipedia.org/w/api.php?action=query&format=json&origin=*&redirects=1&titles=${encodeURIComponent(sug.name)}&prop=pageimages&piprop=original`, - ) - const orig = (Object.values(page?.query?.pages ?? {})[0] as any)?.original?.source - if (typeof orig === "string" && /\.(svg|png|jpe?g|webp)$/i.test(orig)) logo = orig - } + const logo = await resolveBrandLogo(sug.name, sug.domain) if (logo && draft().affiliation === sug.name) setDraft({ ...draft(), affiliation_logo: logo }) } finally { setResolvingLogo((n) => Math.max(0, n - 1)) diff --git a/packages/ui/src/amicode/institution-lookup.ts b/packages/ui/src/amicode/institution-lookup.ts new file mode 100644 index 0000000000..a5588f255a --- /dev/null +++ b/packages/ui/src/amicode/institution-lookup.ts @@ -0,0 +1,58 @@ +// AMICODE: institution name/logo lookup — shared by the About-You card and the +// onboarding wizard. Pipeline (client-side by design, all CORS-open): +// 1. Clearbit autocomplete → name + domain (its logo CDN is sunset; only the +// suggest API lives on) +// 2. Wikidata P154 "logo image" → Commons FilePath @512px (crisp BRAND mark — +// the NYU torch, not the seal) +// 3. Wikipedia pageimage original (when P154 is absent) +// 4. Google faviconV2 @256 (instant placeholder + last resort) +// Pure async functions — callers own debounce/sequencing/signals. + +export type InstitutionSuggestion = { name: string; domain: string; logo: string } + +/** Instant favicon mark for a domain (placeholder + last-resort logo). */ +export function institutionLogoUrl(domain: string): string { + return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(domain)}&size=256` +} + +/** Clearbit autocomplete: name + domain suggestions (top 5). Never rejects. */ +export async function suggestInstitutions(query: string): Promise { + const q = query.trim() + if (q.length < 2) return [] + try { + const r = await fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(q)}`) + const rows = r.ok ? await r.json() : [] + return Array.isArray(rows) ? rows.slice(0, 5) : [] + } catch { + return [] + } +} + +const wikiJson = (url: string) => + fetch(url) + .then((r) => (r.ok ? r.json() : undefined)) + .catch(() => undefined) + +/** Best brand logo for an institution: Wikidata P154 → Wikipedia pageimage → + * favicon. Never rejects; always returns SOME url. */ +export async function resolveBrandLogo(name: string, domain: string): Promise { + const found = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(name)}&language=en&format=json&origin=*`, + ) + const qid = found?.search?.[0]?.id + if (qid) { + const claims = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P154&format=json&origin=*`, + ) + const file = claims?.claims?.P154?.[0]?.mainsnak?.datavalue?.value + if (typeof file === "string" && file) { + return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=512` + } + } + const page = await wikiJson( + `https://en.wikipedia.org/w/api.php?action=query&format=json&origin=*&redirects=1&titles=${encodeURIComponent(name)}&prop=pageimages&piprop=original`, + ) + const orig = (Object.values(page?.query?.pages ?? {})[0] as any)?.original?.source + if (typeof orig === "string" && /\.(svg|png|jpe?g|webp)$/i.test(orig)) return orig + return institutionLogoUrl(domain) +} From ed80e7fad96705251d50fe46f062173ab71c3fba Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:35:09 -0400 Subject: [PATCH 02/17] =?UTF-8?q?feat(onboarding):=20first-run=20welcome?= =?UTF-8?q?=20wizard=20=E2=80=94=203=20steps=20(brand=20welcome=20?= =?UTF-8?q?=E2=86=92=20about-you=20with=20live=20institution/logo=20lookup?= =?UTF-8?q?=20=E2=86=92=20profile=20preview=20+=20open=20chat);=20saves=20?= =?UTF-8?q?through=20POST=20/amicode/profile=20so=20the=20home=20page=20au?= =?UTF-8?q?tofills=20affiliation=20+=20logo;=20shows=20exactly=20once=20(f?= =?UTF-8?q?resh=20profile,=20dismiss=20remembered)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 60 ++- .../ui/src/amicode/onboarding-wizard.test.ts | 22 + packages/ui/src/amicode/onboarding-wizard.tsx | 458 ++++++++++++++++++ .../components/amicode-onboarding-wizard.tsx | 2 + 4 files changed, 534 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/amicode/onboarding-wizard.test.ts create mode 100644 packages/ui/src/amicode/onboarding-wizard.tsx create mode 100644 packages/ui/src/components/amicode-onboarding-wizard.tsx diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index aa0d177af6..1391259b98 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -62,6 +62,7 @@ import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" +import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" @@ -339,6 +340,42 @@ function HomeDesign() { }) const [sessionsExpanded, setSessionsExpanded] = createSignal(false) + // Shared by the About-You card and the onboarding wizard: identity fields + // ride query params on the raw POST route; refetch renders the saved state. + async function saveProfileFields(fields: Record) { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v) + await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`) + await refetchProfile() + } + + // Onboarding wizard (session zero): decided ONCE when the profile first + // resolves — the mid-wizard profile refetch must not unmount the preview + // step, and a dismiss is remembered per install (localStorage). + const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed" + const [wizardOpen, setWizardOpen] = createSignal(false) + let wizardDecided = false + createEffect(() => { + const view = profileView() + if (wizardDecided || view === undefined || !view.ok) return + wizardDecided = true + let dismissed = false + try { + dismissed = localStorage.getItem(WIZARD_DISMISS_KEY) === "1" + } catch { + /* storage unavailable → treat as not dismissed */ + } + setWizardOpen(shouldShowWizard(view.you, dismissed)) + }) + const dismissWizard = () => { + try { + localStorage.setItem(WIZARD_DISMISS_KEY, "1") + } catch { + /* best-effort */ + } + setWizardOpen(false) + } + function startWithPrompt(prompt: string) { const project = newSessionProject() if (!project) { @@ -633,14 +670,7 @@ function HomeDesign() { starters={AMICODE_STARTERS} onStart={startWithPrompt} onEditProfile={() => startWithPrompt("update my profile — my name, affiliation, and what I work on")} - onSaveProfile={async (fields) => { - // In-place save (About-You card): identity fields ride query - // params on the raw POST route; refetch renders the saved state. - const q = new URLSearchParams() - for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v) - await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`) - await refetchProfile() - }} + onSaveProfile={saveProfileFields} resumeName={resumeProblem()?.name} resumeMeta={resumeMeta()} onResume={() => { @@ -657,6 +687,20 @@ function HomeDesign() { /> + + { + const v = profileView() + return v?.ok ? v.you.name : "" + })()} + onComplete={saveProfileFields} + onDismiss={dismissWizard} + onOpenChat={() => { + dismissWizard() + startWithPrompt("") + }} + /> + { + test("fresh profile, not dismissed → show", () => { + expect(shouldShowWizard({}, false)).toBe(true) + expect(shouldShowWizard({ affiliation: "", scholar: "", focus: "" }, false)).toBe(true) + }) + test("any identity field set → never show (wizard or card already filled it)", () => { + expect(shouldShowWizard({ affiliation: "NYU" }, false)).toBe(false) + expect(shouldShowWizard({ scholar: "https://scholar.google.com/x" }, false)).toBe(false) + expect(shouldShowWizard({ focus: "transmon gates" }, false)).toBe(false) + }) + test("dismissed → never show, even fresh", () => { + expect(shouldShowWizard({}, true)).toBe(false) + }) + test("profile not loaded yet → never flash the wizard", () => { + expect(shouldShowWizard(undefined, false)).toBe(false) + }) +}) diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx new file mode 100644 index 0000000000..d0738b69ae --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -0,0 +1,458 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Mark } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" + +// AMICODE: first-run onboarding wizard — the dedicated welcome UI (session +// zero, visual edition). Three steps: brand welcome → about-you (name, focus, +// institution with live logo lookup, Scholar) → preview + open chat. Saving +// goes through the SAME POST /amicode/profile the About-You card uses (the +// host passes onComplete), so the home page reflects it instantly and the +// chat's overture interview skips what's already answered. + +export type WizardFields = { + name: string + affiliation: string + focus: string + scholar: string + affiliation_logo: string +} + +/** Show the wizard only for a genuinely fresh profile: nothing identity-like + * saved yet and no prior dismiss. Pure — unit-tested. */ +export function shouldShowWizard( + profile: { affiliation?: string | null; scholar?: string | null; focus?: string | null } | undefined, + dismissed: boolean, +): boolean { + if (dismissed || profile === undefined) return false + return !profile.affiliation && !profile.scholar && !profile.focus +} + +const FIELD: Record = { + width: "100%", + "box-sizing": "border-box", + background: "var(--v2-background-bg-layer-02, transparent)", + border: "1px solid var(--v2-border-border-base)", + "border-radius": "8px", + padding: "9px 12px", + "font-size": "13px", + color: "var(--v2-text-text-base)", + outline: "none", +} +const LABEL: Record = { + "font-size": "11px", + "font-weight": "650", + "letter-spacing": "0.06em", + "text-transform": "uppercase", + color: "var(--v2-text-text-muted)", + "margin-bottom": "4px", +} + +export function AmicodeOnboardingWizard(props: { + initialName?: string + onComplete: (fields: WizardFields) => Promise + onDismiss: () => void + onOpenChat: () => void +}) { + const [step, setStep] = createSignal<0 | 1 | 2>(0) + const [fields, setFields] = createSignal({ + name: props.initialName ?? "", + affiliation: "", + focus: "", + scholar: "", + affiliation_logo: "", + }) + const [suggestions, setSuggestions] = createSignal([]) + const [resolvingLogo, setResolvingLogo] = createSignal(0) + const [saving, setSaving] = createSignal(false) + const [saveError, setSaveError] = createSignal(undefined) + + // same debounce + out-of-order discipline as the About-You card + let searchTimer: ReturnType | undefined + let searchSeq = 0 + onCleanup(() => { + if (searchTimer) clearTimeout(searchTimer) + }) + const search = (q: string) => { + if (searchTimer) clearTimeout(searchTimer) + if (q.trim().length < 2) { + searchSeq++ + setSuggestions([]) + return + } + searchTimer = setTimeout(() => { + const seq = ++searchSeq + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) + }, 200) + } + const pick = async (sug: InstitutionSuggestion) => { + setFields({ ...fields(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) + setSuggestions([]) + setResolvingLogo((n) => n + 1) + try { + const logo = await resolveBrandLogo(sug.name, sug.domain) + if (logo && fields().affiliation === sug.name) setFields({ ...fields(), affiliation_logo: logo }) + } finally { + setResolvingLogo((n) => Math.max(0, n - 1)) + } + } + + const saveAndPreview = async () => { + setSaving(true) + setSaveError(undefined) + try { + await props.onComplete(fields()) + setStep(2) + } catch { + setSaveError("Couldn't save — server unreachable. Try again.") + } finally { + setSaving(false) + } + } + + const Dots = () => ( +
+ + {(i) => ( + + )} + +
+ ) + + const PrimaryBtn = (p: { label: string; onClick: () => void; disabled?: boolean }) => ( + + ) + const QuietBtn = (p: { label: string; onClick: () => void }) => ( + + ) + + return ( +
+
+ {/* step 0 — welcome */} + +
+ +
+
+ Welcome to Amicode +
+
+ Amico is your quantum-computing agent — it designs pulses from a conversation, warm-starts from your + pulse bank, and tunes on real hardware. Thirty seconds of setup makes it yours. +
+
+
+ setStep(1)} /> + props.onDismiss()} /> +
+
+
+ + {/* step 1 — about you */} + +
+
+
+ About you +
+
+ This fills your home page and helps Amico tailor its physics to you. +
+
+
+
Name
+ setFields({ ...fields(), name: e.currentTarget.value })} + placeholder="Ada Lovelace" + /> +
+
+
Institution
+
+ + (e.currentTarget.style.display = "none")} + /> + + { + setFields({ ...fields(), affiliation: e.currentTarget.value }) + search(e.currentTarget.value) + }} + placeholder="Start typing — we'll find the logo" + /> +
+ 0}> +
+ + {(sug) => ( + + )} + +
+
+
+
+
What you work on
+ setFields({ ...fields(), focus: e.currentTarget.value })} + placeholder="e.g. high-fidelity gates on transmons" + /> +
+
+
Google Scholar (optional)
+ setFields({ ...fields(), scholar: e.currentTarget.value })} + placeholder="https://scholar.google.com/…" + /> +
+ +
{saveError()}
+
+
+ 0 ? "Finding logo…" : "Continue"} + disabled={saving() || resolvingLogo() > 0} + onClick={() => void saveAndPreview()} + /> + setStep(0)} /> + + props.onDismiss()} /> + +
+
+
+ + {/* step 2 — done: the profile as the home page will show it */} + +
+
+ }> + + (e.currentTarget.style.display = "none")} + /> + + +
+
+ {fields().name || "You"} +
+
+ {fields().affiliation || "Independent"} +
+
+
+
+ You're set. Amico will remember this — say hi and design your first pulse. +
+
+ props.onOpenChat()} /> + props.onDismiss()} /> +
+
+
+ + +
+
+ ) +} diff --git a/packages/ui/src/components/amicode-onboarding-wizard.tsx b/packages/ui/src/components/amicode-onboarding-wizard.tsx new file mode 100644 index 0000000000..0e1c1b2430 --- /dev/null +++ b/packages/ui/src/components/amicode-onboarding-wizard.tsx @@ -0,0 +1,2 @@ +// AMICODE: re-export shim (wildcard export path) — logic in ../amicode/onboarding-wizard.tsx. +export { AmicodeOnboardingWizard, shouldShowWizard, type WizardFields } from "../amicode/onboarding-wizard" From 4db62ee6cc20b999d0b7ba9ff9000afcc243b0db Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:53:44 -0400 Subject: [PATCH 03/17] =?UTF-8?q?feat(library):=20papers=20that=20make=20A?= =?UTF-8?q?mico=20smarter=20=E2=80=94=20Library=20card=20replaces=20the=20?= =?UTF-8?q?starter=20chips=20(Open=20chat=20owns=20'start=20something');?= =?UTF-8?q?=20PDFs=20upload=20via=20POST=20/amicode/library=20into=20~/.am?= =?UTF-8?q?ico/library=20(sanitized=20basename,=20%PDF-=20magic=20check,?= =?UTF-8?q?=2030MB=20cap),=20GET=20lists=20newest-first;=20'Discuss=20late?= =?UTF-8?q?st=20=E2=86=92'=20hands=20the=20agent=20the=20paper=20path=20+?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 24 +++- packages/app/src/utils/amicode-fetch.ts | 13 +- .../opencode/src/server/amicode/library.ts | 83 +++++++++++ .../server/routes/instance/httpapi/server.ts | 10 ++ .../test/server/amicode-library.test.ts | 34 +++++ packages/ui/src/amicode/home-cards.tsx | 132 ++++++++++++++---- 6 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/src/server/amicode/library.ts create mode 100644 packages/opencode/test/server/amicode-library.test.ts diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 1391259b98..073423e633 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -66,7 +66,6 @@ import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amico import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" -import { AMICODE_STARTERS } from "@opencode-ai/ui/amicode-getting-started" import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" import { parseProblemResponse } from "@opencode-ai/ui/amicode-entity-view" import { Mark } from "@opencode-ai/ui/logo" @@ -352,6 +351,26 @@ function HomeDesign() { // Onboarding wizard (session zero): decided ONCE when the profile first // resolves — the mid-wizard profile refetch must not unmount the preview // step, and a dismiss is remembered per install (localStorage). + // Library (papers that make Amico smarter): count + latest for the card. + const [libraryRaw, { refetch: refetchLibrary }] = createResource( + () => state.selection.server, + () => amicodeGet(focusedServer(), "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/amicode/library").catch(() => undefined), + ) + const libraryView = createMemo(() => { + const raw = libraryRaw() as { ok?: boolean; papers?: { name?: string; path?: string }[] } | undefined + if (!raw || raw.ok !== true || !Array.isArray(raw.papers)) return undefined + return { + count: raw.papers.length, + latestName: typeof raw.papers[0]?.name === "string" ? raw.papers[0].name : undefined, + latestPath: typeof raw.papers[0]?.path === "string" ? raw.papers[0].path : undefined, + } + }) + async function uploadPaper(filename: string, dataB64: string) { + const res = await amicodePost(focusedServer(), "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/amicode/library", { filename, data_b64: dataB64 }) + if ((res as { ok?: boolean } | undefined)?.ok !== true) throw new Error("library save rejected") + await refetchLibrary() + } + const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed" const [wizardOpen, setWizardOpen] = createSignal(false) let wizardDecided = false @@ -667,8 +686,9 @@ function HomeDesign() {
startWithPrompt("update my profile — my name, affiliation, and what I work on")} onSaveProfile={saveProfileFields} resumeName={resumeProblem()?.name} diff --git a/packages/app/src/utils/amicode-fetch.ts b/packages/app/src/utils/amicode-fetch.ts index 9a0873753b..64006e9811 100644 --- a/packages/app/src/utils/amicode-fetch.ts +++ b/packages/app/src/utils/amicode-fetch.ts @@ -21,7 +21,11 @@ export async function amicodeGet(conn: ServerConnection.Any | undefined, route: /** POST sibling of amicodeGet — the amicode raw routes keep params in the URL * (no body), so this is the same call shape with method POST. Used by the * About-You card's in-place profile save. */ -export async function amicodePost(conn: ServerConnection.Any | undefined, route: string): Promise { +export async function amicodePost( + conn: ServerConnection.Any | undefined, + route: string, + jsonBody?: unknown, +): Promise { if (!conn) throw new Error("no active server") const headers: Record = {} if (conn.http.password) @@ -29,7 +33,12 @@ export async function amicodePost(conn: ServerConnection.Any | undefined, route: username: conn.http.username, password: conn.http.password, })}` - const res = await fetch(new URL(route, conn.http.url), { method: "POST", headers }) + if (jsonBody !== undefined) headers["content-type"] = "application/json" + const res = await fetch(new URL(route, conn.http.url), { + method: "POST", + headers, + ...(jsonBody !== undefined ? { body: JSON.stringify(jsonBody) } : {}), + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as unknown } diff --git a/packages/opencode/src/server/amicode/library.ts b/packages/opencode/src/server/amicode/library.ts new file mode 100644 index 0000000000..96967f7c08 --- /dev/null +++ b/packages/opencode/src/server/amicode/library.ts @@ -0,0 +1,83 @@ +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +// AMICODE: the user's paper library — PDFs uploaded from the home page that +// make Amico smarter about THIS user's work. Files land in ~/.amico/library; +// the amicode extension grants the agent's file tools read access to that dir, +// so "read the paper I just added" works with zero further plumbing. Same +// never-reject discipline as problems.ts: every body is a JSON string. + +export function libraryRoot(): string { + const env = process.env.AMICODE_LIBRARY_DIR + if (env && env.trim() !== "") return env + return path.join(os.homedir(), ".amico", "library") +} + +export function synthesizeLibrary(code: string, detail: string): string { + return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` }) +} + +const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake + +/** Basename-only, conservative charset, single .pdf suffix. */ +export function sanitizeFilename(raw: string): string | null { + const base = path.basename(raw).trim() + if (!/\.pdf$/i.test(base)) return null + const clean = base + .slice(0, -4) + .replace(/[^\w.\- ]+/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120) + return clean === "" ? null : `${clean}.pdf` +} + +export function libraryBody(root: string = libraryRoot()): string { + try { + if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null }) + const papers = readdirSync(root) + .filter((f) => f.toLowerCase().endsWith(".pdf")) + .map((f) => { + const st = statSync(path.join(root, f)) + return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) } + }) + .sort((a, b) => b.added_ms - a.added_ms) + return JSON.stringify({ ok: true, papers, error: null }) + } catch (err) { + return synthesizeLibrary("bad_output", String(err)) + } +} + +/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the + * refreshed listing on success so the client renders in one round-trip. */ +export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string { + let parsed: { filename?: unknown; data_b64?: unknown } + try { + parsed = JSON.parse(rawBody) + } catch { + return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}") + } + if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string") + return synthesizeLibrary("bad_request", "filename and data_b64 are required strings") + const name = sanitizeFilename(parsed.filename) + if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization") + let bytes: Buffer + try { + bytes = Buffer.from(parsed.data_b64, "base64") + } catch { + return synthesizeLibrary("bad_request", "data_b64 is not valid base64") + } + if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file") + if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`) + // magic check: every real PDF opens with %PDF- + if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-"))) + return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)") + try { + mkdirSync(root, { recursive: true }) + writeFileSync(path.join(root, name), bytes) + } catch (err) { + return synthesizeLibrary("write_failed", String(err)) + } + return libraryBody(root) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9b6142bf68..81cb55fed4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -60,6 +60,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors import { serveUIEffect } from "@/server/shared/ui" import * as AmicodeVaults from "@/server/amicode/vaults" import * as AmicodeProblems from "@/server/amicode/problems" +import * as AmicodeLibrary from "@/server/amicode/library" import * as AmicodeProfile from "@/server/amicode/profile" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -233,6 +234,15 @@ const amicodeProblemsRoute = HttpRouter.use((router) => // editable identity fields ride query params (small strings; keeps the // handler body-free like every other amicode route). Returns the fresh // profile JSON so the card can render the saved state without a second GET. + yield* router.add("GET", "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/amicode/library", () => + Effect.sync(() => HttpServerResponse.text(AmicodeLibrary.libraryBody(), { contentType: "application/json" })), + ) + yield* router.add("POST", "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/amicode/library", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + return HttpServerResponse.text(AmicodeLibrary.saveLibraryFile(body), { contentType: "application/json" }) + }), + ) yield* router.add("POST", "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/amicode/profile", (request) => Effect.sync(() => { const params = new URL(request.url, "http://localhost").searchParams diff --git a/packages/opencode/test/server/amicode-library.test.ts b/packages/opencode/test/server/amicode-library.test.ts new file mode 100644 index 0000000000..3da24cd42d --- /dev/null +++ b/packages/opencode/test/server/amicode-library.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { libraryBody, saveLibraryFile, sanitizeFilename } from "@/server/amicode/library" + +const PDF = Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.from("x".repeat(64))]) +const body = (filename: string, data: Buffer = PDF) => JSON.stringify({ filename, data_b64: data.toString("base64") }) + +describe("library", () => { + test("save → list roundtrip; newest first; path included for the agent prompt", () => { + const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-")) + const saved = JSON.parse(saveLibraryFile(body("Krotov Methods (2024).pdf"), root)) + expect(saved.ok).toBe(true) + expect(saved.papers).toHaveLength(1) + expect(saved.papers[0].name).toBe("Krotov Methods -2024-.pdf") + const listed = JSON.parse(libraryBody(root)) + expect(listed.papers[0].path).toContain(root) + expect(listed.papers[0].size).toBe(PDF.length) + }) + test("rejects non-PDF content, wrong extension, oversize, garbage body", () => { + const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-")) + expect(JSON.parse(saveLibraryFile(body("notes.txt"), root)).ok).toBe(false) + expect(JSON.parse(saveLibraryFile(body("fake.pdf", Buffer.from("hello")))).ok).toBe(false) + expect(JSON.parse(saveLibraryFile("not json", root)).ok).toBe(false) + expect(JSON.parse(saveLibraryFile(JSON.stringify({ filename: "a.pdf" }), root)).ok).toBe(false) + }) + test("sanitizeFilename: basename-only, pdf-only, traversal-proof", () => { + expect(sanitizeFilename("../../etc/passwd.pdf")).toBe("passwd.pdf") + expect(sanitizeFilename("paper.PDF")).toMatch(/\.pdf$/i) + expect(sanitizeFilename("nope.txt")).toBeNull() + expect(sanitizeFilename(".pdf")).toBeNull() + }) +}) diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index cf392906ed..2b53796fba 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1002,6 +1002,101 @@ function Sparkline(props: { values: number[] }) { ) } +// --------------------------------------------------------------------------- +// LIBRARY — upload papers so Amico learns YOUR work (the personalization card) +// --------------------------------------------------------------------------- +function LibraryCard(props: { + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper: (filename: string, dataB64: string) => Promise + onStart: (prompt: string) => void +}) { + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal(undefined) + let fileInput: HTMLInputElement | undefined + + const upload = async (files: FileList | null) => { + if (!files || files.length === 0) return + setBusy(true) + setError(undefined) + try { + for (const file of Array.from(files)) { + const buf = new Uint8Array(await file.arrayBuffer()) + let bin = "" + const CHUNK = 0x8000 + for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)) + await props.onUploadPaper(file.name, btoa(bin)) + } + } catch { + setError("Upload failed — is the server up?") + } finally { + setBusy(false) + if (fileInput) fileInput.value = "" + } + } + + return ( + + void upload(e.currentTarget.files)} + /> +
+ {(props.library?.count ?? 0) > 0 + ? `${props.library!.count} paper${props.library!.count === 1 ? "" : "s"}` + : "Make Amico smarter"} +
+
{error() ?? props.library?.latestName ?? "upload papers — Amico learns your work"}
+
+ + + + +
+
+ ) +} + export interface HomeLiveRun { name?: string iteration?: number | null @@ -1046,8 +1141,10 @@ const COMPACT_CSS = ` export function AmicodeHomeCards(props: { profile: ProfileView | undefined - starters: readonly { label: string; prompt: string }[] onStart: (prompt: string) => void + // Library ("make Amico smarter"): uploaded papers land in ~/.amico/library + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper?: (filename: string, dataB64: string) => Promise onEditProfile: () => void onSaveProfile?: (fields: { name?: string @@ -1151,32 +1248,13 @@ export function AmicodeHomeCards(props: { - {/* Start something */} - -
- - {(starter) => ( - - )} - -
-
+ {/* Library — papers that make Amico smarter (replaces the old starter + chips: Open chat already owns "start something"). Uploads go to + POST /amicode/library; the extension grants the agent read access + to ~/.amico/library so "read my latest paper" just works. */} + + +
) From 5a14614d774bc6e878bda688211fd3c308017409 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 8 Jul 2026 18:08:33 -0400 Subject: [PATCH 04/17] amicode(home): fix silent dead-end on 'Open chat' with no tracked projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh browser profile against a bare `opencode serve`, the home page's primary CTA (Meet-Amico card / "Open chat") did nothing: the persisted client-side project list is empty, so startWithPrompt fell through to openNewSession(), which needs the same newSessionProject() that just came back empty and silently returns. Fall back to the focused server's own working directory, synced from GET /path (.directory; "" until loaded, so the guard holds). Open and touch it as a project — self-healing: the home page tracks it from then on — and start the draft with the prompt preserved. Deliberately not sync.data.project: the server's "global" record has worktree "/". Regression spec drives the real UI against a mocked server with no localStorage seed; verified failing on the unfixed code and passing with the fix. tsgo -b clean; bun test:unit 376 pass. Co-Authored-By: Claude Fable 5 --- AMICODE-PATCHES.md | 20 +++++++++++ .../home-open-chat-empty-projects.spec.ts | 33 +++++++++++++++++++ packages/app/src/pages/home.tsx | 18 ++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index e89ce2c289..0834bb663e 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -225,3 +225,23 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - index.css: @font-face for both (JuliaMono full glyph set — Julia Unicode; Racing Sans One latin subset, font-display swap). logo.tsx + wordmark-v2.tsx: font-family 'Racing Sans One' first, weight 700→400. settings.tsx: monoDefault/monoFallback lead with JuliaMono. theme.css: --font-family-mono leads with JuliaMono. Terminal font DELIBERATELY unchanged (JetBrainsMono Nerd Font Mono via separate terminalFallback). - New assets (git-added — build breaks without them): public/assets/RacingSansOne-Regular.woff2 (21 KB) + JuliaMono-Regular.woff2 (946 KB). - Font build sha256: `2a15da111be516516fb1fbd1a4fb5ae02ad9bddd6373ede08cdf7b28c19d163a` (dist/opencode-local + vendored path, write-temp + mv -f swap; SUPERSEDES #13's a73d8583… — same code, fonts now committed). Verify (scratch port 14099): `GET /assets/RacingSansOne-Regular.woff2` → 200 font/woff2 21804 B; `GET /assets/JuliaMono-Regular.woff2` → 200 font/woff2 946516 B; "Racing Sans One" in built css + index/new-session chunks, "JuliaMono" in `index-Dwtxigfs.css`; `GET /amicode/problems` → 200; `GET /` → 200 `Amicode`; ui `bun test src` → 70 pass; typecheck green ui+app (no snapshots assert fonts, per Aaron — confirmed nothing went red). Bonus confirmation: KaTeX\_\* woff2 assets now in dist — the entity view's katex import (#13) pulls its font set into the embed. +9. (home CTA fallback) — amicode(home): "Open chat" works on a fresh profile. + - BUG: `startWithPrompt` (fork wiring for the Meet-Amico card, patch 5ef6b7e0e) dead-ended + silently when the persisted client-side project list was empty (fresh browser profile + against a bare `opencode serve`): the `!project` branch called `openNewSession()`, which + needs the SAME empty `newSessionProject()` and hits `if (!conn || !project) return`. + Primary home CTA did nothing, no error. Hit live 2026-07-08 (web UI on a scratch dir). + - FIX (packages/app/src/pages/home.tsx, `startWithPrompt` only): when no project is + tracked, fall back to the focused server's own working directory — + `focusedSync().data.path.directory` (synced from GET /path; "" until loaded, so the + falsy guard holds) — open+touch it as a project (self-heals the home page), then + `tabs.newDraft` with the prompt preserved. Deliberately NOT `sync.data.project`: + the server's "global" project record has worktree "/". + - Regression spec: packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts — + fresh profile (NO localStorage seed), mocked server, click the CTA (`exact: true` — + the whole card is also a button whose accessible name contains "Open chat"), expect + navigation to `/new-session?draftId=` + the cwd persisted as a tracked project. + Verified failing on the unfixed code, passing with the fix. Playwright note: config + reuses any server on port 3000 (`reuseExistingServer`) — run with `PLAYWRIGHT_PORT=` + if something else (e.g. the harmoniqs website dev server) holds 3000. + - Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail. diff --git a/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts new file mode 100644 index 0000000000..39ee2ca9cc --- /dev/null +++ b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +// Regression: on a fresh profile (no tracked projects in localStorage) the +// home "Open chat" CTA dead-ended silently — startWithPrompt fell through to +// openNewSession(), which needs the same newSessionProject() that just came +// back empty. It must instead fall back to the server's own working +// directory (GET /path → .directory) and start a draft there, tracking the +// directory as a project so the rest of the home page works from then on. +test("home 'Open chat' falls back to the server cwd on a fresh profile", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: [], + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + + // Deliberately NO localStorage seed — an empty tracked-project list is the + // regression condition (contrast: session-list-path-loading.spec.ts seeds it). + await page.goto("/") + // exact: true — the whole Meet-Amico card is also a button whose accessible + // name contains "Open chat"; we want the CTA inside it. + await page.getByRole("button", { name: "Open chat", exact: true }).click() + + // Navigates to a new-session draft instead of doing nothing. + await expect(page).toHaveURL(/\/new-session\?draftId=/) + + // And the server cwd is now a tracked project (the self-healing part). + const persisted = await page.evaluate(() => localStorage.getItem("opencode.global.dat:server") ?? "") + expect(persisted).toContain(fixture.directory) +}) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index aa0d177af6..cb199904d3 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -341,11 +341,23 @@ function HomeDesign() { const [sessionsExpanded, setSessionsExpanded] = createSignal(false) function startWithPrompt(prompt: string) { const project = newSessionProject() - if (!project) { - openNewSession() + if (project) { + tabs.newDraft({ server: server.key, directory: project.worktree }, prompt) return } - tabs.newDraft({ server: server.key, directory: project.worktree }, prompt) + // No tracked projects (fresh profile against a bare `opencode serve`): + // openNewSession() would dead-end silently here — it needs the same + // newSessionProject() that just came back empty. Fall back to the server's + // own working directory (path.directory, synced from GET /path; "" until + // loaded) and start tracking it, so the home CTAs work on first visit. + // Deliberately NOT sync.data.project: its "global" record has worktree "/". + const conn = focusedServer() + const directory = focusedSync().data.path.directory + if (!conn || !directory) return + const ctx = global.createServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + tabs.newDraft({ server: ServerConnection.key(conn), directory }, prompt) } function setSelection(next: HomeProjectSelection) { From 5b4bc4d2bb74e95b7304632046c7aa1b3678adbc Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 21:15:22 -0400 Subject: [PATCH 05/17] =?UTF-8?q?feat(onboarding):=20library=20step=20in?= =?UTF-8?q?=20the=20wizard=20=E2=80=94=20'Teach=20Amico=20your=20work'=20(?= =?UTF-8?q?upload=20PDFs=20between=20about-you=20and=20the=20finish;=20?= =?UTF-8?q?=E2=9C=93-list=20of=20uploads,=20continue-without-papers=20path?= =?UTF-8?q?;=20step=20skipped=20when=20upload=20isn't=20wired);=20home=20L?= =?UTF-8?q?ibrary=20card=20stays;=20shared=20fileToBase64=20util?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 1 + packages/ui/src/amicode/home-cards.tsx | 7 +- packages/ui/src/amicode/onboarding-wizard.tsx | 98 ++++++++++++++++++- packages/ui/src/amicode/upload.ts | 12 +++ 4 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/amicode/upload.ts diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 073423e633..5e67da69ff 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -714,6 +714,7 @@ function HomeDesign() { return v?.ok ? v.you.name : "" })()} onComplete={saveProfileFields} + onUploadPaper={uploadPaper} onDismiss={dismissWizard} onOpenChat={() => { dismissWizard() diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 2b53796fba..b9bd7b57fe 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -6,6 +6,7 @@ import { resolveBrandLogo, type InstitutionSuggestion, } from "./institution-lookup" +import { fileToBase64 } from "./upload" // AMICODE: home-screen card strip (the "central screen" Aaron wanted the H-bot // and useful practitioner info on). Two identity heroes — MEET AMICO (who your @@ -1020,11 +1021,7 @@ function LibraryCard(props: { setError(undefined) try { for (const file of Array.from(files)) { - const buf = new Uint8Array(await file.arrayBuffer()) - let bin = "" - const CHUNK = 0x8000 - for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)) - await props.onUploadPaper(file.name, btoa(bin)) + await props.onUploadPaper(file.name, await fileToBase64(file)) } } catch { setError("Upload failed — is the server up?") diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx index d0738b69ae..a05943c730 100644 --- a/packages/ui/src/amicode/onboarding-wizard.tsx +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -6,6 +6,7 @@ import { resolveBrandLogo, type InstitutionSuggestion, } from "./institution-lookup" +import { fileToBase64 } from "./upload" // AMICODE: first-run onboarding wizard — the dedicated welcome UI (session // zero, visual edition). Three steps: brand welcome → about-you (name, focus, @@ -55,10 +56,34 @@ const LABEL: Record = { export function AmicodeOnboardingWizard(props: { initialName?: string onComplete: (fields: WizardFields) => Promise + /** Optional: enables the library step (papers that make Amico smarter). */ + onUploadPaper?: (filename: string, dataB64: string) => Promise onDismiss: () => void onOpenChat: () => void }) { - const [step, setStep] = createSignal<0 | 1 | 2>(0) + const [step, setStep] = createSignal<0 | 1 | 2 | 3>(0) + const FINAL = 3 + // library step state (skipped entirely when onUploadPaper isn't wired) + const [uploaded, setUploaded] = createSignal([]) + const [uploadBusy, setUploadBusy] = createSignal(false) + const [uploadError, setUploadError] = createSignal(undefined) + let paperInput: HTMLInputElement | undefined + const uploadPapers = async (files: FileList | null) => { + if (!files || files.length === 0 || !props.onUploadPaper) return + setUploadBusy(true) + setUploadError(undefined) + try { + for (const file of Array.from(files)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + setUploaded([...uploaded(), file.name]) + } + } catch { + setUploadError("Upload failed — PDFs only, up to 30MB.") + } finally { + setUploadBusy(false) + if (paperInput) paperInput.value = "" + } + } const [fields, setFields] = createSignal({ name: props.initialName ?? "", affiliation: "", @@ -108,7 +133,7 @@ export function AmicodeOnboardingWizard(props: { setSaveError(undefined) try { await props.onComplete(fields()) - setStep(2) + setStep(props.onUploadPaper ? 2 : FINAL) } catch { setSaveError("Couldn't save — server unreachable. Try again.") } finally { @@ -118,7 +143,7 @@ export function AmicodeOnboardingWizard(props: { const Dots = () => (
- + {(i) => ( - {/* step 2 — done: the profile as the home page will show it */} - + {/* step 2 — library: papers that make Amico smarter (optional) */} + +
+
+
+ Teach Amico your work +
+
+ Upload papers (PDFs) — Amico reads them to learn your methods and results. You can add more anytime from + the Library card on the home page. +
+
+ void uploadPapers(e.currentTarget.files)} + /> + 0}> +
+ + {(name) => ( +
+ + + {name} + +
+ )} +
+
+
+ +
{uploadError()}
+
+
+ 0 ? "Add another PDF" : "Upload a PDF"} + disabled={uploadBusy()} + onClick={() => paperInput?.click()} + /> + 0 ? "Continue" : "Continue without papers"} + disabled={uploadBusy()} + onClick={() => setStep(FINAL)} + /> + + setStep(1)} /> + +
+
+
+ + {/* final step — done: the profile as the home page will show it */} +
{ + const buf = new Uint8Array(await file.arrayBuffer()) + let bin = "" + const CHUNK = 0x8000 + for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)) + return btoa(bin) +} From 73d45a1c9f55f294ee96e0940f7c40e211f1ce3e Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 21:21:02 -0400 Subject: [PATCH 06/17] =?UTF-8?q?fix(onboarding):=20wizard=20finale=20land?= =?UTF-8?q?s=20on=20the=20HOME=20page=20(primary),=20chat=20demoted=20to?= =?UTF-8?q?=20the=20quiet=20secondary=20=E2=80=94=20the=20autofilled=20hom?= =?UTF-8?q?e=20is=20the=20payoff=20shot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/onboarding-wizard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx index a05943c730..bf36a0bdbc 100644 --- a/packages/ui/src/amicode/onboarding-wizard.tsx +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -533,8 +533,8 @@ export function AmicodeOnboardingWizard(props: { You're set. Amico will remember this — say hi and design your first pulse.
- props.onOpenChat()} /> - props.onDismiss()} /> + props.onDismiss()} /> + props.onOpenChat()} />
From 5e278815c58771d06208a19d91d900c9d6389f31 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 8 Jul 2026 23:14:45 -0400 Subject: [PATCH 07/17] Consolidate the fork brand mark to one geometry, synced to amicode PR #99 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork rendered the old "digi" pixel-H mark in four independent copies while amicode redesigned its mark (PR #99). This unifies them to a single MARK_PATH and syncs the geometry to #99's FINAL square mark (viewBox 0 0 3600 3600) — not the intermediate "hackathon" geometry an earlier draft of this branch had copied, which #99 itself later abandoned. - logo.tsx: MARK_PATH = amico_reduced.svg's outer-bracket path (fill-rule evenodd). Mark/Splash render it; viewBox 64x56 → 3600 square. MarkDetailed = amico.svg's full mark (bracket + internal accents), viewBox 116 287 3377 3035 → 3600 square, for the Meet Amico card only. - spinner.tsx (AmicoSpinner) + run-card.tsx (share-card SVG) both now import MARK_PATH instead of carrying their own copies — run-card was a fourth private copy of the old glyph; its transform is recalibrated (scale 0.55 → 0.011) for the 3600-unit space. - favicon/amico.svg: reduced bracket on the yellow chip, viewBox 3600, path kept byte-identical to MARK_PATH. - logo.css: aspect-ratio 8/7 → 1/1 (mark is square now). - Small contexts use the reduced mark, the Meet Amico card uses the detailed one — matching amicode's own small/large split. Rebased onto local/amicode (picks up PR #8; AMICODE-PATCHES.md conflict resolved, entry 15 rewritten for the final geometry). Checks: bun turbo typecheck (ui+app) green; ui bun test src 95 pass; app vite build clean — new mark path in the bundle, old geometry and the 64x56 viewBox gone (0 chunks), favicon carries the new path. Co-Authored-By: Claude Fable 5 --- AMICODE-PATCHES.md | 13 +++ packages/ui/src/amicode/home-cards.tsx | 4 +- packages/ui/src/amicode/run-card.tsx | 9 +- packages/ui/src/amicode/spinner.tsx | 11 ++- packages/ui/src/assets/favicon/amico.svg | 28 ++----- packages/ui/src/components/logo.css | 2 +- packages/ui/src/components/logo.tsx | 102 +++++++++++++++-------- 7 files changed, 104 insertions(+), 65 deletions(-) diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index 0834bb663e..0a839a745e 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -245,3 +245,16 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. reuses any server on port 3000 (`reuseExistingServer`) — run with `PLAYWRIGHT_PORT=` if something else (e.g. the harmoniqs website dev server) holds 3000. - Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail. + +15. (mark drift fix, synced to amicode PR #99 final) — amicode: consolidated the fork's brand mark to ONE geometry, matching amicode's redesigned mark. The "kept in sync manually" cross-repo promise from patch #8 had already silently failed. + +- Trigger: amicode's mark was redesigned (PR #99) without a corresponding update here — the fork still rendered the OLD "digi" pixel-accented H-robot everywhere. PR #99 went through several iterations before landing on its final geometry; this entry tracks that FINAL state (square viewBox `0 0 3600 3600`), not the intermediate "hackathon mark" (viewBox `116 287 3377 3035`) an earlier draft of this fork PR had copied — that intermediate geometry is now itself stale and was replaced here. +- Two copies of near-identical geometry lived in THIS repo (logo.tsx's `Robot` used by `Mark`/`Splash`, and spinner.tsx's `AmicoSpinner`), plus a third in favicon/amico.svg. Consolidated to a single `MARK_PATH` exported from logo.tsx. +- Geometry now mirrors amicode PR #99's two authored SVGs (amicode:`packages/extension/media/amico{,_reduced}.svg`), both square `0 0 3600 3600`: + - `MARK_PATH` = amico_reduced.svg's outer-bracket path (fill-rule evenodd screen knockout). Used by `Mark`, `Splash`, `AmicoSpinner`, and mirrored as a literal in favicon/amico.svg — every SMALL context, matching amicode's own "small → reduced" rule. + - `MarkDetailed` = amico.svg's full detailed mark (bracket path + internal circuit-pattern rects/polygons). Used ONLY by the Meet Amico home card (`w-12`/48px), large enough for the detail to resolve. + - `Mark`/`Splash`/`MarkDetailed` viewBox `0 0 64 56`/`116 287 3377 3035` → `0 0 3600 3600`; logo.css aspect-ratio `8/7` → `1/1` (the mark is square now). +- Still theme-adaptive via currentColor + var(--icon-strong-base) — this is a live webview DOM, so currentColor resolves (unlike amicode's native VS Code tab icon, which needs committed {light,dark} files; see amicode PR #99). +- NOT a re-established cross-repo sync promise — `MARK_PATH` is the single source of truth WITHIN this repo; it happens to match amicode's current geometry, kept aligned by hand when the mark changes. +- Tests: ui `bun test src/amicode` green; typecheck green ui + app. +- NOT done this round: full native `bun run script/build.ts` compile + vendored-binary swap — this patch only touches the embedded web UI. Deferred to the next amicode.N release tag, same split as patches #8/#11. diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index b9bd7b57fe..6008021f82 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,5 +1,5 @@ import { For, Show, createEffect, createMemo, on, type JSX, createSignal, onCleanup } from "solid-js" -import { Mark } from "../components/logo" +import { MarkDetailed } from "../components/logo" import { institutionLogoUrl, suggestInstitutions, @@ -244,7 +244,7 @@ function MeetAmicoCard(props: { onStart: (prompt: string) => void }) { data-slot="amicode-meet-identity" style={{ display: "flex", gap: "12px", "align-items": "center", "margin-top": "10px" }} > - +
Amico
diff --git a/packages/ui/src/amicode/run-card.tsx b/packages/ui/src/amicode/run-card.tsx index 38f74daa56..d7e9c22f82 100644 --- a/packages/ui/src/amicode/run-card.tsx +++ b/packages/ui/src/amicode/run-card.tsx @@ -6,6 +6,12 @@ // trading card, not UI panel. Pure string-SVG so the same markup drives both // the in-app gallery (innerHTML) and the PNG export (SVG → Image → canvas). +// The mark geometry lives in ONE place — logo.tsx's MARK_PATH (a plain string +// const). Importing it here keeps this card from drifting into a private copy +// of the glyph, which is what this consolidation fixes. The mark is square +// (viewBox 0 0 3600 3600). +import { MARK_PATH } from "../components/logo" + export type RunCardData = { slug: string problem: string @@ -129,14 +135,13 @@ export function renderRunCardSvg(d: RunCardData): string { } } - const MARK = "M2 2h16v14h28V2h16v52H46V40H18v14H2Z M9 19h46v18H9Z" const mono = "ui-monospace, SFMono-Regular, Menlo, monospace" const sans = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" return ` - + AMICODE SOLVED PULSE diff --git a/packages/ui/src/amicode/spinner.tsx b/packages/ui/src/amicode/spinner.tsx index eac03cab83..a836b2d1f1 100644 --- a/packages/ui/src/amicode/spinner.tsx +++ b/packages/ui/src/amicode/spinner.tsx @@ -1,4 +1,5 @@ import { type ComponentProps } from "solid-js" +import { MARK_PATH } from "../components/logo" // AMICODE: working/thinking spinner — the Harmoniqs H-robot silhouette as a // small monochrome glyph (H body with the screen slit knocked out via @@ -10,6 +11,12 @@ import { type ComponentProps } from "solid-js" // matches the stock spinner's own animation language. prefers-reduced-motion: // static glyph, no animation (matchMedia guard; inline animations don't // inherit the CSS-file media-query pattern used elsewhere). +// +// Path comes from logo.tsx's MARK_PATH (Mark/Splash now render the same +// glyph) instead of its own copy — this file used to carry an independent +// literal that happened to already match Mark/Splash's silhouette variant; +// consolidated so there's one geometry in this repo, not a second one that +// could quietly drift the way Mark/Splash's OWN old copy did. const reducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches @@ -22,7 +29,7 @@ export function AmicoSpinner(props: { return ( - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/packages/ui/src/components/logo.css b/packages/ui/src/components/logo.css index 6891ec3d64..1eaa2eba8d 100644 --- a/packages/ui/src/components/logo.css +++ b/packages/ui/src/components/logo.css @@ -1,4 +1,4 @@ [data-component="logo-mark"] { width: 16px; - aspect-ratio: 8/7; + aspect-ratio: 1/1; /* mark viewBox is square (0 0 3600 3600) */ } diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 8250cdad1c..f0d1b7b5a5 100644 --- a/packages/ui/src/components/logo.tsx +++ b/packages/ui/src/components/logo.tsx @@ -1,48 +1,35 @@ import { type ComponentProps } from "solid-js" -// AMICODE branding v2: Mark/Splash render the "digi" Harmoniqs H-robot -// (canonical source also lives at amicode:packages/extension/media/amico.svg, -// kept in sync manually); Logo renders the AMICODE wordmark. Component names, -// props, and data-component hooks are kept identical to stock. The robot body -// follows currentColor; the display rect + glyphs are fixed brand colors. -// viewBox is 64:56 (8:7, not square) — see logo.css aspect-ratio. +// AMICODE branding: Mark/Splash render the Harmoniqs H-robot mark; Logo +// renders the AMICODE wordmark. Component names, props, and data-component +// hooks are kept identical to stock. The mark is square (viewBox 0 0 3600 +// 3600) — see logo.css aspect-ratio 1/1. +// +// Geometry mirrors amicode PR #99's two authored SVGs (amicode: +// packages/extension/media/amico{,_reduced}.svg), both square 0 0 3600 3600: +// MARK_PATH → amico_reduced.svg — the outer bracket (fill-rule evenodd +// knocks out the screen). The SMALL-size mark: Mark, Splash, +// AmicoSpinner (../amicode/spinner.tsx, imports MARK_PATH), +// and favicon/amico.svg (mirrors it as a literal — a static +// SVG can't import a TS module; keep its +// byte-identical to this constant). +// MarkDetailed → amico.svg — the full mark WITH the internal circuit-pattern +// accents, for LARGE contexts only (the Meet Amico card). +// This matches amicode's own "small → reduced, large → detailed" split, so +// the fork and the extension render the same brand mark. MARK_PATH is the +// single source of truth for the glyph within this repo; it is kept aligned +// with amicode's geometry by hand when the mark changes (no build-time link). +export const MARK_PATH = + "M2279.19,374.09v622.56h-958.38V374.09H202.07v2851.83h1118.74v-520.15h958.38v520.15h1118.74V374.09h-1118.74ZM3165.55,2523.71H478.91v-1338.38h2686.65v1338.38Z" -const Robot = () => ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - -) +const Robot = () => export const Mark = (props: { class?: string }) => { return ( @@ -57,7 +44,7 @@ export const Splash = (props: Pick, "ref" | "class">) => { ref={props.ref} data-component="logo-splash" classList={{ [props.class ?? ""]: !!props.class }} - viewBox="0 0 64 56" + viewBox="0 0 3600 3600" xmlns="http://www.w3.org/2000/svg" style={{ color: "var(--icon-strong-base)" }} > @@ -66,6 +53,47 @@ export const Splash = (props: Pick, "ref" | "class">) => { ) } +// Detailed mark (amicode PR #99's amico.svg) — the full H-robot WITH the +// internal circuit-pattern accents, used ONLY where it renders large enough to +// resolve (the Meet Amico card, ~48px). currentColor + var(--icon-strong-base), +// same convention as Mark/Splash, so it stays theme-adaptive here — unlike +// amicode's native VS Code chat-tab icon, which needs committed light/dark SVG +// files because a native tab icon has no live CSS context for currentColor. +// Geometry mirrors amicode:packages/extension/media/amico.svg (viewBox +// 0 0 3600 3600); keep in sync by hand if that mark changes. +export const MarkDetailed = (props: { class?: string }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + export const Logo = (props: { class?: string }) => { return ( Date: Wed, 8 Jul 2026 23:29:25 -0400 Subject: [PATCH 08/17] Use the detailed mark in the chat landing and onboarding wizard hero The reduced bracket is right for small chrome (titlebar, footer, tiny fallbacks), but the large brand-hero contexts should show the full mark. Switch to MarkDetailed at: - the new-session / main chat landing hero (both the v2 design view, 144px, and the classic view, 112px) - the onboarding wizard welcome step (56px) The wizard's tiny 36px affiliation-logo fallback stays on the reduced Mark (below where the internal accents resolve). Checks: typecheck (ui+app) green; ui bun test src 99 pass; app build clean with the detailed-mark accents present in the bundle. Co-Authored-By: Claude Fable 5 --- .../app/src/components/session/session-new-design-view.tsx | 5 +++-- packages/app/src/components/session/session-new-view.tsx | 4 ++-- packages/ui/src/amicode/onboarding-wizard.tsx | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx index 5cb67750e6..aa9bbb621c 100644 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -1,17 +1,18 @@ import { Show, type JSX } from "solid-js" -import { Logo, Mark } from "@opencode-ai/ui/logo" +import { Logo, MarkDetailed } from "@opencode-ai/ui/logo" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" // amicode: new-session start screen, top→bottom — H-bot mark (hero), the AMICODE // wordmark, tagline + how-it-works + starter chips, then the composer. Sizing // mirrors the classic NewSessionView (Mark w-36 / Logo w-72); keeps the session // tabs and centered composer. The chips are the one-tap path to the next task. +// The hero uses the DETAILED mark (144px, well above where the accents resolve). export function NewSessionDesignView(props: { children: JSX.Element; gettingStarted?: JSX.Element }) { return (
- +
{props.gettingStarted}
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index b70f8fb9ab..b0df2b278d 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -10,7 +10,7 @@ import { amicodeGet } from "@/utils/amicode-fetch" import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" import { Icon } from "@opencode-ai/ui/icon" import { AmicodeGettingStarted } from "@opencode-ai/ui/amicode-getting-started" -import { Mark } from "@opencode-ai/ui/logo" +import { MarkDetailed } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" const MAIN_WORKTREE = "main" @@ -78,7 +78,7 @@ export function NewSessionView(props: NewSessionViewProps) {
- + {/* amicode: straight wordmark (the Logo's Racing Sans One face reads as italic); byline keeps the brand attribution. */}
- +
Welcome to Amicode From ddfd46977bee1525523236f68efa1271425b8526 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 06:40:58 +0000 Subject: [PATCH 09/17] feat(amicode): add "Inspect Run" button to the entity rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Inspect Run" button on the amicode problem-header rail, beside the live run chip, that opens the VS Code Run Inspector on demand instead of relying on auto-launch. It fires the existing host bridge — postAmicode("amicode.openInspector") — which chat_panel.ts relays to the already-allowlisted vscode command; no new bridge, allowlist, or route. - entity-rail.tsx: new optional onInspectRun prop + button, gated on a run existing (hasRun) so it shows alongside the run chip, not before any solve. packages/ui stays bridge-agnostic (fires a callback only). - message-timeline.tsx: wires onInspectRun to the bridge, passed only when framed in Amicode (inAmicode()), so the public web/share build shows nothing. - use-amicode-commands.tsx: export postAmicode + inAmicode for reuse. Pairs with harmoniqs/amicode#116 (stops the inspector auto-launching); reaches amicode users after this is re-vendored into the extension binary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/session/message-timeline.tsx | 2 ++ .../pages/session/use-amicode-commands.tsx | 7 ++-- packages/ui/src/amicode/entity-rail.tsx | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c6329b39c5..5385dd6c7a 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -73,6 +73,7 @@ import { useServer } from "@/context/server" import { usePrompt } from "@/context/prompt" import { startPrompt, draftPrompt } from "@/utils/start-prompt" import { amicodeGet } from "@/utils/amicode-fetch" +import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands" import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { messageAgentColor } from "@/utils/agent" @@ -1697,6 +1698,7 @@ export function MessageTimeline(props: { } onOpenEntity={openEntityView} onOpenSwitcher={openSwitcher} + onInspectRun={inAmicode() ? () => postAmicode("amicode.openInspector") : undefined} retryLabel={language.t("amicode.retry")} unavailableLabel={language.t("amicode.unavailable")} onAsk={(text) => { diff --git a/packages/app/src/pages/session/use-amicode-commands.tsx b/packages/app/src/pages/session/use-amicode-commands.tsx index ebeee0f065..b9a072be9d 100644 --- a/packages/app/src/pages/session/use-amicode-commands.tsx +++ b/packages/app/src/pages/session/use-amicode-commands.tsx @@ -10,9 +10,12 @@ import { useCommand, type CommandOption } from "@/context/command" // Each command posts {source:"amicode",kind:"command",command} to window.parent; // chat_panel.ts relays it to an ALLOWLISTED vscode command. -const inAmicode = () => typeof window !== "undefined" && window.self !== window.top +// Exported so non-palette surfaces (e.g. the "Inspect Run" button on the entity +// rail) can fire the same host-bridged commands. inAmicode() gates them out of +// the public web/share build, where there is no extension host to relay to. +export const inAmicode = () => typeof window !== "undefined" && window.self !== window.top -const postAmicode = (command: string) => { +export const postAmicode = (command: string) => { try { window.parent?.postMessage({ source: "amicode", kind: "command", command }, "*") } catch {} diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c3c4e2b86a..3d82be9ad1 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -49,6 +49,10 @@ export function AmicodeEntityRail(props: { onOpenEntity: (kind: string, seq?: number) => void onOpenSwitcher: () => void onAsk?: (text: string) => void + // Bridge-agnostic: fired when the user clicks "Inspect Run". The app wires it + // to the host (postAmicode → amicode.openInspector) and passes it only when + // framed in Amicode, so the button stays hidden everywhere else. + onInspectRun?: () => void retryLabel: string unavailableLabel: string }) { @@ -134,6 +138,12 @@ export function AmicodeEntityRail(props: { if (snapshot.kind !== "ready") return undefined return snapshot.view.name ?? snapshot.view.slug }) + // Whether there is a run to inspect — gates the "Inspect Run" button so it + // appears alongside the live run chip, not before any solve has started. + const hasRun = createMemo(() => { + const snapshot = state() + return snapshot.kind === "ready" && snapshot.view.runs.length > 0 + }) return ( 0}> @@ -264,6 +274,30 @@ export function AmicodeEntityRail(props: { )} + + +
From ac8246e97c029f61ce33858fe18bf14844d34663 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 06:41:42 +0000 Subject: [PATCH 10/17] Fix chat scroll jitter during streaming; respect reduced motion The message timeline had three independent systems writing scrollTop to the bottom on the same content update, reading scroll geometry at slightly different instants from a virtualizer whose item heights are estimates. When virtua corrected a measured height a frame later, the writers had already landed on slightly different positions -> visible bounce. - session.tsx: gate createAutoScroll on the session's real working state instead of a hard-coded `true`. The auto-scroller's ResizeObserver was force-following the bottom on *any* reflow (image load, accordion expand, font swap), which read as the chat jumping on its own. Send-to-bottom and the jump button are unaffected (they use the force path). - message-timeline.tsx: only realign via virtua's estimate-based scrollToIndex(align:"end") when the row set changes or status flips, not on every streamed token. Pure intra-row growth is left to the measured-bottom rAF lock, which pins against the real DOM height, so the two mechanisms stop disagreeing frame-to-frame. Safe because timelineRowKeys is memoized with `equals: sameKeys`. - message-part.tsx: honor prefers-reduced-motion in the imperative ShellSubmessage reveal (the CSS already does; this JS animate() did not, and its initial render collapses width to 0 / blurs the value). Verified: app + ui typecheck clean; message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass (16/16). Perceptual smoothness during live streaming still wants a visual pass against a real model. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/pages/session.tsx | 11 ++++++++++- .../app/src/pages/session/message-timeline.tsx | 15 ++++++++++++--- packages/ui/src/components/message-part.tsx | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dda77aaff0..f5949620a1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1200,8 +1200,17 @@ export default function Page() { ), ) + // Only follow the bottom while the model is actually streaming. This was + // previously hard-coded to `true`, so the auto-scroller's ResizeObserver + // force-scrolled on *any* reflow (image load, tool accordion expand, font + // swap, layout settle) — which read as the chat "jumping" on its own. Gating + // it on the session's real working state also stops it from competing with + // the timeline's own bottom-lock outside of streaming. const autoScroll = createAutoScroll({ - working: () => true, + working: () => { + const id = params.id + return id ? sync.data.session_working(id) : false + }, overflowAnchor: "dynamic", }) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c6329b39c5..fa2325a935 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -568,12 +568,21 @@ export function MessageTimeline(props: { createEffect( on( () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, - () => { + (curr, prev) => { if (!virtualizer) return if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return - const keys = timelineRowKeys() + const keys = curr[0] if (keys.length === 0) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) + // Only realign via virtua (an estimate-based scroll that can visibly + // pre-jump before item heights are measured) when the *set of rows* + // changes or the session status flips. Pure intra-row growth while a + // token streams is left to the measured-bottom rAF lock below, which + // pins against the real DOM height — so the two mechanisms stop landing + // on slightly different scroll positions frame-to-frame. `timelineRowKeys` + // is memoized with `equals: sameKeys`, so its reference only changes when + // the row set actually changes, making this comparison cheap and exact. + const rowsChanged = !prev || prev[0] !== keys || prev[2] !== curr[2] + if (rowsChanged) virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) scheduleMeasuredBottomAnchor() }, { defer: true }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 71b1fa8078..cd4d427050 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -60,6 +60,9 @@ import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +const reducedMotion = () => + typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + async function writeClipboard(text: string): Promise { const body = typeof document === "undefined" ? undefined : document.body if (body) { @@ -90,6 +93,17 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) { onMount(() => { if (!props.animate) return + // The initial render collapses width to 0 and hides the value behind a blur; + // if the user prefers reduced motion, snap straight to the resting state + // instead of animating (and instead of leaving it stuck collapsed/hidden). + if (reducedMotion()) { + if (widthRef) widthRef.style.width = "auto" + if (valueRef) { + valueRef.style.opacity = "1" + valueRef.style.filter = "blur(0px)" + } + return + } requestAnimationFrame(() => { if (widthRef) { animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) From 4b1922208ca863b16771b9f45c401f10a8977ba3 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 06:56:10 +0000 Subject: [PATCH 11/17] fix(amicode): remove the yellow accent rail from card surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3px accent-colored border-left (and the onboarding wizard's 4px accent border-top) read as a heavy yellow outline on every amicode card. Drop the rail from the home dashboard hero cards, the in-chat receipt/entity/ask/run cards, the problem rail, and the onboarding wizard — each keeps its neutral 1px hairline. The H-mark, live dots, hover tints, and focus outlines (keyboard a11y) are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/amicode/ask-card.tsx | 1 - packages/ui/src/amicode/card.tsx | 1 - packages/ui/src/amicode/entity-rail.tsx | 1 - packages/ui/src/amicode/entity-view.tsx | 1 - packages/ui/src/amicode/home-cards.tsx | 2 +- packages/ui/src/amicode/onboarding-wizard.tsx | 1 - packages/ui/src/amicode/run-window.tsx | 1 - 7 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/ui/src/amicode/ask-card.tsx b/packages/ui/src/amicode/ask-card.tsx index 501379d6d0..abb189dcbc 100644 --- a/packages/ui/src/amicode/ask-card.tsx +++ b/packages/ui/src/amicode/ask-card.tsx @@ -44,7 +44,6 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string; sessi gap: "8px", "min-width": "0", border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", "border-radius": "6px", background: "var(--v2-background-bg-layer-01)", padding: "8px 12px", diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index c392996a2f..372e569a50 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -51,7 +51,6 @@ function Chip(props: { tool: string; status?: string; output?: string }) { gap: "8px", "min-width": "0", border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", "border-radius": "6px", background: "var(--v2-background-bg-layer-01)", padding: "4px 12px", diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c3c4e2b86a..1fd7f1f78c 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -148,7 +148,6 @@ export function AmicodeEntityRail(props: { "max-height": "76px", "overflow-y": "auto", border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", "border-radius": "6px", background: "var(--v2-background-bg-layer-01)", padding: "6px 10px", diff --git a/packages/ui/src/amicode/entity-view.tsx b/packages/ui/src/amicode/entity-view.tsx index 9b1abfe2a4..a5fcb93e26 100644 --- a/packages/ui/src/amicode/entity-view.tsx +++ b/packages/ui/src/amicode/entity-view.tsx @@ -56,7 +56,6 @@ export function AmicodeEntityView(props: { class="flex flex-col gap-3 py-2 pl-4 pr-3" data-component="amicode-entity-view" data-kind={props.kind} - style={{ "border-left": "3px solid var(--v2-icon-icon-accent)" }} > Date: Thu, 9 Jul 2026 07:05:50 +0000 Subject: [PATCH 12/17] test(amicode): cover the Inspect Run bridge envelope Locks the contract the entity-rail "Inspect Run" button depends on: postAmicode posts exactly {source:"amicode", kind:"command", command:"amicode.openInspector"} (the command chat_panel.ts allowlists), never throws if the parent frame rejects the post, and inAmicode() reports false when unframed so the button stays hidden on the public web build. Solid components can't be DOM-rendered under this repo's bun test harness, so this covers the button's observable contract at the bridge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../session/use-amicode-commands.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/app/src/pages/session/use-amicode-commands.test.ts diff --git a/packages/app/src/pages/session/use-amicode-commands.test.ts b/packages/app/src/pages/session/use-amicode-commands.test.ts new file mode 100644 index 0000000000..602ba8d10c --- /dev/null +++ b/packages/app/src/pages/session/use-amicode-commands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { inAmicode, postAmicode } from "./use-amicode-commands" + +// The "Inspect Run" button (entity rail) and the Amico command palette both +// reach the VS Code extension through postAmicode(). chat_panel.ts relays the +// envelope to the host and executes ONLY commands on its BRIDGE_ALLOWED_COMMANDS +// allowlist — so the exact envelope shape and command string are a contract. +describe("postAmicode bridge envelope", () => { + test('posts {source:"amicode", kind:"command", command} to window.parent with "*"', () => { + const spy = spyOn(window.parent, "postMessage").mockImplementation(() => {}) + try { + postAmicode("amicode.openInspector") + expect(spy).toHaveBeenCalledTimes(1) + // Cast past the DOM postMessage overloads (which type arg 2 as + // WindowPostMessageOptions) — postAmicode passes a legacy string origin. + const [message, targetOrigin] = spy.mock.calls[0] as unknown as [unknown, unknown] + expect(message).toEqual({ source: "amicode", kind: "command", command: "amicode.openInspector" }) + // Post to any origin — chat_panel.ts pins the origin on the receiving side. + expect(targetOrigin).toBe("*") + } finally { + spy.mockRestore() + } + }) + + test("never throws even if the parent frame rejects the post", () => { + const spy = spyOn(window.parent, "postMessage").mockImplementation(() => { + throw new Error("no parent") + }) + try { + expect(() => postAmicode("amicode.openInspector")).not.toThrow() + } finally { + spy.mockRestore() + } + }) +}) + +// inAmicode() gates the button (and the palette ops) so they never render in the +// public web / share build, where there is no extension host to relay to. In a +// non-framed context self === top, so it must report false. +describe("inAmicode gate", () => { + test("false when not framed (self === top)", () => { + expect(inAmicode()).toBe(false) + }) +}) From d9cfccab2f304c7f9b1ad8d28518ff417066bd0e Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 07:13:59 +0000 Subject: [PATCH 13/17] feat(amicode): redesign the receipt card + entity view Fold the full AMICO receipt redesign into the rail removal: instead of just dropping the accent bar, give the whole family one visual language. - receipt card (card.tsx): the H-mark doubles as the working spinner, diffs render structured (dimmed old -> new) instead of a run-on string, a green check settles the done state, failure tints the card red, and it is a real + ) } diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 1fd7f1f78c..3af14de190 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -1,6 +1,7 @@ import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" import { hasUserReplyAfter } from "./ask" import { registerAmicodeAskBridge } from "./ask-bridge" +import { AmicoMark } from "./spinner" import { registerAmicodeUiBridge } from "./ui-bridge" import { type ProblemView, @@ -147,24 +148,15 @@ export function AmicodeEntityRail(props: { "min-width": "0", "max-height": "76px", "overflow-y": "auto", - border: "1px solid var(--v2-border-border-base)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "6px 10px", "font-size": "11px", "line-height": "16px", "white-space": "nowrap", }} > - - AMICO + + + AMICO = { "\\hat H = \\tfrac{\\Omega(t)}{2}\\sum_i \\sigma_x^{(i)} - \\Delta(t)\\sum_i \\hat n_i + \\sum_{i, +): DiffPiece[] { + const { changes } = receiptParts({ problem: "", entity, action, diff: diff ?? {} }) + return changes.map((change) => + change.kind === "elision" + ? { key: "…" } + : change.kind === "set" + ? { key: humanizeKey(change.key), to: change.to } + : { key: humanizeKey(change.key), from: change.from, to: change.to }, + ) +} + export function AmicodeEntityView(props: { view: ProblemView | undefined // undefined → loading skeleton kind: string @@ -37,6 +64,41 @@ export function AmicodeEntityView(props: { ? props.anchorSeq : undefined, ) + const changedKeys = createMemo(() => { + const seq = anchored() + const set = new Set() + if (seq === undefined) return set + const event = history().find((candidate) => candidate.seq === seq) + if (event?.diff) + for (const key of Object.keys(event.diff)) { + set.add(key) + const bare = key.split(".").pop() + if (bare) set.add(bare) + } + return set + }) + const isChanged = (key: string) => { + const set = changedKeys() + return set.has(key) || set.has(key.split(".").pop() ?? key) + } + // Rows with a group-header flag when a nested group first appears (entityRows + // flattens each object's children contiguously, so a group is one run). + const fieldRows = createMemo(() => { + let prevGroup: string | undefined + return rows().map((row) => { + const group = fieldGroup(row.key) + const showGroupHeader = group !== undefined && group !== prevGroup + prevGroup = group + return { + key: row.key, + value: row.value, + name: humanizeKey(row.key), + groupLabel: group ? humanizeKey(group) : undefined, + showGroupHeader, + } + }) + }) + const latestTs = createMemo(() => history()[0]?.ts) const runTier = createMemo(() => { if (props.kind !== "run" || !props.view) return undefined const refs = props.view.runs @@ -52,174 +114,151 @@ export function AmicodeEntityView(props: { }) return ( -
+
+
+
+
+
+
+
} > {(view) => ( -
- - {view().error} - -
} > - - {(tier) => ( -
- - {tier() === "free" ? "free · unvetted" : tier()} - -
- )} + +
+ + {(tier) => ( + + {tier() === "free" ? "free · unvetted" : tier()} + + )} + + {(ts) => Updated {ts()}} +
+ {(html) => ( -
+
+
Hamiltonian
+
+
)} + 0} - fallback={ -
- — -
- } + when={fieldRows().length > 0} + fallback={
No fields recorded yet.
} > +
Details
- + {(row) => ( -
- - {row.key} - - - {row.value} - - -
+ <> + +
{row.groupLabel}
+
+
+ + {row.name} + {row.key} + + + {row.value} + + +
+ )}
+ 0}> -
- - History - -
- - {(event) => ( -
{ - if (anchored() === event.seq) queueMicrotask(() => el.scrollIntoView({ block: "nearest" })) - }} - > -
- - #{event.seq} - - - {(source) => ( - - {source()} - +
History
+
+ + {(event) => ( +
{ + if (anchored() === event.seq) queueMicrotask(() => el.scrollIntoView({ block: "nearest" })) + }} + > +
+ #{event.seq} + + {(source) => {source()}} + + {(ts) => {ts()}} +
+
+ 0} + fallback={{event.action ? humanizeKey(event.action) : "—"}} + > + + {(piece, index) => ( + <> + 0}> + + + {piece.key} + + {piece.from} + + + + {piece.to} + + )} - - - - {event.ts} - - -
- - {receiptText({ - problem: "", - entity: event.entity, - action: event.action, - diff: (event.diff ?? {}) as Record, - })} - + +
- )} -
-
+
+ )} +
+ +
+ + + Read-only. Changes are made by asking AMICO in chat — ✎ drafts the message for you. + +
)} diff --git a/packages/ui/src/amicode/problem.ts b/packages/ui/src/amicode/problem.ts index eebc7e2ecc..0ae8f64c59 100644 --- a/packages/ui/src/amicode/problem.ts +++ b/packages/ui/src/amicode/problem.ts @@ -277,6 +277,22 @@ export function railState(current: ProblemView | undefined, lastGood: ProblemVie export type EntityRow = { key: string; value: string } +/** Field/label humanizer for the entity view: last dotted segment, underscores + * to spaces, sentence case — `params.drive_max` → "Drive max", `problem_type` + * → "Problem type". Presentation only; the raw key is still shown alongside. */ +export function humanizeKey(key: string): string { + const raw = (key.split(".").pop() ?? key).replaceAll("_", " ").trim() + if (!raw) return key + return raw.charAt(0).toUpperCase() + raw.slice(1) +} + +/** The dotted prefix of a flattened row key (the group it belongs to), or + * undefined for a top-level scalar. `params.drive_max` → "params". */ +export function fieldGroup(key: string): string | undefined { + const dot = key.indexOf(".") + return dot === -1 ? undefined : key.slice(0, dot) +} + /** Stable field rows: scalars in object order, one level of object nesting * flattened to dotted keys, undefined skipped. */ export function entityRows(entity: Record): EntityRow[] { diff --git a/packages/ui/src/amicode/receipt.ts b/packages/ui/src/amicode/receipt.ts index 532f138812..257041dd26 100644 --- a/packages/ui/src/amicode/receipt.ts +++ b/packages/ui/src/amicode/receipt.ts @@ -61,18 +61,39 @@ function short(value: unknown): string { return JSON.stringify(value) } +// A receipt's body, broken into typed pieces so the UI can render each change +// as a discrete unit (dimmed old value · arrow · new value) instead of a flat +// string. `receiptText` below joins these back into the exact one-line form the +// legacy chip and the tests expect — the structured and string renders never +// diverge because they come from the same source. +export type ReceiptChange = + | { kind: "elision" } + | { kind: "set"; key: string; to: string } + | { kind: "change"; key: string; from: string; to: string } +export type ReceiptParts = { label: string; changes: ReceiptChange[]; action: string } + +export function receiptParts(sentinel: DiffSentinel): ReceiptParts { + const changes: ReceiptChange[] = Object.entries(sentinel.diff).map(([key, entry]) => { + if (key === "…") return { kind: "elision" } + const bare = key.split(".").pop() ?? key + if (entry.from === null || entry.from === undefined) return { kind: "set", key: bare, to: short(entry.to) } + return { kind: "change", key: bare, from: short(entry.from), to: short(entry.to) } + }) + return { label: entityLabel(sentinel.entity), changes, action: sentinel.action } +} + /** `System · levels 3→4 · omega 4.8` — dotted diff keys render bare (last * segment); creates (from null) render value-only; the spec-A elision key * `…` renders as a bare ellipsis. Empty diff → the action. One line, always. */ export function receiptText(sentinel: DiffSentinel): string { - const parts = Object.entries(sentinel.diff).map(([key, entry]) => { - if (key === "…") return "…" - const bare = key.split(".").pop() ?? key - if (entry.from === null || entry.from === undefined) return `${bare} ${short(entry.to)}` - return `${bare} ${short(entry.from)}→${short(entry.to)}` + const { label, changes, action } = receiptParts(sentinel) + const parts = changes.map((change) => { + if (change.kind === "elision") return "…" + if (change.kind === "set") return `${change.key} ${change.to}` + return `${change.key} ${change.from}→${change.to}` }) - const body = parts.length > 0 ? parts.join(" · ") : sentinel.action - return `${entityLabel(sentinel.entity)} · ${body}` + const body = parts.length > 0 ? parts.join(" · ") : action + return `${label} · ${body}` } /** For any raw-output display path: drop a trailing sentinel line. NOTE: the diff --git a/packages/ui/src/amicode/run-window.tsx b/packages/ui/src/amicode/run-window.tsx index 52e2bf37e9..c4cda07924 100644 --- a/packages/ui/src/amicode/run-window.tsx +++ b/packages/ui/src/amicode/run-window.tsx @@ -1,4 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { AmicoMark } from "./spinner" import { fetchAmicodeRunSeries, openAmicodeEntity } from "./ui-bridge" import { type RunSeries, type RunSeriesView, elapsedLabel, headlineMetric, parseRunSeriesResponse } from "./run-series" @@ -189,9 +190,6 @@ export function RunWindow(props: { run: string; lab?: string }) { "flex-direction": "column", gap: "6px", "min-width": "0", - border: "1px solid var(--v2-border-border-base)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "8px 12px", "font-size": "12px", "line-height": "16px", @@ -199,9 +197,10 @@ export function RunWindow(props: { run: string; lab?: string }) { }} > {/* header: AMICO · Run · status · iter · metric · elapsed */} -
- - AMICO +
+ + + AMICO · Run diff --git a/packages/ui/src/amicode/spinner.tsx b/packages/ui/src/amicode/spinner.tsx index a836b2d1f1..b807021f66 100644 --- a/packages/ui/src/amicode/spinner.tsx +++ b/packages/ui/src/amicode/spinner.tsx @@ -21,6 +21,25 @@ import { MARK_PATH } from "../components/logo" const reducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches +// Static/animated brand mark for the amicode surfaces (receipt card, entity +// view, rail, ask, run window). Same H-glyph as AmicoSpinner; color comes from +// the `.amc-mark` class (accent) via currentColor, and `running` toggles the +// pulse through `.amc-mark.is-running` (CSS owns the prefers-reduced-motion +// guard here — see amicode.css — rather than the matchMedia guard below). +export function AmicoMark(props: { class?: string; running?: boolean }) { + return ( + + ) +} + export function AmicoSpinner(props: { class?: string classList?: ComponentProps<"div">["classList"] diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 1b17f6c2b7..d5a8d22f55 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -35,6 +35,7 @@ @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); +@import "../amicode/amicode.css" layer(components); @import "../components/message-nav.css" layer(components); @import "../components/popover.css" layer(components); @import "../components/progress.css" layer(components); From e99e80d43c6fb59d9adf3dc72df20667e2c8dcf3 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 9 Jul 2026 11:25:12 -0400 Subject: [PATCH 14/17] =?UTF-8?q?feat(solver):=20show-only=20High-Performa?= =?UTF-8?q?nce=20Solver=20toggle=20=E2=80=94=20[Piccolo=20|=20=E2=9A=A1=20?= =?UTF-8?q?Piccolissimo=20+=20Altissimo=20PRO]=20segmented=20control=20on?= =?UTF-8?q?=20home=20(spec-20260709-093000);=20selection=20persists=20in?= =?UTF-8?q?=20localStorage,=20changes=20nothing=20about=20solves=20(future?= =?UTF-8?q?=20wiring:=20executor=20cloud-altissimo=20seam=20+=20issimo=20e?= =?UTF-8?q?ntitlement=20as=20the=20subscription=20gate)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 5 + packages/ui/src/amicode/solver-toggle.test.ts | 30 ++++++ packages/ui/src/amicode/solver-toggle.tsx | 102 ++++++++++++++++++ .../src/components/amicode-solver-toggle.tsx | 2 + 4 files changed, 139 insertions(+) create mode 100644 packages/ui/src/amicode/solver-toggle.test.ts create mode 100644 packages/ui/src/amicode/solver-toggle.tsx create mode 100644 packages/ui/src/components/amicode-solver-toggle.tsx diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 3ca9e08e48..880f8697b5 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -63,6 +63,7 @@ import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" +import { AmicodeSolverToggle } from "@opencode-ai/ui/amicode-solver-toggle" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" @@ -696,6 +697,10 @@ function HomeDesign() {
{/* Cards: full width across, sized to content so they never scroll */}
+ {/* solver mode (show-only v1, spec-20260709-093000): right-aligned above the cards */} +
+ +
{ + const m = new Map() + return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v) } +} + +describe("solver mode persistence", () => { + test("defaults to piccolo; hp round-trips; garbage → piccolo", () => { + const s = mem() + expect(loadSolverMode(s)).toBe("piccolo") + saveSolverMode("hp", s) + expect(loadSolverMode(s)).toBe("hp") + s.setItem("amicode-solver-mode", "nonsense") + expect(loadSolverMode(s)).toBe("piccolo") + }) + test("storage failures fail soft", () => { + const broken = { + getItem: () => { + throw new Error("nope") + }, + setItem: () => { + throw new Error("nope") + }, + } + expect(loadSolverMode(broken)).toBe("piccolo") + expect(() => saveSolverMode("hp", broken)).not.toThrow() + }) +}) diff --git a/packages/ui/src/amicode/solver-toggle.tsx b/packages/ui/src/amicode/solver-toggle.tsx new file mode 100644 index 0000000000..89e9064898 --- /dev/null +++ b/packages/ui/src/amicode/solver-toggle.tsx @@ -0,0 +1,102 @@ +import { createSignal } from "solid-js" + +// AMICODE: solver mode toggle — SHOW-ONLY v1 (spec-20260709-093000). Presents +// the future High-Performance path (Piccolissimo splines + Altissimo GPU +// solver on Harmoniqs cloud) as a selectable mode so demos can tell the story +// today. It deliberately changes NOTHING about solves: the future wiring is +// the scores schema's `executor: "cloud-altissimo" | local` seam, and the +// existing `issimo` entitlement tier becomes the subscription gate. + +export type SolverMode = "piccolo" | "hp" +const KEY = "amicode-solver-mode" + +export function loadSolverMode(storage: Pick = localStorage): SolverMode { + try { + return storage.getItem(KEY) === "hp" ? "hp" : "piccolo" + } catch { + return "piccolo" + } +} + +export function saveSolverMode(mode: SolverMode, storage: Pick = localStorage): void { + try { + storage.setItem(KEY, mode) + } catch { + /* storage unavailable — selection just won't persist */ + } +} + +export function AmicodeSolverToggle() { + const [mode, setMode] = createSignal(loadSolverMode()) + const pick = (m: SolverMode) => { + setMode(m) + saveSolverMode(m) + } + const seg = (active: boolean): Record => ({ + display: "inline-flex", + "align-items": "center", + gap: "6px", + padding: "5px 12px", + "font-size": "12px", + "font-weight": active ? "650" : "450", + border: "none", + cursor: "pointer", + background: active ? "color-mix(in srgb, var(--v2-icon-icon-accent) 14%, transparent)" : "transparent", + color: active ? "var(--v2-text-text-base)" : "var(--v2-text-text-muted)", + }) + return ( +
+ + Solver + +
+ + +
+ High Performance Solver +
+ ) +} diff --git a/packages/ui/src/components/amicode-solver-toggle.tsx b/packages/ui/src/components/amicode-solver-toggle.tsx new file mode 100644 index 0000000000..994f5e0841 --- /dev/null +++ b/packages/ui/src/components/amicode-solver-toggle.tsx @@ -0,0 +1,2 @@ +// AMICODE: re-export shim (wildcard export path) — logic in ../amicode/solver-toggle.tsx. +export { AmicodeSolverToggle, loadSolverMode, saveSolverMode, type SolverMode } from "../amicode/solver-toggle" From e24ab1ab51902cbeb4bd00a3be98f9a40bd16a98 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 9 Jul 2026 11:25:12 -0400 Subject: [PATCH 15/17] =?UTF-8?q?fix(brand):=20chat-window=20AMICODE=20wor?= =?UTF-8?q?dmark=20back=20to=20the=20brand=20typeface=20=E2=80=94=20restor?= =?UTF-8?q?e=20the=20=20component=20and=20its=20Racing=20Sans=20One?= =?UTF-8?q?=20face=20(straight-sans=20experiment=20reverted=20per=20review?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/components/session/session-new-view.tsx | 16 +++------------- packages/ui/src/components/logo.tsx | 5 ++--- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index b70f8fb9ab..a28f1c1dc9 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -10,7 +10,7 @@ import { amicodeGet } from "@/utils/amicode-fetch" import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" import { Icon } from "@opencode-ai/ui/icon" import { AmicodeGettingStarted } from "@opencode-ai/ui/amicode-getting-started" -import { Mark } from "@opencode-ai/ui/logo" +import { Logo, Mark } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" const MAIN_WORKTREE = "main" @@ -79,18 +79,8 @@ export function NewSessionView(props: NewSessionViewProps) {
- {/* amicode: straight wordmark (the Logo's Racing Sans One face reads as - italic); byline keeps the brand attribution. */} -
- AMICODE -
+ {/* amicode: brand wordmark in the brand typeface (Racing Sans One — restored per review) */} +
{/* amicode: getting-started block (tagline + how-it-works + starter chips) */} { dominant-baseline="central" textLength="230" lengthAdjust="spacingAndGlyphs" - font-family="var(--font-family-sans, ui-sans-serif, system-ui, -apple-system, sans-serif)" - font-weight="750" - letter-spacing="4" + font-family="'Racing Sans One', var(--font-family-mono, ui-monospace, monospace)" + font-weight="400" font-size="36" fill="var(--icon-base)" > From ee918de1b958c7d85f1069b2278a40d0ec157d0a Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 9 Jul 2026 12:22:03 -0400 Subject: [PATCH 16/17] =?UTF-8?q?feat(solver):=20wire=20the=20toggle=20to?= =?UTF-8?q?=20a=20real=20switch=20=E2=80=94=20file-backed=20GET/POST=20/am?= =?UTF-8?q?icode/solver-mode=20(POST=20writes=20status:switching;=20'ready?= =?UTF-8?q?'=20is=20the=20extension's=20word=20after=20the=20actual=20swit?= =?UTF-8?q?ch),=20toggle=20becomes=20server-truth=20controlled,=20and=20a?= =?UTF-8?q?=20staged=20switch=20wizard=20polls=20THROUGH=20the=20server=20?= =?UTF-8?q?restart=20(connection=20failures=20=3D=20the=20restarting=20sta?= =?UTF-8?q?ge)=20with=20a=2090s=20never-trap=20valve=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 43 +++++- .../src/server/amicode/solver-mode.ts | 56 ++++++++ .../server/routes/instance/httpapi/server.ts | 12 ++ .../test/server/amicode-solver-mode.test.ts | 36 +++++ .../ui/src/amicode/solver-switch-wizard.tsx | 124 ++++++++++++++++++ packages/ui/src/amicode/solver-toggle.tsx | 19 ++- .../amicode-solver-switch-wizard.tsx | 2 + 7 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/src/server/amicode/solver-mode.ts create mode 100644 packages/opencode/test/server/amicode-solver-mode.test.ts create mode 100644 packages/ui/src/amicode/solver-switch-wizard.tsx create mode 100644 packages/ui/src/components/amicode-solver-switch-wizard.tsx diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 880f8697b5..7b946b8ae2 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -63,7 +63,8 @@ import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" -import { AmicodeSolverToggle } from "@opencode-ai/ui/amicode-solver-toggle" +import { AmicodeSolverToggle, type SolverMode } from "@opencode-ai/ui/amicode-solver-toggle" +import { AmicodeSolverSwitchWizard } from "@opencode-ai/ui/amicode-solver-switch-wizard" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" @@ -372,6 +373,28 @@ function HomeDesign() { await refetchLibrary() } + // Solver mode (rchari/solver-wire): server-truth via /amicode/solver-mode; + // selecting a mode POSTs, then the switch wizard polls THROUGH the server + // restart (failures expected mid-switch) until the extension reports ready. + const [solverRaw, { refetch: refetchSolverMode }] = createResource( + () => state.selection.server, + () => amicodeGet(focusedServer(), "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/amicode/solver-mode").catch(() => undefined), + ) + const solverState = createMemo(() => { + const raw = solverRaw() as { ok?: boolean; mode?: string; status?: string } | undefined + if (!raw || raw.ok !== true) return undefined + return { mode: (raw.mode === "hp" ? "hp" : "piccolo") as SolverMode, switching: raw.status === "switching" } + }) + const [switchTarget, setSwitchTarget] = createSignal(undefined) + const selectSolver = (mode: SolverMode) => { + setSwitchTarget(mode) + void amicodePost(focusedServer(), `/amicode/solver-mode?mode=${mode}`).catch(() => setSwitchTarget(undefined)) + } + const pollSolver = async () => { + const raw = (await amicodeGet(focusedServer(), "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/amicode/solver-mode")) as { mode?: string; status?: string } + return { mode: String(raw?.mode ?? ""), status: String(raw?.status ?? "") } + } + const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed" const [wizardOpen, setWizardOpen] = createSignal(false) let wizardDecided = false @@ -699,7 +722,11 @@ function HomeDesign() {
{/* solver mode (show-only v1, spec-20260709-093000): right-aligned above the cards */}
- +
+ + {(target) => ( + { + setSwitchTarget(undefined) + void refetchSolverMode() + }} + /> + )} + // editable identity fields ride query params (small strings; keeps the // handler body-free like every other amicode route). Returns the fresh // profile JSON so the card can render the saved state without a second GET. + yield* router.add("GET", "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/amicode/solver-mode", () => + Effect.sync(() => + HttpServerResponse.text(AmicodeSolverMode.solverModeBody(), { contentType: "application/json" }), + ), + ) + yield* router.add("POST", "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/amicode/solver-mode", (request) => + Effect.sync(() => { + const mode = new URL(request.url, "http://localhost").searchParams.get("mode") ?? undefined + return HttpServerResponse.text(AmicodeSolverMode.setSolverModeBody(mode), { contentType: "application/json" }) + }), + ) yield* router.add("GET", "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/amicode/library", () => Effect.sync(() => HttpServerResponse.text(AmicodeLibrary.libraryBody(), { contentType: "application/json" })), ) diff --git a/packages/opencode/test/server/amicode-solver-mode.test.ts b/packages/opencode/test/server/amicode-solver-mode.test.ts new file mode 100644 index 0000000000..6b2ff99521 --- /dev/null +++ b/packages/opencode/test/server/amicode-solver-mode.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { readSolverMode, solverModeBody, setSolverModeBody } from "@/server/amicode/solver-mode" + +const tmp = () => path.join(mkdtempSync(path.join(tmpdir(), "solver-")), "solver-mode.json") + +describe("solver mode", () => { + test("defaults piccolo/ready; malformed file → default", () => { + const f = tmp() + expect(readSolverMode(f)).toEqual({ mode: "piccolo", status: "ready" }) + writeFileSync(f, "garbage") + expect(readSolverMode(f)).toEqual({ mode: "piccolo", status: "ready" }) + }) + test("POST hp → switching (ready is the extension's word, not ours)", () => { + const f = tmp() + const res = JSON.parse(setSolverModeBody("hp", f)) + expect(res).toMatchObject({ ok: true, mode: "hp", status: "switching" }) + expect(JSON.parse(readFileSync(f, "utf8")).requested_at).toBeTruthy() + }) + test("idempotent: re-selecting the settled mode does not re-trigger switching", () => { + const f = tmp() + writeFileSync(f, JSON.stringify({ mode: "hp", status: "ready" })) + expect(JSON.parse(setSolverModeBody("hp", f))).toMatchObject({ mode: "hp", status: "ready" }) + }) + test("bad mode rejected", () => { + expect(JSON.parse(setSolverModeBody("turbo", tmp())).ok).toBe(false) + expect(JSON.parse(setSolverModeBody(undefined, tmp())).ok).toBe(false) + }) + test("GET body reflects the file", () => { + const f = tmp() + writeFileSync(f, JSON.stringify({ mode: "hp", status: "switching" })) + expect(JSON.parse(solverModeBody(f))).toMatchObject({ ok: true, mode: "hp", status: "switching" }) + }) +}) diff --git a/packages/ui/src/amicode/solver-switch-wizard.tsx b/packages/ui/src/amicode/solver-switch-wizard.tsx new file mode 100644 index 0000000000..e808707ccb --- /dev/null +++ b/packages/ui/src/amicode/solver-switch-wizard.tsx @@ -0,0 +1,124 @@ +import { For, Show, createSignal, onCleanup, onMount } from "solid-js" +import { Mark } from "../components/logo" + +// AMICODE: the solver-switch wizard — a staged overlay shown while the +// extension performs a REAL solver switch (entitlement grant → session-server +// restart → ready). Stage progress is driven by polling GET /amicode/solver-mode +// through the restart window (connection failures are EXPECTED mid-switch and +// advance the "restarting" stage rather than erroring). Honest theater: every +// stage corresponds to something actually happening on the other side of the +// solver-mode.json contract. + +export type SwitchTarget = "piccolo" | "hp" + +const STAGES: Record = { + hp: ["Unlocking Piccolissimo (issimo entitlement)", "Restarting session server", "Piccolissimo ready"], + piccolo: ["Reverting to the public stack", "Restarting session server", "Piccolo ready"], +} + +export function AmicodeSolverSwitchWizard(props: { + target: SwitchTarget + /** Poll fn: resolves {mode, status} or rejects while the server restarts. */ + poll: () => Promise<{ mode: string; status: string }> + onDone: () => void +}) { + const [stage, setStage] = createSignal(0) + const [done, setDone] = createSignal(false) + let timer: ReturnType | undefined + let sawRestart = false + + onMount(() => { + const started = Date.now() + timer = setInterval(async () => { + try { + const state = await props.poll() + if (sawRestart || Date.now() - started > 1500) setStage((s) => Math.max(s, sawRestart ? 2 : 1)) + if (state.mode === props.target && state.status === "ready") { + setStage(STAGES[props.target].length - 1) + setDone(true) + if (timer) clearInterval(timer) + setTimeout(() => props.onDone(), 1400) + } + } catch { + // server down mid-restart — that IS stage 2 + sawRestart = true + setStage((s) => Math.max(s, 1)) + } + // safety valve: never trap the user behind theater + if (Date.now() - started > 90_000) { + if (timer) clearInterval(timer) + props.onDone() + } + }, 900) + }) + onCleanup(() => { + if (timer) clearInterval(timer) + }) + + return ( +
+
+ +
+ {props.target === "hp" ? "Switching to High-Performance Solver" : "Switching to Piccolo"} +
+
+ + {(label, i) => ( +
+ + {i() < stage() || done() ? "✓" : i() === stage() ? "◌" : "·"} + + + {label} + +
+ )} +
+
+ +
+ the chat will reconnect automatically +
+
+
+
+ ) +} diff --git a/packages/ui/src/amicode/solver-toggle.tsx b/packages/ui/src/amicode/solver-toggle.tsx index 89e9064898..77f8908c3d 100644 --- a/packages/ui/src/amicode/solver-toggle.tsx +++ b/packages/ui/src/amicode/solver-toggle.tsx @@ -26,11 +26,20 @@ export function saveSolverMode(mode: SolverMode, storage: Pick(loadSolverMode()) +export function AmicodeSolverToggle(props: { + /** Server-truth mode (GET /amicode/solver-mode); localStorage is only the + * pre-load hint so the control doesn't flash piccolo on boot. */ + mode?: SolverMode + switching?: boolean + onSelect?: (mode: SolverMode) => void +}) { + const [local, setLocal] = createSignal(loadSolverMode()) + const mode = () => props.mode ?? local() const pick = (m: SolverMode) => { - setMode(m) + if (props.switching || m === mode()) return + setLocal(m) saveSolverMode(m) + props.onSelect?.(m) } const seg = (active: boolean): Record => ({ display: "inline-flex", @@ -96,7 +105,9 @@ export function AmicodeSolverToggle() {
- High Performance Solver + + {props.switching ? "Switching…" : "High Performance Solver"} +
) } diff --git a/packages/ui/src/components/amicode-solver-switch-wizard.tsx b/packages/ui/src/components/amicode-solver-switch-wizard.tsx new file mode 100644 index 0000000000..5bcc7cca5e --- /dev/null +++ b/packages/ui/src/components/amicode-solver-switch-wizard.tsx @@ -0,0 +1,2 @@ +// AMICODE: re-export shim (wildcard export path) — logic in ../amicode/solver-switch-wizard.tsx. +export { AmicodeSolverSwitchWizard, type SwitchTarget } from "../amicode/solver-switch-wizard" From c7cb5724818784bf411093a1a51a151ff3ef5f94 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 9 Jul 2026 13:05:50 -0400 Subject: [PATCH 17/17] =?UTF-8?q?fix(solver):=20wizard=20never=20shows=20t?= =?UTF-8?q?he=20internal=20'issimo'=20codename=20=E2=80=94=20stage=20reads?= =?UTF-8?q?=20'Unlocking=20Piccolissimo'=20(same=20rule=20as=20the=20SCORE?= =?UTF-8?q?=20naming=20guard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/solver-switch-wizard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/amicode/solver-switch-wizard.tsx b/packages/ui/src/amicode/solver-switch-wizard.tsx index e808707ccb..5bba5c360e 100644 --- a/packages/ui/src/amicode/solver-switch-wizard.tsx +++ b/packages/ui/src/amicode/solver-switch-wizard.tsx @@ -12,7 +12,7 @@ import { Mark } from "../components/logo" export type SwitchTarget = "piccolo" | "hp" const STAGES: Record = { - hp: ["Unlocking Piccolissimo (issimo entitlement)", "Restarting session server", "Piccolissimo ready"], + hp: ["Unlocking Piccolissimo", "Restarting session server", "Piccolissimo ready"], piccolo: ["Reverting to the public stack", "Restarting session server", "Piccolo ready"], }