diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index e89ce2c289..0a839a745e 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -225,3 +225,36 @@ 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. + +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/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/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..f3c7d40812 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, MarkDetailed } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" const MAIN_WORKTREE = "main" @@ -78,19 +78,10 @@ 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); + MarkDetailed is the redesign's detailed mark (kept from trunk) */} +
{/* amicode: getting-started block (tagline + how-it-works + starter chips) */} ) { + 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). + // 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() + } + + // 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 + 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) { - 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) { @@ -628,19 +720,21 @@ 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 */} +
+ +
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 +751,33 @@ function HomeDesign() { />
+ + { + const v = profileView() + return v?.ok ? v.you.name : "" + })()} + onComplete={saveProfileFields} + onUploadPaper={uploadPaper} + onDismiss={dismissWizard} + onOpenChat={() => { + dismissWizard() + startWithPrompt("") + }} + /> + + + {(target) => ( + { + setSwitchTarget(undefined) + void refetchSolverMode() + }} + /> + )} + 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..e99ab50167 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" @@ -568,12 +569,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 }, @@ -1697,6 +1707,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.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) + }) +}) 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/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/amicode/solver-mode.ts b/packages/opencode/src/server/amicode/solver-mode.ts new file mode 100644 index 0000000000..c9ad9f4652 --- /dev/null +++ b/packages/opencode/src/server/amicode/solver-mode.ts @@ -0,0 +1,56 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +// AMICODE: solver mode — the state behind the home page's +// [Piccolo | Piccolissimo + Altissimo] toggle, file-backed so the amicode +// EXTENSION can react (it watches this file, grants/revokes the `issimo` +// entitlement, re-preps the session config, restarts the server, then flips +// status back to "ready" — see amicode packages/extension/src/solver_mode.ts, +// the other half of this contract; change both in one change-set). +// +// {"mode":"piccolo"|"hp", "status":"ready"|"switching", "requested_at": ISO} +// +// POST here only ever writes status:"switching" — "ready" is the extension's +// word, given after the switch actually happened. Same never-reject JSON-body +// discipline as the sibling modules. + +export type SolverMode = "piccolo" | "hp" + +export function solverModeFile(): string { + const ops = process.env.AMICODE_OPS_DIR ?? path.join(os.homedir(), ".amico", "amicode") + return path.join(ops, "solver-mode.json") +} + +export function readSolverMode(file: string = solverModeFile()): { mode: SolverMode; status: "ready" | "switching" } { + try { + if (!existsSync(file)) return { mode: "piccolo", status: "ready" } + const parsed = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown } + return { + mode: parsed.mode === "hp" ? "hp" : "piccolo", + status: parsed.status === "switching" ? "switching" : "ready", + } + } catch { + return { mode: "piccolo", status: "ready" } + } +} + +export function solverModeBody(file: string = solverModeFile()): string { + const state = readSolverMode(file) + return JSON.stringify({ ok: true, ...state, error: null }) +} + +export function setSolverModeBody(modeRaw: string | undefined, file: string = solverModeFile()): string { + if (modeRaw !== "piccolo" && modeRaw !== "hp") + return JSON.stringify({ ok: false, mode: null, status: null, error: "bad_request: mode must be piccolo|hp" }) + const current = readSolverMode(file) + // Idempotent: re-selecting the active, settled mode is a no-op (no restart). + if (current.mode === modeRaw && current.status === "ready") return solverModeBody(file) + try { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify({ mode: modeRaw, status: "switching", requested_at: new Date().toISOString() })) + } catch (err) { + return JSON.stringify({ ok: false, mode: null, status: null, error: `write_failed: ${String(err)}` }) + } + return solverModeBody(file) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9b6142bf68..87f8cfe449 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -60,6 +60,8 @@ 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 AmicodeSolverMode from "@/server/amicode/solver-mode" import * as AmicodeProfile from "@/server/amicode/profile" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -233,6 +235,26 @@ 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/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" })), + ) + 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/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/amicode.css b/packages/ui/src/amicode/amicode.css new file mode 100644 index 0000000000..e4f005056b --- /dev/null +++ b/packages/ui/src/amicode/amicode.css @@ -0,0 +1,577 @@ +/* AMICODE surfaces — the in-chat receipt card (card.tsx), the entity-view + * dialog body (entity-view.tsx), the problem-header rail (entity-rail.tsx), the + * question card (ask-card.tsx) and the run window (run-window.tsx). One shared + * visual language: a 1px hairline with a 3px accent rail, the H-mark as the + * signature (and, while working, the spinner), state carried in form as well as + * words. All colors come from the v2 theme tokens; only failure earns the one + * red rail. Namespaced by [data-component="amicode-*"] so nothing leaks. + * + * Imported from styles/index.css (components layer). Interaction/animation/ + * pseudo-element rules live here because inline styles can't express them. */ + +@keyframes amc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.32; } +} +@keyframes amc-blip { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); } + 50% { opacity: 0.45; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 12%, transparent); } +} +@keyframes amc-shimmer { + 100% { transform: translateX(100%); } +} + +/* ---- shared signature (mark + wordmark) ---------------------------------- */ +.amc-mark { + width: 13px; + height: 13px; + flex-shrink: 0; + color: var(--v2-icon-icon-accent); + fill: currentColor; +} +@media (prefers-reduced-motion: no-preference) { + .amc-mark.is-running { animation: amc-pulse 1.2s ease-in-out infinite; } +} +.amc-sig { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.amc-wordmark { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.11em; + color: var(--v2-text-text-accent); +} +.amc-rule { + width: 1px; + align-self: stretch; + margin: 2px 10px; + background: var(--v2-border-border-base); + flex-shrink: 0; +} + +/* ============================================================ + THE CHIP — [data-component="amicode-card"] + ============================================================ */ +[data-component="amicode-card"] { + -webkit-appearance: none; + appearance: none; + font: inherit; + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; + text-align: left; + /* No side rail — identity comes from the accent-tinted wash + hairline and + the bright H-mark. A faint gold border reads as "AMICO" without a bar. */ + border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 22%, var(--v2-border-border-base)); + border-radius: 9px; + background: color-mix(in srgb, var(--v2-icon-icon-accent) 6%, var(--v2-background-bg-layer-01)); + padding: 5px 9px 5px 10px; + font-size: 12.5px; + line-height: 17px; + color: var(--v2-text-text-base); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); + transition: + background 0.16s ease, + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.12s ease; +} +[data-component="amicode-card"][data-clickable="true"] { + cursor: pointer; +} +[data-component="amicode-card"][data-clickable="true"]:hover { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 11%, var(--v2-background-bg-layer-01)); + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 42%, var(--v2-border-border-base)); + box-shadow: + var(--v2-elevation-raised, 0 1px 2px rgba(0, 0, 0, 0.25)), + 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 8%, transparent); +} +[data-component="amicode-card"][data-clickable="true"]:active { + transform: translateY(0.5px); +} +[data-component="amicode-card"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 2px; +} +[data-component="amicode-card"][data-state="error"] { + border-color: color-mix(in srgb, var(--v2-state-fg-danger) 38%, var(--v2-border-border-base)); + background: color-mix(in srgb, var(--v2-state-fg-danger) 6%, var(--v2-background-bg-layer-01)); +} +[data-component="amicode-card"][data-state="error"] .amc-mark, +[data-component="amicode-card"][data-state="error"] .amc-wordmark { + color: var(--v2-state-fg-danger); +} +[data-component="amicode-card"] .amc-mark { + width: 13px; + height: 13px; +} + +[data-component="amicode-card"] .amc-body { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + overflow: hidden; +} +[data-component="amicode-card"] .amc-label { + font-weight: 640; + color: var(--v2-text-text-base); + white-space: nowrap; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-detail { + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +[data-component="amicode-card"][data-state="running"] .amc-detail { + color: var(--v2-text-text-accent); +} +[data-component="amicode-card"][data-state="error"] .amc-detail { + color: var(--v2-state-fg-danger); +} + +/* structured diff pieces */ +[data-component="amicode-card"] .amc-diff { + display: inline-flex; + align-items: baseline; + gap: 4px; + white-space: nowrap; +} +[data-component="amicode-card"] .amc-diff + .amc-diff { + padding-left: 7px; + margin-left: 2px; + border-left: 1px solid var(--v2-border-border-muted); +} +[data-component="amicode-card"] .amc-diff .k { color: var(--v2-text-text-muted); } +[data-component="amicode-card"] .amc-diff .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11.5px; + color: var(--v2-text-text-base); +} +[data-component="amicode-card"] .amc-diff .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-card"] .amc-diff .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 1px; +} + +[data-component="amicode-card"] .amc-trail { + display: inline-flex; + align-items: center; + gap: 7px; + margin-left: 12px; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-tick { + width: 12px; + height: 12px; + color: var(--v2-state-fg-success); + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-livedot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--v2-icon-icon-accent); +} +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-card"][data-state="running"] .amc-livedot { animation: amc-blip 1.2s ease-in-out infinite; } +} +[data-component="amicode-card"] .amc-chev { + color: var(--v2-text-text-faint); + font-size: 15px; + line-height: 1; + opacity: 0; + transform: translateX(-3px); + transition: opacity 0.16s ease, transform 0.16s ease, color 0.16s ease; +} +[data-component="amicode-card"][data-clickable="true"]:hover .amc-chev, +[data-component="amicode-card"][data-clickable="true"]:focus-visible .amc-chev { + opacity: 1; + transform: translateX(0); + color: var(--v2-text-text-muted); +} +/* keyboard/touch: no hover — keep the affordance visible so it never hides */ +@media (hover: none) { + [data-component="amicode-card"][data-clickable="true"] .amc-chev { opacity: 0.6; transform: none; } +} + +/* ============================================================ + ENTITY VIEW — [data-component="amicode-entity-view"] (dialog body) + ============================================================ */ +[data-component="amicode-entity-view"] { + display: flex; + flex-direction: column; + padding: 4px 4px 0; + color: var(--v2-text-text-base); +} + +[data-component="amicode-entity-view"] .amc-ev-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 2px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 600; + padding: 3px 7px; + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier[data-tier="free"] { + background: var(--v2-state-bg-warning); + color: var(--v2-state-fg-warning); +} + +[data-component="amicode-entity-view"] .amc-ev-formula { + margin: 8px 0; + padding: 14px 16px; + border-radius: 10px; + background: var(--v2-background-bg-layer-01); + border: 1px solid var(--v2-border-border-muted); + overflow-x: auto; +} +[data-component="amicode-entity-view"] .amc-ev-formula-label { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--v2-text-text-faint); + margin-bottom: 10px; +} + +[data-component="amicode-entity-view"] .amc-ev-sec { + display: flex; + align-items: center; + gap: 10px; + margin: 20px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-sec::after { + content: ""; + flex: 1; + height: 1px; + background: var(--v2-border-border-muted); +} + +[data-component="amicode-entity-view"] .amc-ev-group { + margin: 8px 4px 2px; + font-size: 11px; + font-weight: 600; + color: var(--v2-text-text-muted); +} + +[data-component="amicode-entity-view"] .amc-field { + display: grid; + grid-template-columns: 160px 1fr auto; + align-items: baseline; + gap: 14px; + padding: 7px 8px 7px 4px; + border-radius: 8px; + transition: background 0.12s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover { background: var(--v2-background-bg-layer-01); } +[data-component="amicode-entity-view"] .amc-field .amc-fk { min-width: 0; } +[data-component="amicode-entity-view"] .amc-field .amc-fk .name { + font-size: 13px; + font-weight: 560; + color: var(--v2-text-text-base); +} +[data-component="amicode-entity-view"] .amc-field .amc-fk .raw { + display: block; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-faint); + margin-top: 1px; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 13px; + color: var(--v2-text-text-base); + text-align: right; + font-variant-numeric: tabular-nums; + word-break: break-word; + min-width: 0; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed { color: var(--v2-text-text-accent); } +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed::before { + content: "•"; + color: var(--v2-icon-icon-accent); + padding-right: 6px; + font-size: 10px; + vertical-align: middle; +} + +[data-component="amicode-entity-view"] .amc-edit { + -webkit-appearance: none; + appearance: none; + justify-self: end; + font: inherit; + font-size: 11px; + color: var(--v2-text-text-muted); + background: transparent; + border: 1px solid var(--v2-border-border-muted); + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + opacity: 0.55; + transition: opacity 0.14s ease, color 0.14s ease, border-color 0.14s ease, background 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit { opacity: 1; } +[data-component="amicode-entity-view"] .amc-edit:hover { + color: var(--v2-text-text-accent); + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 45%, transparent); + background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent); +} +[data-component="amicode-entity-view"] .amc-edit:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 1px; + opacity: 1; +} +[data-component="amicode-entity-view"] .amc-edit .amc-edit-label { + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.18s ease, opacity 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit .amc-edit-label, +[data-component="amicode-entity-view"] .amc-edit:focus-visible .amc-edit-label { + max-width: 90px; + opacity: 1; +} + +[data-component="amicode-entity-view"] .amc-timeline { + display: flex; + flex-direction: column; + margin-left: 6px; + padding-left: 2px; + border-left: 1.5px solid var(--v2-border-border-muted); +} +[data-component="amicode-entity-view"] .amc-event { + position: relative; + padding: 9px 10px 9px 20px; + border-radius: 8px; +} +[data-component="amicode-entity-view"] .amc-event::before { + content: ""; + position: absolute; + left: -5px; + top: 15px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--v2-background-bg-base); + border: 1.5px solid var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored] { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored]::before { + background: var(--v2-icon-icon-accent); + border-color: var(--v2-icon-icon-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 18%, transparent); +} +[data-component="amicode-entity-view"] .amc-event .erow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-component="amicode-entity-view"] .amc-event .seq { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-muted); + background: var(--v2-background-bg-layer-02); + border-radius: 5px; + padding: 1px 6px; +} +[data-component="amicode-entity-view"] .amc-event .src { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event .when { + font-size: 11px; + color: var(--v2-text-text-faint); + margin-left: auto; +} +[data-component="amicode-entity-view"] .amc-event .summary { + font-size: 13px; + color: var(--v2-text-text-base); + margin-top: 3px; +} +[data-component="amicode-entity-view"] .amc-event .summary .k { color: var(--v2-text-text-muted); } +[data-component="amicode-entity-view"] .amc-event .summary .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 12px; +} +[data-component="amicode-entity-view"] .amc-event .summary .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-entity-view"] .amc-event .summary .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 4px; +} + +[data-component="amicode-entity-view"] .amc-ev-foot { + display: flex; + align-items: center; + gap: 9px; + margin-top: 18px; + padding: 12px 4px 6px; + border-top: 1px solid var(--v2-border-border-muted); + font-size: 12px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-foot .amc-mark { + width: 13px; + height: 13px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-foot b { + color: var(--v2-text-text-base); + font-weight: 600; +} + +[data-component="amicode-entity-view"] .amc-ev-empty { + font-size: 13px; + color: var(--v2-text-text-muted); + padding: 4px; +} + +/* error + skeleton states */ +[data-component="amicode-entity-view"] .amc-ev-error { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} +[data-component="amicode-entity-view"] .amc-ev-error .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--v2-state-fg-danger); +} +[data-component="amicode-entity-view"] .amc-ev-error .msg { + flex: 1; + font-size: 14px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-retry { + -webkit-appearance: none; + appearance: none; + font: inherit; + font-size: 12px; + color: var(--v2-text-text-base); + background: transparent; + border: 1px solid var(--v2-border-border-strong); + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; + flex-shrink: 0; +} +[data-component="amicode-entity-view"] .amc-ev-retry:hover { background: var(--v2-background-bg-layer-02); } +[data-component="amicode-entity-view"] .amc-ev-retry:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 1px; +} + +[data-component="amicode-entity-view"] .amc-sk { + height: 14px; + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + position: relative; + overflow: hidden; + margin: 12px 0; +} +[data-component="amicode-entity-view"] .amc-sk.w60 { width: 60%; } +[data-component="amicode-entity-view"] .amc-sk.w40 { width: 40%; } +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-entity-view"] .amc-sk::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--v2-text-text-base) 8%, transparent), transparent); + animation: amc-shimmer 1.4s infinite; + } +} + +/* ============================================================ + SHARED CARD SURFACES — rail, ask, run window + ============================================================ */ +[data-component="amicode-entity-rail"], +[data-component="amicode-ask-card"], +[data-component="amicode-run-window"] { + /* Same rail-free treatment as the receipt card: gold-tinted hairline + a + whisper of accent wash, so the whole AMICO family shares one identity. */ + border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 20%, var(--v2-border-border-base)); + border-radius: 9px; + background: color-mix(in srgb, var(--v2-icon-icon-accent) 5%, var(--v2-background-bg-layer-01)); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); +} + +/* rail chip hover */ +[data-component="amicode-entity-rail"] [data-slot="amicode-rail-chip"] { + border-radius: 5px; + padding: 1px 5px; + margin: -1px -1px; + transition: background 0.14s ease; +} +[data-component="amicode-entity-rail"] button[data-slot="amicode-rail-chip"]:hover { + background: var(--v2-background-bg-layer-02); +} +[data-component="amicode-entity-rail"] [data-slot="amicode-rail-problem"]:hover { + color: var(--v2-text-text-accent) !important; +} +[data-component="amicode-entity-rail"] button:focus-visible, +[data-component="amicode-ask-card"] button:focus-visible, +[data-component="amicode-run-window"] button:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 2px; +} + +/* ask-card option hover */ +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled) { + transition: background 0.14s ease, border-color 0.14s ease, transform 0.12s ease; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):hover { + border-color: var(--v2-icon-icon-accent) !important; + background: var(--v2-background-bg-layer-03) !important; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):active { + transform: translateY(0.5px); +} + +/* run window: subtle hover to signal it opens the Run entity */ +[data-component="amicode-run-window"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-component="amicode-run-window"]:hover { + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 40%, var(--v2-border-border-base)); + background: color-mix(in srgb, var(--v2-icon-icon-accent) 8%, var(--v2-background-bg-layer-01)); +} diff --git a/packages/ui/src/amicode/ask-card.tsx b/packages/ui/src/amicode/ask-card.tsx index 501379d6d0..71b224a6c6 100644 --- a/packages/ui/src/amicode/ask-card.tsx +++ b/packages/ui/src/amicode/ask-card.tsx @@ -1,6 +1,7 @@ import { For, Show, createSignal } from "solid-js" import { amicodeAskBridge } from "./ask-bridge" import { answeredOption, hasUserReplyAfter, type AskInput } from "./ask" +import { AmicoMark } from "./spinner" import { useData } from "../context/data" // AMICODE: question card for amicode_ask tool parts — question text + one @@ -43,24 +44,15 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string; sessi "flex-direction": "column", 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", "font-size": "12px", "line-height": "16px", }} > -
- - AMICO +
+ + + AMICO · Question diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index c392996a2f..1a021051de 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -1,8 +1,9 @@ -import { Match, Show, Switch, createMemo } from "solid-js" +import { For, Match, Show, Switch, createMemo } from "solid-js" import { amicodeStage } from "./stage" import { parseAskInput } from "./ask" import { AmicodeAskCard } from "./ask-card" -import { parseDiffSentinel, receiptText } from "./receipt" +import { parseDiffSentinel, receiptParts } from "./receipt" +import { AmicoMark } from "./spinner" import { openAmicodeEntity } from "./ui-bridge" import { RunWindow } from "./run-window" @@ -31,74 +32,150 @@ function runRefFromOutput(output: unknown): { run: string; lab?: string } | unde // is agent-directed text and is deliberately NOT rendered — durable state // lives in the problem rail (./entity-rail.tsx). Receipt click opens the // entity view through the ui-bridge (no-op until the rail registers it). +// +// The card carries four states, differentiated in FORM, not just words: the +// H-mark is the spinner while working (running), settles under a green check +// when done with no diff, becomes a structured diff receipt when there is one, +// and turns its rail red on failure. It is a real + ) } diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c3c4e2b86a..125b4968c5 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, @@ -49,6 +50,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 +139,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}> @@ -147,25 +158,15 @@ export function AmicodeEntityRail(props: { "min-width": "0", "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", "font-size": "11px", "line-height": "16px", "white-space": "nowrap", }} > - - AMICO + + + AMICO )} + + +
diff --git a/packages/ui/src/amicode/entity-view.tsx b/packages/ui/src/amicode/entity-view.tsx index 9b1abfe2a4..d6b14cb001 100644 --- a/packages/ui/src/amicode/entity-view.tsx +++ b/packages/ui/src/amicode/entity-view.tsx @@ -1,7 +1,8 @@ import { For, Show, createMemo } from "solid-js" import katex from "katex" -import { entityLabel, receiptText } from "./receipt" -import { type ProblemView, editPromptText, entityRows, historyRows } from "./problem" +import { AmicoMark } from "./spinner" +import { receiptParts } from "./receipt" +import { type ProblemView, editPromptText, entityRows, fieldGroup, historyRows, humanizeKey } from "./problem" // AMICODE ring-2 entity view (spec B): dialog BODY opened from a rail chip or // diff receipt — current fields (pretty table + per-field "Edit in chat" @@ -10,6 +11,13 @@ import { type ProblemView, editPromptText, entityRows, historyRows } from "./pro // writes route through the agent's tools (ring 0), so entity state and // conversation never fork. Presentation-only over ./problem.ts helpers; the // app owns fetching and wraps this in its Dialog. +// +// Redesign: raw wire keys lead with a human label (params.drive_max → "Drive +// max") and keep the technical name underneath; nested groups get a subhead; +// the field you arrived from (anchorSeq's diff) is marked; the ✎ affordance is +// always faintly present, not hover-only; and a footer states the read-only / +// edit-in-chat contract so the absence of Save buttons reads as intent. Styling +// is in ./amicode.css; data-slot hooks are preserved for the e2e suite. // Known-platform Hamiltonians, rendered via the fork's existing katex (same // dep marked.tsx uses). Unknown platform → no math block. @@ -20,6 +28,25 @@ const HAMILTONIAN_LATEX: Record = { "\\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,175 +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/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 0df447cc60..64bb2a2810 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,5 +1,12 @@ 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, + 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 @@ -125,7 +132,7 @@ const CARD: JSX.CSSProperties = { background: "var(--v2-background-bg-layer-01)", padding: "14px 16px", } -const HERO_CARD: JSX.CSSProperties = { ...CARD, "border-left": "3px solid var(--v2-icon-icon-accent)" } +const HERO_CARD: JSX.CSSProperties = { ...CARD } const EYEBROW: JSX.CSSProperties = { "font-size": "10px", "font-weight": "700", @@ -237,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
@@ -397,14 +404,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 +420,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)) @@ -1033,6 +1003,97 @@ 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)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + } + } 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 @@ -1077,8 +1138,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 @@ -1182,32 +1245,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. */} + + +
) 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) +} diff --git a/packages/ui/src/amicode/onboarding-wizard.test.ts b/packages/ui/src/amicode/onboarding-wizard.test.ts new file mode 100644 index 0000000000..8e77470a85 --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { shouldShowWizard } from "./onboarding-wizard" + +// The wizard is session-zero UI: it must appear exactly once — for a fresh +// profile that was never dismissed — and never fight a filled-in profile. +describe("shouldShowWizard", () => { + 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..c07a02b6ef --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -0,0 +1,545 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Mark, MarkDetailed } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + 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, +// 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 + /** 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 | 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: "", + 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(props.onUploadPaper ? 2 : FINAL) + } 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 — 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 */} + +
+
+ }> + + (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.onDismiss()} /> + props.onOpenChat()} /> +
+
+
+ + +
+
+ ) +} 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-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/run-window.tsx b/packages/ui/src/amicode/run-window.tsx index 4573c146ed..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,10 +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-left": "3px solid var(--v2-icon-icon-accent)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "8px 12px", "font-size": "12px", "line-height": "16px", @@ -200,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/solver-switch-wizard.tsx b/packages/ui/src/amicode/solver-switch-wizard.tsx new file mode 100644 index 0000000000..5bba5c360e --- /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", "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.test.ts b/packages/ui/src/amicode/solver-toggle.test.ts new file mode 100644 index 0000000000..3638b68f6e --- /dev/null +++ b/packages/ui/src/amicode/solver-toggle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { loadSolverMode, saveSolverMode } from "./solver-toggle" + +const mem = () => { + 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..77f8908c3d --- /dev/null +++ b/packages/ui/src/amicode/solver-toggle.tsx @@ -0,0 +1,113 @@ +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(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) => { + if (props.switching || m === mode()) return + setLocal(m) + saveSolverMode(m) + props.onSelect?.(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 + +
+ + +
+ + {props.switching ? "Switching…" : "High Performance Solver"} + +
+ ) +} diff --git a/packages/ui/src/amicode/spinner.tsx b/packages/ui/src/amicode/spinner.tsx index eac03cab83..b807021f66 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,10 +11,35 @@ 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 +// 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"] @@ -22,7 +48,7 @@ export function AmicoSpinner(props: { return ( { + 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) +} diff --git a/packages/ui/src/assets/favicon/amico.svg b/packages/ui/src/assets/favicon/amico.svg index 3b651d3fcf..c6fd7a1df4 100644 --- a/packages/ui/src/assets/favicon/amico.svg +++ b/packages/ui/src/assets/favicon/amico.svg @@ -1,22 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - + + + + 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" 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" 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" 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..ff2f1411d2 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 ( { 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)" > 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 }) 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);