diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index 4d25536a38..b4fd45255d 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -56,6 +56,7 @@ PATH="$HOME/.bun/bin:$PATH" OPENCODE_VERSION=1.17.3 bun run script/build.ts --si ```bash ./dist/opencode-local serve --port 14096 ``` + - startup log: `opencode server listening on http://127.0.0.1:14096` (+ warning: `OPENCODE_SERVER_PASSWORD` not set → unsecured) - `GET /` → 200, SolidJS app HTML shell (`OpenCode`, `
`, script `/assets/index-BfWaaOZM.js` = the exact vite-build hash → served from the binary embed, no separate app step needed) - `GET /assets/index-BfWaaOZM.js` → 200 text/javascript 1,694,088 B @@ -66,6 +67,7 @@ PATH="$HOME/.bun/bin:$PATH" OPENCODE_VERSION=1.17.3 bun run script/build.ts --si ### 4. How the SolidJS app is served `packages/opencode/src/server/shared/ui.ts`: + - Compiled binary: `import("opencode-web-ui.gen.ts")` resolves → `serveEmbeddedUIEffect` serves the embedded `packages/app/dist` files (SPA fallback to `index.html`), with CSP derived from the inline theme-preload script hash. @@ -79,28 +81,29 @@ PATH="$HOME/.bun/bin:$PATH" OPENCODE_VERSION=1.17.3 bun run script/build.ts --si User-visible brand sites in the served web app (packages/app + packages/ui), enumerated at tag v1.17.3. Format: file:line — string/asset — replacement. [desktop] = ships only in the Electron desktop app, not the served web app. -| # | site | current | replacement | -|---|------|---------|-------------| -| 1 | packages/app/index.html:6 | `OpenCode` | `Amicode` | -| 2 | packages/app/index.html:8 | svg favicon `/favicon-v3.svg` | `/amico.svg` (new brand asset) | -| 3 | packages/ui/src/assets/favicon/site.webmanifest:2-3 (symlinked as packages/app/public/site.webmanifest) | `name`/`short_name` "OpenCode" | "Amicode" | -| 4 | packages/ui/src/assets/favicon/amico.svg (new; symlinked as packages/app/public/amico.svg, matching the repo's favicon symlink pattern) | (new) | favicon: brand-accent disc `#FFF676` + amico face (from amicode `packages/extension/media/amico.svg`, on-accent `#000`) | -| 5 | packages/ui/src/components/favicon.tsx:10 | `apple-mobile-web-app-title` "OpenCode" | "Amicode" (+ svg icon Link `/amico.svg`) | -| 6 | packages/ui/src/components/logo.tsx | `Logo` = OPENCODE letterform SVG; `Mark`/`Splash` = "O" mark | `Logo` → AMICODE wordmark text; `Mark`/`Splash` → amico face (inlined from amico.svg). Rendered at: home.tsx:1151 (watermark), pages/error.tsx:281, session-side-panel.tsx, session-new-view.tsx, app.tsx (loading splash) | -| 7 | packages/ui/src/v2/components/wordmark-v2.tsx | OPENCODE glyph SVG | AMICODE wordmark text (rendered on session-new-design-view.tsx new-session screen) | -| 8 | packages/ui/src/theme/context.tsx:72 | theme display label `opencode: "OpenCode"` | `"Amicode"` (key `opencode` untouched — storage/config contract) | -| 9 | packages/app/src/i18n/en.ts | 30 brand mentions ("…models in OpenCode", "OpenCode Desktop", update/error/settings copy) | "Amicode" (29 sites; "OpenCode Zen" kept, see below) | -| 10 | packages/app/src/desktop-menu.ts:75 [desktop] | menu label "OpenCode" | "Amicode" | -| 11 | packages/app/src/wsl/settings-model.ts:17-18 [desktop, Windows/WSL] | "Install OpenCode"/"Update OpenCode" | "Install/Update Amicode" | -| 13 | packages/ui/src/components/logo.css | `[data-component="logo-mark"]` aspect-ratio 4/5 (old 16x20 viewBox) | aspect-ratio 1/1 (new square amico mark) | -| 12 | packages/ui/src/theme/themes/oc-2.json (default theme) | accent tokens `v2-{background,text,icon}-*accent*` = blue-600/700 (light), blue-400/300 (dark) | Harmoniqs accent `#FFF676` (brand.css `--color-accent`): dark text/icon/bg-accent → `#FFF676`, hover `#FFFA9E`; light bg-accent → `#FFF676`, light text/icon-accent → derived dark shades `#857A00` / hover `#6B6200` (raw `#FFF676` is unreadable on light bg) | +| # | site | current | replacement | +| --- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | packages/app/index.html:6 | `OpenCode` | `Amicode` | +| 2 | packages/app/index.html:8 | svg favicon `/favicon-v3.svg` | `/amico.svg` (new brand asset) | +| 3 | packages/ui/src/assets/favicon/site.webmanifest:2-3 (symlinked as packages/app/public/site.webmanifest) | `name`/`short_name` "OpenCode" | "Amicode" | +| 4 | packages/ui/src/assets/favicon/amico.svg (new; symlinked as packages/app/public/amico.svg, matching the repo's favicon symlink pattern) | (new) | favicon: brand-accent disc `#FFF676` + amico face (from amicode `packages/extension/media/amico.svg`, on-accent `#000`) | +| 5 | packages/ui/src/components/favicon.tsx:10 | `apple-mobile-web-app-title` "OpenCode" | "Amicode" (+ svg icon Link `/amico.svg`) | +| 6 | packages/ui/src/components/logo.tsx | `Logo` = OPENCODE letterform SVG; `Mark`/`Splash` = "O" mark | `Logo` → AMICODE wordmark text; `Mark`/`Splash` → amico face (inlined from amico.svg). Rendered at: home.tsx:1151 (watermark), pages/error.tsx:281, session-side-panel.tsx, session-new-view.tsx, app.tsx (loading splash) | +| 7 | packages/ui/src/v2/components/wordmark-v2.tsx | OPENCODE glyph SVG | AMICODE wordmark text (rendered on session-new-design-view.tsx new-session screen) | +| 8 | packages/ui/src/theme/context.tsx:72 | theme display label `opencode: "OpenCode"` | `"Amicode"` (key `opencode` untouched — storage/config contract) | +| 9 | packages/app/src/i18n/en.ts | 30 brand mentions ("…models in OpenCode", "OpenCode Desktop", update/error/settings copy) | "Amicode" (29 sites; "OpenCode Zen" kept, see below) | +| 10 | packages/app/src/desktop-menu.ts:75 [desktop] | menu label "OpenCode" | "Amicode" | +| 11 | packages/app/src/wsl/settings-model.ts:17-18 [desktop, Windows/WSL] | "Install OpenCode"/"Update OpenCode" | "Install/Update Amicode" | +| 13 | packages/ui/src/components/logo.css | `[data-component="logo-mark"]` aspect-ratio 4/5 (old 16x20 viewBox) | aspect-ratio 1/1 (new square amico mark) | +| 12 | packages/ui/src/theme/themes/oc-2.json (default theme) | accent tokens `v2-{background,text,icon}-*accent*` = blue-600/700 (light), blue-400/300 (dark) | Harmoniqs accent `#FFF676` (brand.css `--color-accent`): dark text/icon/bg-accent → `#FFF676`, hover `#FFFA9E`; light bg-accent → `#FFF676`, light text/icon-accent → derived dark shades `#857A00` / hover `#6B6200` (raw `#FFF676` is unreadable on light bg) | Deliberately left stock (and why): + - 25 non-English app locales + all ui locales brand strings — demo is English; en.ts is the default/fallback dictionary; bulk-editing translations is churn without review. - "OpenCode Zen" (app en.ts:139) and "OpenCode Go" (ui en.ts:58) — proper names of the external commercial model-gateway services the binary still connects to; renaming would misrepresent a third-party service. - Shiki syntax-theme name "OpenCode" (ui context/marked.tsx, ui pierre/worker.ts, ui pierre/index.ts) — cross-module string identifier, not user-visible; partial rename breaks code highlighting. - Font names "OpenCode Sans/Mono" — CSS font-family identifiers. -- Binary icon assets (favicon-96x96-v3.png, favicon-v3.ico, apple-touch-icon-v3.png, web-app-manifest-*.png, social-share.png) — need image regeneration tooling; svg favicon takes precedence in modern browsers. Morning follow-up. +- Binary icon assets (favicon-96x96-v3.png, favicon-v3.ico, apple-touch-icon-v3.png, web-app-manifest-\*.png, social-share.png) — need image regeneration tooling; svg favicon takes precedence in modern browsers. Morning follow-up. - All internal identifiers: `opencode-titlebar-*` DOM ids, `opencode-*` localStorage keys, `OPENCODE_*` env vars, config keys, API/SDK strings, `@opencode-ai/*` imports, `oc-theme-preload-script` id (the server CSP hash regex in packages/opencode/src/server/shared/ui.ts matches this exact id). - Storybook/stories files — dev-only, not in the served app. - desktop-menu.ts:203 "OpenCode Documentation" [desktop] — the link target IS opencode.ai/docs; relabeling would misattribute upstream docs. @@ -120,26 +123,26 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. 1. `4e20def26e` — amicode: build fixes for local v1.17.3 reproduction (AMICODE-PATCHES.md, bun.lock ghostty-web drift) 2. `c566a17db7` — amicode: L1 branding — AMICODE wordmark, logo, accent. Files: packages/app/index.html, packages/ui/src/assets/favicon/{amico.svg,site.webmanifest}, packages/app/public/amico.svg (symlink), packages/ui/src/components/{favicon.tsx,logo.tsx,logo.css}, packages/ui/src/v2/components/wordmark-v2.tsx, packages/ui/src/theme/context.tsx, packages/ui/src/theme/themes/oc-2.json, packages/app/src/i18n/en.ts, packages/app/src/desktop-menu.ts, packages/app/src/wsl/settings-model.ts. User-visible strings/assets ONLY — no identifier, config-key, env-var, or API renames. -3. `1929d3db09` — amicode: L2 renderer slot — amicode_* tool cards. - - Renderer location: the session timeline (packages/app/src/pages/session/message-timeline.tsx:1057) delegates every message part to `Part` in **packages/ui/src/components/message-part.tsx**; tool parts dispatch at `PART_MAPPING["tool"]` via `ToolRegistry.render(part().tool) ?? GenericTool`. (Errored tool parts branch to ToolErrorCard *before* this dispatch, so amicode errors keep stock error rendering.) - - Sole stock-code touch: packages/ui/src/components/message-part.tsx — import at **line 38**, dispatch branch at **lines 1392–1395**: `/^amicode_/.test(part().tool) ? AmicodeToolCard : (ToolRegistry.render(...) ?? GenericTool)`. Prefix regex (not per-name ToolRegistry registration) so future amicode_* tools (to_hardware, calibrate) auto-match. +3. `1929d3db09` — amicode: L2 renderer slot — amicode\_\* tool cards. + - Renderer location: the session timeline (packages/app/src/pages/session/message-timeline.tsx:1057) delegates every message part to `Part` in **packages/ui/src/components/message-part.tsx**; tool parts dispatch at `PART_MAPPING["tool"]` via `ToolRegistry.render(part().tool) ?? GenericTool`. (Errored tool parts branch to ToolErrorCard _before_ this dispatch, so amicode errors keep stock error rendering.) + - Sole stock-code touch: packages/ui/src/components/message-part.tsx — import at **line 38**, dispatch branch at **lines 1392–1395**: `/^amicode_/.test(part().tool) ? AmicodeToolCard : (ToolRegistry.render(...) ?? GenericTool)`. Prefix regex (not per-name ToolRegistry registration) so future amicode\_\* tools (to_hardware, calibrate) auto-match. - New files (all presentation): packages/ui/src/amicode/card.tsx (AmicodeToolCard: "AMICODE · " header in accent `var(--v2-text-text-accent)`, 3px accent left border `var(--v2-icon-icon-accent)`, monospace body = tool output, falls back to pretty-printed input while running); packages/ui/src/amicode/stage.ts (pure stage mapping: pick_system→System, set_model→Model, formulate→Formulation, solve→Run, else de-underscored+capitalized); packages/ui/src/amicode/stage.test.ts (3 tests, `bun test src/amicode` → 3 pass). - Visual check of the card in a live session = morning (headless smoke can't exercise a tool-call part without a model turn). 4. `9829bd1864` — amicode: L3 entity rail + collapsed tool chips. Live feedback (Aaron, 2026-07-03): full cards repeated per call cluttered the transcript, and the raw tool return is agent-directed text (for the MODEL, not the human). - AmicodeToolCard collapsed to a one-line chip "AMICODE · updated ✓" (or "running…"); monospace body REMOVED on purpose — do not resurrect it. - - NEW packages/ui/src/amicode/entity-rail.tsx — `AmicodeEntityRail`: compact sticky row, chips System / Formulation / Run showing the LATEST state per stage, derived client-side by scanning the session's amicode_* tool parts (messages ULID-sorted; later parts win). Renders nothing until the session has ≥1 amicode_* part (non-amicode sessions stay stock); stages without parts show a dimmed "—". pick_system AND set_model both feed the System chip. + - NEW packages/ui/src/amicode/entity-rail.tsx — `AmicodeEntityRail`: compact sticky row, chips System / Formulation / Run showing the LATEST state per stage, derived client-side by scanning the session's amicode*\* tool parts (messages ULID-sorted; later parts win). Renders nothing until the session has ≥1 amicode*\* part (non-amicode sessions stay stock); stages without parts show a dimmed "—". pick_system AND set_model both feed the System chip. - NEW packages/ui/src/components/amicode-entity-rail.tsx — one-line re-export shim so packages/app imports resolve through the EXISTING `"./*": "./src/components/*.tsx"` export wildcard (packages/ui/package.json untouched). - stage.ts extended with pure helpers `railStage` + `chipTextFromSummary` (tolerant summary→chip parser: first parenthetical, comma tokens, rewrites omega=→ω=, delta=→δ=, drive_max=→cap, "N levels"→"N lvl", `=-`→`=−`; unknown tokens pass through raw; no/empty parenthetical → undefined). stage.test.ts: 9 tests / 20 expects, `bun test src/amicode` → 9 pass. - Stock-code touch #2 (rail mount): packages/app/src/pages/session/message-timeline.tsx — import at **line 20**, mount at **lines 1583–1584** (bottom of the sticky `data-session-title` header inside the timeline ScrollView: once per session view, sticky at top, no prompt overlap, no scroll theft; rail hidden when `showHeader()` is false — acceptable v0). Total stock-file touches across L2+L3: message-part.tsx (dispatch) + message-timeline.tsx (mount). 5. (final night batch) — amicode: ask buttons + start-screen wordmark + default locale en. a. ASK BUTTONS (`amicode_ask`, 7th tool in the pack). NEW packages/ui/src/amicode/{ask.ts, ask-bridge.ts, ask-card.tsx, ask.test.ts}. - - `parseAskInput` (pure): reads `question`/`options[]` from the tool part's INPUT args; trims, drops non-string/empty options; malformed → undefined → AmicodeToolCard falls back to the collapsed chip. - - `latestAssistantMessageID` (pure): ULID-max over role==="assistant" — staleness guard; buttons render disabled when the part is NOT in the last assistant message, after a click (local `picked` state), or when no bridge is registered (share page/read-only surfaces can never submit). - - Send path: ask-bridge module signal. The app registers it through the EXISTING rail mount — `AmicodeEntityRail` gained an `onAsk` prop; message-timeline passes `(text) => sdk.client.session.promptAsync({ sessionID, parts: [{type:"text", text}] })` — same endpoint the prompt input's submit uses (`model`/`agent` are OPTIONAL in the server PromptInput schema → session/agent defaults apply). Bridge unregisters on rail unmount. - - Stock-code touches (L2 family): packages/ui/src/components/message-part.tsx — `messageID={part().messageID}` added to the tool `` (1 line, needed for the staleness guard); packages/app/src/pages/session/message-timeline.tsx — the existing rail mount now passes `onAsk` (same insertion site as L3). - - Tests: `bun test src/amicode` → 14 pass / 33 expects (stage 9 + ask 5). - b. START-SCREEN WORDMARK (L1 family). packages/app/src/components/session/session-new-view.tsx — localized `session.new.title` headline ("Build anything" / "Créez ce que vous voulez") REPLACED by `` (AMICODE wordmark component, deliberately NOT localized — brand marks don't translate). Mark (amico face) above and localized repo-path/branch/subtitle lines kept as-is. (The new-design variant session-new-design-view.tsx already renders WordmarkV2 since L1.) - c. DEFAULT LOCALE = en (L1 family). packages/app/src/context/language.tsx — both init sites (`warm` line ~196 and provider `initial` line ~203) now default `readStoredLocale() ?? "en"` instead of `?? detectLocale()`; an explicit stored preference (`opencode.global.dat:language`) still wins. Rationale: only en.ts is branded tonight. REVERT CONDITION: restore `?? detectLocale()` at both sites once the other locales are branded (`detectLocale` is left in place, currently unreferenced). + - `parseAskInput` (pure): reads `question`/`options[]` from the tool part's INPUT args; trims, drops non-string/empty options; malformed → undefined → AmicodeToolCard falls back to the collapsed chip. + - `latestAssistantMessageID` (pure): ULID-max over role==="assistant" — staleness guard; buttons render disabled when the part is NOT in the last assistant message, after a click (local `picked` state), or when no bridge is registered (share page/read-only surfaces can never submit). + - Send path: ask-bridge module signal. The app registers it through the EXISTING rail mount — `AmicodeEntityRail` gained an `onAsk` prop; message-timeline passes `(text) => sdk.client.session.promptAsync({ sessionID, parts: [{type:"text", text}] })` — same endpoint the prompt input's submit uses (`model`/`agent` are OPTIONAL in the server PromptInput schema → session/agent defaults apply). Bridge unregisters on rail unmount. + - Stock-code touches (L2 family): packages/ui/src/components/message-part.tsx — `messageID={part().messageID}` added to the tool `` (1 line, needed for the staleness guard); packages/app/src/pages/session/message-timeline.tsx — the existing rail mount now passes `onAsk` (same insertion site as L3). + - Tests: `bun test src/amicode` → 14 pass / 33 expects (stage 9 + ask 5). + b. START-SCREEN WORDMARK (L1 family). packages/app/src/components/session/session-new-view.tsx — localized `session.new.title` headline ("Build anything" / "Créez ce que vous voulez") REPLACED by `` (AMICODE wordmark component, deliberately NOT localized — brand marks don't translate). Mark (amico face) above and localized repo-path/branch/subtitle lines kept as-is. (The new-design variant session-new-design-view.tsx already renders WordmarkV2 since L1.) + c. DEFAULT LOCALE = en (L1 family). packages/app/src/context/language.tsx — both init sites (`warm` line ~196 and provider `initial` line ~203) now default `readStoredLocale() ?? "en"` instead of `?? detectLocale()`; an explicit stored preference (`opencode.global.dat:language`) still wins. Rationale: only en.ts is branded tonight. REVERT CONDITION: restore `?? detectLocale()` at both sites once the other locales are branded (`detectLocale` is left in place, currently unreferenced). - Batch build (L1+L2+L3+ask/wordmark/locale) sha256: `a236b87fe92043eea6129a4a5b260ca2adc7fe51fc634e49405872973a7a60cb` at both `dist/opencode-local` and the vendored path. Verify: `GET /` → 200 `Amicode`; main chunk `/assets/index-Dwcv_zit.js` → 200, `AMICODE` ×1 (start-screen wordmark now in the MAIN bundle); session chunk `/assets/session-CGaG1HX5.js` → 200, `amicode-ask-card` ×1 + `amicode-entity-rail` ×1; `POST /session {}` → 200 session object; `bun test src/amicode` → 14 pass. - ⚠️ Vendor swap while the binary is RUNNING: plain `cp` fails with ETXTBSY. Use write-temp + `mv -f` (rename) — the running process keeps the old inode, next restart picks up the new file. Done that way this batch. 6. (getting-started) — amicode: start-screen getting-started block + starter chips. @@ -166,39 +169,48 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - LEGACY pending morning review: packages/ui/src/amicode/{ask-card.tsx, ask-bridge.ts, ask.ts hasUserReplyAfter/parseAskInput} + the message-part.tsx amicode_ask routing remain in place as the legacy renderer for deprecated amicode_ask calls — do NOT remove tonight. - Question-branding build sha256: `69070a33f8d5ebdff4f38f3d68b4be5625398b581accfb577945e1e9d522f44c` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify: `GET /` → 200 `Amicode`; AMICO display labels total 4 — session chunk `session-BfBMMSAH.js` ×3 (rail/chip/ask-card) + composer-state chunk `session-composer-state-D8UQPTE4.js` ×1 (question dock, `amicode-question-brand` slot ×1 — the dock compiles into the COMPOSER chunk, not the session chunk); css asset carries the accent border rule ×1; `POST /session {}` → 200; `bun test src/amicode` → 20 pass. 10. (H-glyph working spinner) — amicode: the session timeline's working/thinking spinner is the H-robot glyph. - - Design v2 per Aaron (binary-eye-frames concept DROPPED before commit): small monochrome H silhouette with the screen slit knocked out (single path, fill-rule=evenodd, currentColor — mount-site `style={{color}}` passes through like stock Spinner). - - Animation choice: gentle opacity pulse reusing the EXISTING `pulse-opacity` keyframes (styles/animations.css, 0.4→1) at 1.2s — chosen over Claude-style rotation because the H is non-radial and tumbles/blurs at 16px, and pulse matches the stock spinner's own animation language. prefers-reduced-motion: static glyph, NO animation (matchMedia guard; the repo's CSS-file media-query pattern doesn't cover inline animations). - - NEW packages/ui/src/amicode/spinner.tsx (`AmicoSpinner`, data-component="amico-spinner") + packages/ui/src/components/amico-spinner.tsx re-export shim. No pure frame logic remained after the design change → no new unit tests. - - Stock touch: packages/app/src/pages/session/message-timeline.tsx — import swap + `` replacing `` at the sticky-header working indicator (~line 1359); unused Spinner import removed. Other spinner sites left STOCK (list for morning): session-header.tsx:362 (header circle), home.tsx:848, sidebar-items.tsx:66+124, sidebar-workspace.tsx:102, dialog-connect-provider.tsx. - - Spinner build sha256: `83f5a9fd3b35155bf2014115b54a27ef3d9542e8b14c7a83925175a73c1f7ebc` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify: `GET /` → 200 `Amicode`; session chunk `session-CMqmIgk2.js` → `amico-spinner` ×1 + `pulse-opacity 1.2s` ×1; `POST /session {}` → 200; `bun test src/amicode` → 20 pass. - - SWEEP (Aaron: "no spinner" — the sticky-header site isn't where he looks): AmicoSpinner now at ALL visible working indicators: session-header.tsx:362 (top-right circle, size-3.5 + tint), message-part.tsx task-tool running row (~1841; inline 16x14 style because basic-tool.css sizes only [data-component="spinner"]), session-retry.tsx:58 (retry card), home.tsx:848, sidebar-items.tsx:66+124, sidebar-workspace.tsx:102 — each with the site's exact class/tint. Stock Spinner imports removed from swapped files. Left stock on purpose: dialog-connect-provider.tsx (provider setup, rarely seen), wsl/dialog-add-server.tsx (Windows-only). - - Sweep build sha256: `8aa718ad957607ff4d4da563d836a54451420dfb98d390a722ec09217bf2383c` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify: `GET /` → 200 `Amicode`; `amico-spinner` ×1 in served MAIN chunk `index-A7nOnjqy.js` — the component definition hoisted to the main bundle now that main-route sites (home/sidebar) import it; all call sites share that one definition, so marker count = 1 definition, not per-site; `pulse-opacity 1.2s` wired ×1; `POST /session {}` → 200; `bun test src/amicode` → 20 pass. + +- Design v2 per Aaron (binary-eye-frames concept DROPPED before commit): small monochrome H silhouette with the screen slit knocked out (single path, fill-rule=evenodd, currentColor — mount-site `style={{color}}` passes through like stock Spinner). +- Animation choice: gentle opacity pulse reusing the EXISTING `pulse-opacity` keyframes (styles/animations.css, 0.4→1) at 1.2s — chosen over Claude-style rotation because the H is non-radial and tumbles/blurs at 16px, and pulse matches the stock spinner's own animation language. prefers-reduced-motion: static glyph, NO animation (matchMedia guard; the repo's CSS-file media-query pattern doesn't cover inline animations). +- NEW packages/ui/src/amicode/spinner.tsx (`AmicoSpinner`, data-component="amico-spinner") + packages/ui/src/components/amico-spinner.tsx re-export shim. No pure frame logic remained after the design change → no new unit tests. +- Stock touch: packages/app/src/pages/session/message-timeline.tsx — import swap + `` replacing `` at the sticky-header working indicator (~line 1359); unused Spinner import removed. Other spinner sites left STOCK (list for morning): session-header.tsx:362 (header circle), home.tsx:848, sidebar-items.tsx:66+124, sidebar-workspace.tsx:102, dialog-connect-provider.tsx. +- Spinner build sha256: `83f5a9fd3b35155bf2014115b54a27ef3d9542e8b14c7a83925175a73c1f7ebc` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify: `GET /` → 200 `Amicode`; session chunk `session-CMqmIgk2.js` → `amico-spinner` ×1 + `pulse-opacity 1.2s` ×1; `POST /session {}` → 200; `bun test src/amicode` → 20 pass. +- SWEEP (Aaron: "no spinner" — the sticky-header site isn't where he looks): AmicoSpinner now at ALL visible working indicators: session-header.tsx:362 (top-right circle, size-3.5 + tint), message-part.tsx task-tool running row (~1841; inline 16x14 style because basic-tool.css sizes only [data-component="spinner"]), session-retry.tsx:58 (retry card), home.tsx:848, sidebar-items.tsx:66+124, sidebar-workspace.tsx:102 — each with the site's exact class/tint. Stock Spinner imports removed from swapped files. Left stock on purpose: dialog-connect-provider.tsx (provider setup, rarely seen), wsl/dialog-add-server.tsx (Windows-only). +- Sweep build sha256: `8aa718ad957607ff4d4da563d836a54451420dfb98d390a722ec09217bf2383c` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify: `GET /` → 200 `Amicode`; `amico-spinner` ×1 in served MAIN chunk `index-A7nOnjqy.js` — the component definition hoisted to the main bundle now that main-route sites (home/sidebar) import it; all call sites share that one definition, so marker count = 1 definition, not per-site; `pulse-opacity 1.2s` wired ×1; `POST /session {}` → 200; `bun test src/amicode` → 20 pass. + 11. (release prep) — amicode: darwin-arm64 cross-compile + release-asset packaging. - - packages/opencode/script/build.ts: `OPENCODE_BUILD_TARGETS` env filter (comma-separated artifact names, e.g. `opencode-linux-x64,opencode-darwin-arm64`); unset → stock behavior. Needed because stock offers only --single (current platform) or ALL 12 targets. - - Darwin cross-compile SUCCEEDED first attempt: run WITHOUT --skip-install (the cross-platform prebuild pulls of @opentui/core / @parcel/watcher / @ff-labs/fff-bun are required for non-native targets; they did NOT dirty the tree) and WITHOUT --single. Output: dist/opencode-darwin-arm64/bin/opencode (Mach-O 64-bit arm64, ~120 MB, not smoke-testable on linux). - - Determinism note: the linux-x64 binary from this run is BYTE-IDENTICAL to the sweep build (sha 8aa718ad…) — Bun compile is reproducible given identical source + models.dev snapshot. - - Release assets (upstream layout: flat `opencode` inside; from dist//bin: linux `tar -czf`, darwin `zip -r`), staged at dist/release/: - - opencode-linux-x64.tar.gz sha256 268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10 - - opencode-darwin-arm64.zip sha256 f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455 - (opencode.lock.json stores the ARCHIVE sha — fetch_opencode.mjs hashes the downloaded asset bytes. tar.gz embeds mtimes: upload THESE exact files, do not repack.) - - Publication status: push/release NOT executed — the permission system denied adding a push remote (`git remote add origin`); per agent policy the coordinator's message is not user consent, and this fork's standing guardrail is no push remote. Everything is staged for a human-run publish (commands in the handoff). + +- packages/opencode/script/build.ts: `OPENCODE_BUILD_TARGETS` env filter (comma-separated artifact names, e.g. `opencode-linux-x64,opencode-darwin-arm64`); unset → stock behavior. Needed because stock offers only --single (current platform) or ALL 12 targets. +- Darwin cross-compile SUCCEEDED first attempt: run WITHOUT --skip-install (the cross-platform prebuild pulls of @opentui/core / @parcel/watcher / @ff-labs/fff-bun are required for non-native targets; they did NOT dirty the tree) and WITHOUT --single. Output: dist/opencode-darwin-arm64/bin/opencode (Mach-O 64-bit arm64, ~120 MB, not smoke-testable on linux). +- Determinism note: the linux-x64 binary from this run is BYTE-IDENTICAL to the sweep build (sha 8aa718ad…) — Bun compile is reproducible given identical source + models.dev snapshot. +- Release assets (upstream layout: flat `opencode` inside; from dist//bin: linux `tar -czf`, darwin `zip -r`), staged at dist/release/: + - opencode-linux-x64.tar.gz sha256 268596e61475dd79d3076dd65d39bf97926d56c28477419250ab3c678bd55b10 + - opencode-darwin-arm64.zip sha256 f1d6291485246e03a3d33eefb40b4d264d68c8e5812b11b4ffdfec4444ab9455 + (opencode.lock.json stores the ARCHIVE sha — fetch_opencode.mjs hashes the downloaded asset bytes. tar.gz embeds mtimes: upload THESE exact files, do not repack.) +- Publication status: push/release NOT executed — the permission system denied adding a push remote (`git remote add origin`); per agent policy the coordinator's message is not user consent, and this fork's standing guardrail is no push remote. Everything is staged for a human-run publish (commands in the handoff). + 12. (vaults panel tab) — amicode: "Vaults" tab in the connections popover + `GET /amicode/vaults` endpoint + Manage-vaults prompt handoff. Spec/plan: personal vault `spec-20260703-140141-amicode-vaults-panel-tab` / `plan-20260703-143356-amicode-vaults-panel-tab` (rollout step 6, panel half, of the amico-vault CLI spec; data source = `amico-vault status --json`, harmoniqs/amico PR #184). - - FIRST SERVER-SIDE STOCK TOUCH in the stack: packages/opencode/src/server/routes/instance/httpapi/server.ts — import + `amicodeVaultsRoute` const below docRoute (exact docRoute idiom: raw `HttpRouter.use`, `authOnlyRouterLayer`) + one `Layer.mergeAll` entry (before uiRoute). Endpoint logic in NEW packages/opencode/src/server/amicode/vaults.ts: CLI resolution ($AMICO_VAULT_BIN → $AMICO_OPS/scripts/amico-vault → ~/.amico/ops/scripts/amico-vault → PATH via Bun.which), `AbortSignal.timeout(8s)` deadline (the process-util `timeout` opt is only SIGTERM→SIGKILL grace — it never arms a deadline without `abort`), 10 s response cache, verbatim JSON relay; every failure synthesized into the SAME plural shape `{ok:false, mounts:[], error:": "}` (codes cli_not_found/timeout/exit_/bad_output) — status() never rejects. Tests: packages/opencode/test/server/amicode-vaults.test.ts (3). - - NEW packages/ui/src/amicode/vaults.ts — tolerant single consumer of the wire shape (`last_sync` is git %cr prose or literal "unknown", rendered verbatim, "unknown"/absent → "—"; non-array/singular mounts → bad_shape; per-entry field tolerance) + vaults.test.ts (9). NEW packages/ui/src/amicode/vaults-tab.tsx (`AmicodeVaultsTab`, data-component="amicode-vaults-tab"; rows: warn/clean dot [inlined `warnings.length` check — conscious deviation from the spec's "tested helper", trivial enough that a helper would be ceremony], id, dim kind, rw/ro chip, right-aligned last_sync, dim first-warning second line "+N more"; error state carries a Retry link `amicode-vaults-retry`; skeleton when view undefined; exports AMICODE_MANAGE_VAULTS_PROMPT) + components/amicode-vaults-tab.tsx re-export shim. - - Stock touches (app): status-popover-body.tsx — `createResource` fetch per-active-server (source = shown() && ServerConnection.key → refetch on open/server-switch; Basic auth via the EXISTING authTokenFromCredentials from utils/server.ts; rejected fetch → same error+Retry state), vaults Tabs.Trigger with count badge + Tabs.Content (list + "Manage vaults" secondary Button), props widened `{shown, onClose?}`; status-popover.tsx — `onClose={() => setShown(false)}` threaded at BOTH Body mounts (v1 line ~70, DirectoryStatusPopover body thunk); ServerStatusPopover/ServerBody untouched. en.ts — 5 keys after manageServers (tab.vaults, action.manageVaults, dialog.vaults.{empty,retry,fetchFailed}). - - Manage-vaults mechanism: session-new-view.tsx's local startPrompt closure EXTRACTED to packages/app/src/utils/start-prompt.ts (takes the resolved prompt context — must not call usePrompt() itself); session-new-view refactored to a thin wrapper (behavior-preserving); popover onManage = onClose() + startPrompt(prompt, AMICODE_MANAGE_VAULTS_PROMPT). Prompt sentence doubles as the future vault-setup score's onset trigger. - - Vaults build sha256: `a94226125bc8a8fa9022be8bcdecb2cef2956c19e1955a3dd5031e70de2c6eb1` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify (scratch ports 43197-43199): `GET /amicode/vaults` → 200 relaying live CLI JSON (3 mounts, last_sync "24 minutes ago"/"7 days ago" prose); CLI-absent env (fake HOME, stripped PATH) → `{"ok":false,"mounts":[],"error":"cli_not_found: amico-vault not found — is amico installed?"}`; auth parity with /doc under OPENCODE_SERVER_PASSWORD (noauth 401 / auth 200, both routes); `GET /` → 200 `Amicode`; `POST /session {}` → 200; chunk check: `status-popover-body-WTKgcSgC.js` → `amicode-vaults-tab` ×1 (served 200), "Manage vaults" i18n in `index-DVjD35cF.js`. Tests: `bun test src/amicode` → 29 pass; opencode amicode-vaults tests → 3 pass; typecheck green in ui/app/opencode. Interactive click-through (tab render, Manage handoff) NOT yet human-verified — deferred to dev-host acceptance on 43117. + +- FIRST SERVER-SIDE STOCK TOUCH in the stack: packages/opencode/src/server/routes/instance/httpapi/server.ts — import + `amicodeVaultsRoute` const below docRoute (exact docRoute idiom: raw `HttpRouter.use`, `authOnlyRouterLayer`) + one `Layer.mergeAll` entry (before uiRoute). Endpoint logic in NEW packages/opencode/src/server/amicode/vaults.ts: CLI resolution ($AMICO*VAULT_BIN → $AMICO_OPS/scripts/amico-vault → ~/.amico/ops/scripts/amico-vault → PATH via Bun.which), `AbortSignal.timeout(8s)` deadline (the process-util `timeout` opt is only SIGTERM→SIGKILL grace — it never arms a deadline without `abort`), 10 s response cache, verbatim JSON relay; every failure synthesized into the SAME plural shape `{ok:false, mounts:[], error:": "}` (codes cli_not_found/timeout/exit*/bad_output) — status() never rejects. Tests: packages/opencode/test/server/amicode-vaults.test.ts (3). +- NEW packages/ui/src/amicode/vaults.ts — tolerant single consumer of the wire shape (`last_sync` is git %cr prose or literal "unknown", rendered verbatim, "unknown"/absent → "—"; non-array/singular mounts → bad_shape; per-entry field tolerance) + vaults.test.ts (9). NEW packages/ui/src/amicode/vaults-tab.tsx (`AmicodeVaultsTab`, data-component="amicode-vaults-tab"; rows: warn/clean dot [inlined `warnings.length` check — conscious deviation from the spec's "tested helper", trivial enough that a helper would be ceremony], id, dim kind, rw/ro chip, right-aligned last_sync, dim first-warning second line "+N more"; error state carries a Retry link `amicode-vaults-retry`; skeleton when view undefined; exports AMICODE_MANAGE_VAULTS_PROMPT) + components/amicode-vaults-tab.tsx re-export shim. +- Stock touches (app): status-popover-body.tsx — `createResource` fetch per-active-server (source = shown() && ServerConnection.key → refetch on open/server-switch; Basic auth via the EXISTING authTokenFromCredentials from utils/server.ts; rejected fetch → same error+Retry state), vaults Tabs.Trigger with count badge + Tabs.Content (list + "Manage vaults" secondary Button), props widened `{shown, onClose?}`; status-popover.tsx — `onClose={() => setShown(false)}` threaded at BOTH Body mounts (v1 line ~70, DirectoryStatusPopover body thunk); ServerStatusPopover/ServerBody untouched. en.ts — 5 keys after manageServers (tab.vaults, action.manageVaults, dialog.vaults.{empty,retry,fetchFailed}). +- Manage-vaults mechanism: session-new-view.tsx's local startPrompt closure EXTRACTED to packages/app/src/utils/start-prompt.ts (takes the resolved prompt context — must not call usePrompt() itself); session-new-view refactored to a thin wrapper (behavior-preserving); popover onManage = onClose() + startPrompt(prompt, AMICODE_MANAGE_VAULTS_PROMPT). Prompt sentence doubles as the future vault-setup score's onset trigger. +- Vaults build sha256: `a94226125bc8a8fa9022be8bcdecb2cef2956c19e1955a3dd5031e70de2c6eb1` (dist/opencode-local + vendored path, write-temp + mv -f swap). Verify (scratch ports 43197-43199): `GET /amicode/vaults` → 200 relaying live CLI JSON (3 mounts, last_sync "24 minutes ago"/"7 days ago" prose); CLI-absent env (fake HOME, stripped PATH) → `{"ok":false,"mounts":[],"error":"cli_not_found: amico-vault not found — is amico installed?"}`; auth parity with /doc under OPENCODE_SERVER_PASSWORD (noauth 401 / auth 200, both routes); `GET /` → 200 `Amicode`; `POST /session {}` → 200; chunk check: `status-popover-body-WTKgcSgC.js` → `amicode-vaults-tab` ×1 (served 200), "Manage vaults" i18n in `index-DVjD35cF.js`. Tests: `bun test src/amicode` → 29 pass; opencode amicode-vaults tests → 3 pass; typecheck green in ui/app/opencode. Interactive click-through (tab render, Manage handoff) NOT yet human-verified — deferred to dev-host acceptance on 43117. + 13. (problem UI — spec B) — amicode: problem-header rail + diff receipts + entity view + problem switcher + `GET /amicode/problems|problem|run-status`. Spec/plan: personal vault `spec-20260703-182503-amicode-problem-ui` / `plan-20260703-200416-amicode-problem-ui` (three-ring interaction model; depends on spec A's workspaces on amicode branch `aaron/problem-workspaces`). Commits `fc67485ad2..e6eee73c4c` (9). - - SERVER (second stock touch to server.ts): NEW packages/opencode/src/server/amicode/problems.ts — reads `~/.amico/problems` (env `AMICODE_PROBLEMS_DIR`) + `~/.amico/runs` (env `AMICODE_RUNS_DIR`) directly, `.json` sidecars only; one-shape failure synthesis per route (codes no_problems_dir / not_found: / bad_json / bad_output); TTL caches 10 s (problems/problem) + 1 s (run-status). `score_stages` resolution chain: `problem.score.id` → workspace `interview_state.json` `score_id` (spec A never stamps problem.score — the fallback is what makes pending chips live today) → must equal root `score_manifest.json` id, else `[]`. run-status rules: FINISHED+parseable result.toml → finished; FINISHED alone → failed; else solving with last `AMICODE_ITER` (raw objective f on the wire; client renders F=1−f only when 0≤f≤1). Routes registered via Effect.gen + `yield* router.add` (chained `.add()` does NOT compose — add returns an Effect; first attempt broke 161 server tests, caught pre-commit) under authOnlyRouterLayer; `new URL(request.url, "http://localhost")` (relative request.url, pty.ts idiom). Tests: test/server/amicode-problems.test.ts (11). - - UI PURE: NEW packages/ui/src/amicode/receipt.ts — single consumer of the AMICODE_DIFF sentinel (strict last-line parse; any failure → undefined → legacy chip, old sessions keep rendering; `…` elision key → bare ellipsis; stripSentinel exported/tested but NO current consumer — card never renders raw output, noted per spec's strip rule) + receipt.test.ts (6). NEW problem.ts — single consumer of the three endpoint wire shapes: tolerant parsers, per-kind chip renderers + generic fallback, pending merge (score_stages × present), runChipText, railState (loading/ready/stale-last-good/unavailable), entityRows/historyRows/editPromptText + problem.test.ts (14). NEW ui-bridge.ts (receipt click → rail-registered openEntity, ask-bridge idiom). - - RAIL REWRITE: entity-rail.tsx — session gate unchanged (≥1 amicode_* part); createResource on the completed-part counter (the one implemented refetch trigger; "section open" N/A for an always-visible rail — dialogs fetch on open; server switch handled per-call by amicodeGet resolving the connection each invocation); data-driven chips + dimmed pending chips; run chip polls /amicode/run-status @2.5 s while unfinished, feeds ONLY the run chip, NEVER triggers a problem refetch (spec's #67 transposition), stops on all-terminal + unmount; failure states: stale-last-good renders normally, no-data → `status unavailable · Retry`. DELETED from stage.ts: chipTextFromSummary/TOKEN_REWRITES/railStage/RAIL_STAGES (prose-regex path gone; amicodeStage kept as card fallback label). - - CARD → RECEIPTS: card.tsx parses the part's `output` (message-part.tsx already passed it — zero new stock touches) → one-line `AMICO · System · levels 3→4` receipt, click → entity view via ui-bridge; no-sentinel fallback = the old status chip. - - RING-2 DIALOGS: NEW entity-view.tsx (fields table + per-field ✎ → draftPrompt handoff; history newest-first with seq anchor [missing-from-window → unanchored, no error] incl. event source tool/stage; run tier chip, free = warning "free · unvetted"; KaTeX Hamiltonians for transmon/rydberg via the fork's existing katex) + problem-switcher.tsx (rows: active dot, status, N entities, recorded; Open/New = startPrompt SUBMIT handoffs — UI stays write-free, the agent's amicode_problem moves the active pointer) + re-export shims (also exporting the parsers, vaults-tab idiom). - - APP WIRING: utils/start-prompt.ts gains `draftPrompt` (draft+focus, no submit — the fallback branch promoted); NEW utils/amicode-fetch.ts (per-active-server Basic-auth GET, connection resolved per call); message-timeline.tsx — open-gated createResources for the two dialogs (cleared in dialog.show onClose so reopen refetches), dialog.show wiring, rail props; session-new-view.tsx + getting-started.tsx — conditional Resume chip from /amicode/problems (most-recent non-archived; the STATIC "Resume my pulse design" starter REMOVED per no-empty-chrome); en.ts — 7 `amicode.*` keys. - - AMICODE-REPO SIDE (branch aaron/problem-workspaces, commit d2e2536): AGENTS.md + SCORE.md announce the rail ONCE when the first entity records (score version kept at 3 — guidance prose, not a stage-contract change; a bump would reset in-flight interview state). #47/#48 `problem` ref field deferred via comment on amicode#47. - - Tests: ui `bun test src` → 70 pass / 0 fail; opencode FULL `bun test` → 3000 pass / 0 fail / 2 skip / 1 todo (skips pre-existing, not amicode); typecheck green in ui + app + opencode. - - Problem-UI build sha256: `a73d858373931826ef0ba6a6c4bd741a065516ba8bb3bd4cf5ff559ee9729289` (dist/opencode-local + vendored path, write-temp + mv -f swap). ⚠️ build INCLUDES uncommitted branding WIP present in the tree (RacingSansOne wordmark: index.css @font-face + logo.tsx/wordmark-v2.tsx family swap + woff2 asset, mtimes 21:10-21:11 — NOT part of this patch, left unstaged for its author). Verify (scratch ports 14097/14098): all three endpoints 200 against the LIVE workspace `untitled-2026-07-03` — problems lists it active, problem returns system entity + `score_stages ["system","formulation","run","pulse","device_session"]` resolved through interview_state.json + 3 events, run-status `{ok:true,runs:[]}`; auth parity with /doc under OPENCODE_SERVER_PASSWORD (noauth 401/401, auth 200×3); `GET /` → 200 `Amicode`; chunk check: session chunk `session-Dgq0NnUP.js` → amicode-rail-problem/amicode-card-receipt/AMICODE_DIFF/amicode-entity-view ×1 each + amicode.problems.title ×2, composer chunk `session-composer-state-DvDmcuNl.js` → amicode-problem-switcher + amicode-gs-resume ×1 each. Interactive click-through (rail bind, receipt click, switcher/entity dialogs, Resume chip) NOT yet human-verified — deferred to dev-host acceptance on the extension-launched server (43117), same as patch 12. + +- SERVER (second stock touch to server.ts): NEW packages/opencode/src/server/amicode/problems.ts — reads `~/.amico/problems` (env `AMICODE_PROBLEMS_DIR`) + `~/.amico/runs` (env `AMICODE_RUNS_DIR`) directly, `.json` sidecars only; one-shape failure synthesis per route (codes no_problems_dir / not_found: / bad_json / bad_output); TTL caches 10 s (problems/problem) + 1 s (run-status). `score_stages` resolution chain: `problem.score.id` → workspace `interview_state.json` `score_id` (spec A never stamps problem.score — the fallback is what makes pending chips live today) → must equal root `score_manifest.json` id, else `[]`. run-status rules: FINISHED+parseable result.toml → finished; FINISHED alone → failed; else solving with last `AMICODE_ITER` (raw objective f on the wire; client renders F=1−f only when 0≤f≤1). Routes registered via Effect.gen + `yield* router.add` (chained `.add()` does NOT compose — add returns an Effect; first attempt broke 161 server tests, caught pre-commit) under authOnlyRouterLayer; `new URL(request.url, "http://localhost")` (relative request.url, pty.ts idiom). Tests: test/server/amicode-problems.test.ts (11). +- UI PURE: NEW packages/ui/src/amicode/receipt.ts — single consumer of the AMICODE_DIFF sentinel (strict last-line parse; any failure → undefined → legacy chip, old sessions keep rendering; `…` elision key → bare ellipsis; stripSentinel exported/tested but NO current consumer — card never renders raw output, noted per spec's strip rule) + receipt.test.ts (6). NEW problem.ts — single consumer of the three endpoint wire shapes: tolerant parsers, per-kind chip renderers + generic fallback, pending merge (score_stages × present), runChipText, railState (loading/ready/stale-last-good/unavailable), entityRows/historyRows/editPromptText + problem.test.ts (14). NEW ui-bridge.ts (receipt click → rail-registered openEntity, ask-bridge idiom). +- RAIL REWRITE: entity-rail.tsx — session gate unchanged (≥1 amicode\_\* part); createResource on the completed-part counter (the one implemented refetch trigger; "section open" N/A for an always-visible rail — dialogs fetch on open; server switch handled per-call by amicodeGet resolving the connection each invocation); data-driven chips + dimmed pending chips; run chip polls /amicode/run-status @2.5 s while unfinished, feeds ONLY the run chip, NEVER triggers a problem refetch (spec's #67 transposition), stops on all-terminal + unmount; failure states: stale-last-good renders normally, no-data → `status unavailable · Retry`. DELETED from stage.ts: chipTextFromSummary/TOKEN_REWRITES/railStage/RAIL_STAGES (prose-regex path gone; amicodeStage kept as card fallback label). +- CARD → RECEIPTS: card.tsx parses the part's `output` (message-part.tsx already passed it — zero new stock touches) → one-line `AMICO · System · levels 3→4` receipt, click → entity view via ui-bridge; no-sentinel fallback = the old status chip. +- RING-2 DIALOGS: NEW entity-view.tsx (fields table + per-field ✎ → draftPrompt handoff; history newest-first with seq anchor [missing-from-window → unanchored, no error] incl. event source tool/stage; run tier chip, free = warning "free · unvetted"; KaTeX Hamiltonians for transmon/rydberg via the fork's existing katex) + problem-switcher.tsx (rows: active dot, status, N entities, recorded; Open/New = startPrompt SUBMIT handoffs — UI stays write-free, the agent's amicode_problem moves the active pointer) + re-export shims (also exporting the parsers, vaults-tab idiom). +- APP WIRING: utils/start-prompt.ts gains `draftPrompt` (draft+focus, no submit — the fallback branch promoted); NEW utils/amicode-fetch.ts (per-active-server Basic-auth GET, connection resolved per call); message-timeline.tsx — open-gated createResources for the two dialogs (cleared in dialog.show onClose so reopen refetches), dialog.show wiring, rail props; session-new-view.tsx + getting-started.tsx — conditional Resume chip from /amicode/problems (most-recent non-archived; the STATIC "Resume my pulse design" starter REMOVED per no-empty-chrome); en.ts — 7 `amicode.*` keys. +- AMICODE-REPO SIDE (branch aaron/problem-workspaces, commit d2e2536): AGENTS.md + SCORE.md announce the rail ONCE when the first entity records (score version kept at 3 — guidance prose, not a stage-contract change; a bump would reset in-flight interview state). #47/#48 `problem` ref field deferred via comment on amicode#47. +- Tests: ui `bun test src` → 70 pass / 0 fail; opencode FULL `bun test` → 3000 pass / 0 fail / 2 skip / 1 todo (skips pre-existing, not amicode); typecheck green in ui + app + opencode. +- Problem-UI build sha256: `a73d858373931826ef0ba6a6c4bd741a065516ba8bb3bd4cf5ff559ee9729289` (dist/opencode-local + vendored path, write-temp + mv -f swap). ⚠️ build INCLUDES uncommitted branding WIP present in the tree (RacingSansOne wordmark: index.css @font-face + logo.tsx/wordmark-v2.tsx family swap + woff2 asset, mtimes 21:10-21:11 — NOT part of this patch, left unstaged for its author). Verify (scratch ports 14097/14098): all three endpoints 200 against the LIVE workspace `untitled-2026-07-03` — problems lists it active, problem returns system entity + `score_stages ["system","formulation","run","pulse","device_session"]` resolved through interview_state.json + 3 events, run-status `{ok:true,runs:[]}`; auth parity with /doc under OPENCODE_SERVER_PASSWORD (noauth 401/401, auth 200×3); `GET /` → 200 `Amicode`; chunk check: session chunk `session-Dgq0NnUP.js` → amicode-rail-problem/amicode-card-receipt/AMICODE_DIFF/amicode-entity-view ×1 each + amicode.problems.title ×2, composer chunk `session-composer-state-DvDmcuNl.js` → amicode-problem-switcher + amicode-gs-resume ×1 each. Interactive click-through (rail bind, receipt click, switcher/entity dialogs, Resume chip) NOT yet human-verified — deferred to dev-host acceptance on the extension-launched server (43117), same as patch 12. + 14. (brand fonts) — amicode: AMICODE wordmark → "Racing Sans One"; default code/monospace → "JuliaMono" (Aaron's change, committed + built on his heads-up; it was the uncommitted WIP flagged in #13's build note). Commit `c155bf499b`. - - 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. + +- 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. diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 18846fceb6..51ca5f1481 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -9,6 +9,17 @@ localStorage.removeItem("opencode-theme-css-dark") } + // amicode: VS Code webview bridge — the extension passes ?colorScheme=light|dark + // (from vscode.window.activeColorTheme). Seed the storage key BEFORE the read + // below so the app boots in the editor's theme (prefers-color-scheme inside a + // webview iframe reports the OS, not VS Code). + try { + var q = new URLSearchParams(location.search).get("colorScheme") + if (q === "light" || q === "dark") localStorage.setItem("opencode-color-scheme", q) + } catch (e) { + /* storage unavailable — keep defaults */ + } + var scheme = localStorage.getItem("opencode-color-scheme") || "system" var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) var mode = isDark ? "dark" : "light" diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5bf18b657a..6a1bc0c167 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -7,7 +7,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme/context" +import { ThemeProvider, useTheme } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -50,6 +50,7 @@ import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { AmicodeSplash } from "@opencode-ai/ui/amicode-splash" const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -227,6 +228,35 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } +/** amicode: live theme bridge for the VS Code webview host. The extension + * forwards editor theme changes as window messages (outer relay → iframe); + * route them through the existing setColorScheme so everything re-themes. */ +function AmicodeThemeBridge() { + const theme = useTheme() + const onMsg = (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string; colorScheme?: string } | undefined + if (d?.source !== "amicode" || d.kind !== "theme") return + if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) + } + window.addEventListener("message", onMsg) + onCleanup(() => window.removeEventListener("message", onMsg)) + // ⌘⇧P / Ctrl+Shift+P: when embedded in the amicode webview (we have a + // parent), the EDITOR's Command Palette wins over the app's own palette — + // capture-phase so the in-app binding never sees it; forwarded over the + // existing allowlisted command bridge. + const onKey = (e: KeyboardEvent) => { + if (window.parent === window) return + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "p" || e.key === "P")) { + e.preventDefault() + e.stopPropagation() + window.parent.postMessage({ source: "amicode", kind: "command", command: "workbench.action.showCommands" }, "*") + } + } + window.addEventListener("keydown", onKey, { capture: true }) + onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true })) + return null +} + export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -236,6 +266,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { void window.api?.setTitlebar?.({ mode }) }} > + ) { when={!checking()} fallback={
- +
} > diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 94d1a17205..0b6a9a9389 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -25,6 +25,11 @@ import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" import { StatusPopover, StatusPopoverV2 } from "../status-popover" + +// AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator +// noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is +// where a solver-health panel (server/Julia env/runs dir) belongs later. +const AMICODE_HIDE_STATUS_POPOVER = true import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" @@ -439,7 +444,7 @@ export function SessionHeader() {
- + @@ -531,7 +536,7 @@ type SessionHeaderV2ActionsState = { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { return (
- + diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index d0fa6800a8..b70f8fb9ab 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 { Logo, Mark } from "@opencode-ai/ui/logo" +import { Mark } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" const MAIN_WORKTREE = "main" @@ -79,8 +79,18 @@ export function NewSessionView(props: NewSessionViewProps) {
- {/* amicode: brand wordmark replaces the localized headline — brand marks don't translate */} - + {/* amicode: straight wordmark (the Logo's Racing Sans One face reads as + italic); byline keeps the brand attribution. */} +
+ AMICODE +
{/* amicode: getting-started block (tagline + how-it-works + starter chips) */} Promise @@ -862,14 +864,22 @@ function ProjectTabAvatar(props: { const directory = () => props.directory const sessionId = () => props.sessionId const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) + // amicode: brand mark instead of the letter project-avatar ("blue A"). Idle = + // static Mark; while the agent works, Aaron's H-robot spinner (gentle pulse, + // honors prefers-reduced-motion) replaces the radar ring. Unread keeps a dot. return ( - + + }> + + + + + ) } diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e979ad6a05..c9a4772121 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -367,6 +367,15 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const modified = event.ctrlKey || event.metaKey || event.altKey const isTab = event.key === "Tab" + // Clipboard/undo chords belong to the focused editor — never to the app + // keymap — when typing in an input/textarea (paste into profile fields + // was being swallowed by a colliding keybind). + const isClipboardChord = + (event.metaKey || event.ctrlKey) && + !event.altKey && + ["v", "c", "x", "a", "z", "y"].includes(event.key.toLowerCase()) + if (isEditableTarget(event.target) && isClipboardChord && !isPalette) return + if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab) return diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index b759713d80..aa0d177af6 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,6 +1,19 @@ import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" import type { Session } from "@opencode-ai/sdk/v2/client" -import { batch, createEffect, createMemo, createResource, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" +import { + batch, + createEffect, + createMemo, + createResource, + For, + Match, + on, + onCleanup, + onMount, + Show, + Switch, + createSignal, +} from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" @@ -47,12 +60,16 @@ import { useSettings } from "@/context/settings" import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" -import { amicodeGet } from "@/utils/amicode-fetch" +import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" +import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" import { AMICODE_STARTERS } from "@opencode-ai/ui/amicode-getting-started" import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" import { parseProblemResponse } from "@opencode-ai/ui/amicode-entity-view" +import { Mark } from "@opencode-ai/ui/logo" +import { AmicodeFooter } from "@opencode-ai/ui/amicode-footer" const HOME_SESSION_LIMIT = 64 const HOME_ROW_LAYOUT = @@ -207,7 +224,7 @@ function HomeDesign() { // amicode: home-card data. All read from the focused server's /amicode/* raw // routes (~/.amico). Each degrades to undefined on failure so a card simply // doesn't render (no empty chrome) rather than erroring the whole page. - const [profileRaw] = createResource( + const [profileRaw, { refetch: refetchProfile }] = createResource( () => state.selection.server, () => amicodeGet(focusedServer(), "/amicode/profile").catch(() => undefined), ) @@ -249,6 +266,23 @@ function HomeDesign() { // in-flight run. Polls run-status + run-series only while a run is solving; the // interval self-terminates once status leaves "solving". All failures collapse // to undefined so the card simply doesn't render. + // amicode: run gallery (shareable solve cards) — fetched on open, newest first. + const [galleryOpen, setGalleryOpen] = createSignal(false) + const [runCardsRaw] = createResource( + () => (galleryOpen() ? state.selection.server : undefined), + () => amicodeGet(focusedServer(), "/amicode/run-cards").catch(() => undefined), + ) + const runCards = createMemo(() => { + if (!galleryOpen()) return undefined + const raw = runCardsRaw() + return raw === undefined ? undefined : parseRunCardsResponse(raw) + }) + // PNG save: downloads are dead inside the webview iframe — route through the + // extension's save-file bridge; plain browsers use the gallery's . + const saveCardPng = (filename: string, dataUrl: string) => { + window.parent.postMessage({ source: "amicode", kind: "save-file", filename, dataUrl }, "*") + } + const [runStatusRaw, { refetch: refetchRunStatus }] = createResource( () => state.selection.server, () => amicodeGet(focusedServer(), "/amicode/run-status").catch(() => undefined), @@ -269,7 +303,8 @@ function HomeDesign() { const solvingRunId = createMemo(() => solvingRun()?.runId) const [runSeriesRaw, { refetch: refetchRunSeries }] = createResource( () => solvingRunId(), - (runId) => amicodeGet(focusedServer(), `/amicode/run-series?run=${encodeURIComponent(runId)}`).catch(() => undefined), + (runId) => + amicodeGet(focusedServer(), `/amicode/run-series?run=${encodeURIComponent(runId)}`).catch(() => undefined), ) const liveRun = createMemo(() => { const solving = solvingRun() @@ -290,14 +325,20 @@ function HomeDesign() { } }) createEffect(() => { - if (!solvingRunId()) return - const timer = setInterval(() => { - void refetchRunStatus() - void refetchRunSeries() - }, 2500) + // Standing slow poll while Home is mounted (a solve STARTED from chat must + // surface here without a remount), tightening to 2.5s while one is live. + const live = Boolean(solvingRunId()) + const timer = setInterval( + () => { + void refetchRunStatus() + if (live) void refetchRunSeries() + }, + live ? 2500 : 5000, + ) onCleanup(() => clearInterval(timer)) }) + const [sessionsExpanded, setSessionsExpanded] = createSignal(false) function startWithPrompt(prompt: string) { const project = newSessionProject() if (!project) { @@ -449,106 +490,157 @@ function HomeDesign() { return (
-
+
{/* Top band: Projects (left) + chat sessions (right), fused as one element (~1/3) */} -
- void chooseProject(conn)} - editProject={editProject} - closeProject={(conn, directory) => { - const next = closeHomeProject( - state.selection, - ServerConnection.key(conn), - global.createServerCtx(conn).projects, - directory, - ) - if (next) setSelection(next) - }} - clearNotifications={clearNotifications} - unseenCount={unseenCount} - openSettings={openSettings} - openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} - language={language} - /> - -
- { - focusSessionSearch = focus + void chooseProject(conn)} + editProject={editProject} + closeProject={(conn, directory) => { + const next = closeHomeProject( + state.selection, + ServerConnection.key(conn), + global.createServerCtx(conn).projects, + directory, + ) + if (next) setSelection(next) }} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} + clearNotifications={clearNotifications} + unseenCount={unseenCount} + openSettings={openSettings} + language={language} /> - -
- } - > + +
+ { + focusSessionSearch = focus + }} + onInput={(value) => setState("search", value)} + onFocus={() => setState("searchFocused", true)} + onClose={closeSearch} + onSelect={selectSearchSession} + /> + +
0} - fallback={ -
- -
- } + when={!sessionLoad.isLoading} + fallback={} > - - {(group, index) => ( + 0} + fallback={
-
- - {(record) => ( - +
+ } + > + {(() => { + // amicode: collapsed = the 3 most recent sessions across all + // groups; "Show all (N)" expands. Pure derivation off the + // existing groups() memo — no second data path to drift. + const total = () => groups().reduce((n, g) => n + g.sessions.length, 0) + const visible = () => { + if (sessionsExpanded() || total() <= 3) return groups() + let budget = 3 + const out: typeof groups extends () => infer G ? G : never = [] as never + for (const g of groups()) { + if (budget <= 0) break + const take = g.sessions.slice(0, budget) + budget -= take.length + ;(out as { title: string; sessions: typeof g.sessions }[]).push({ ...g, sessions: take }) + } + return out as ReturnType + } + return ( + <> + + {(group, index) => ( +
+ +
+ + {(record) => ( + + )} + +
+
)}
-
-
- )} - + 3}> + + + + ) + })()} + - -
-
-
+
+ +
{/* Cards: full width across, sized to content so they never scroll */} -
+
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() + }} resumeName={resumeProblem()?.name} resumeMeta={resumeMeta()} onResume={() => { @@ -556,6 +648,7 @@ function HomeDesign() { if (name) startWithPrompt(`Open the problem "${name}" and continue where we left off`) }} onWarmStart={() => startWithPrompt("warm-start a new solve from my pulse bank")} + onOpenGallery={() => setGalleryOpen(true)} liveRun={liveRun()} onOpenLiveRun={() => { const name = resumeProblem()?.name @@ -563,6 +656,14 @@ function HomeDesign() { }} />
+ + + setGalleryOpen(false)} + onSave={window.parent !== window ? saveCardPng : undefined} + /> +
) @@ -580,16 +681,34 @@ function HomeProjectColumn(props: { clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void unseenCount: (server: ServerConnection.Any, project: LocalProject) => number openSettings: () => void - openHelp: () => void language: ReturnType }) { const global = useGlobal() const dialog = useDialog() const controller = useServerManagementController({ navigateOnAdd: false }) return ( -
{import.meta.env.DEV && }
-
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 5badf6f0ca..5c23705090 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -440,14 +440,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit "line-height": "14px", "font-weight": "700", "letter-spacing": "0.08em", - "color": "var(--v2-text-text-accent)", + color: "var(--v2-text-text-accent)", }} > AMICO{" "} - + · {" "} - + Question
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 20d64c6005..c6329b39c5 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -335,18 +335,22 @@ export function MessageTimeline(props: { dialog.show( () => ( - { - dialog.close() - draftPrompt(prompt, text) - }} - onRetry={() => void refetchProblem()} - retryLabel={language.t("amicode.retry")} - editLabel={language.t("amicode.editInChat")} - /> + {/* fit-to-content goes near-fullscreen on run entities (long paths + + history) — cap the panel and let it scroll internally instead */} +
+ { + dialog.close() + draftPrompt(prompt, text) + }} + onRetry={() => void refetchProblem()} + retryLabel={language.t("amicode.retry")} + editLabel={language.t("amicode.editInChat")} + /> +
), () => setEntityViewOpen(false), diff --git a/packages/app/src/utils/amicode-fetch.ts b/packages/app/src/utils/amicode-fetch.ts index 5bdde7fdc2..9a0873753b 100644 --- a/packages/app/src/utils/amicode-fetch.ts +++ b/packages/app/src/utils/amicode-fetch.ts @@ -17,3 +17,19 @@ export async function amicodeGet(conn: ServerConnection.Any | undefined, route: if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as unknown } + +/** 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 { + if (!conn) throw new Error("no active server") + const headers: Record = {} + if (conn.http.password) + headers.Authorization = `Basic ${authTokenFromCredentials({ + username: conn.http.username, + password: conn.http.password, + })}` + const res = await fetch(new URL(route, conn.http.url), { method: "POST", headers }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as unknown +} diff --git a/packages/app/src/wsl/settings-model.test.ts b/packages/app/src/wsl/settings-model.test.ts index 77a5f55d64..1eb051057d 100644 --- a/packages/app/src/wsl/settings-model.test.ts +++ b/packages/app/src/wsl/settings-model.test.ts @@ -22,7 +22,7 @@ describe("WSL server settings presentation", () => { matchesDesktop: null, error: null, }), - ).toBe("Install OpenCode") + ).toBe("Install Amicode") expect( wslOpencodeAction({ distro: "Debian", @@ -32,7 +32,7 @@ describe("WSL server settings presentation", () => { matchesDesktop: false, error: null, }), - ).toBe("Update OpenCode") + ).toBe("Update Amicode") expect( wslOpencodeAction({ distro: "Debian", diff --git a/packages/opencode/src/server/amicode/problems.ts b/packages/opencode/src/server/amicode/problems.ts index f3ac22e74c..314653c83e 100644 --- a/packages/opencode/src/server/amicode/problems.ts +++ b/packages/opencode/src/server/amicode/problems.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" +import { readTerminalState, displayStatus } from "./run-terminal" /** Same env override the amicode plugin honors (test + grant point align). */ export function problemsRoot(): string { @@ -172,6 +173,27 @@ export function problemBody(root: string, slug: string | undefined): string { } } +/** A run with no FINISHED whose run.log has gone cold is STALLED (wedged + * solver, OOM thrash, killed orchestrator) — never "solving": the Now-Solving + * card and pollers must not advertise it forever. 10 min ≫ any real gap + * between iterations (Julia warmup included via file creation). */ +const STALL_AFTER_MS = 10 * 60 * 1000 +function isStalled(dir: string): boolean { + try { + return Date.now() - statSync(path.join(dir, "run.log")).mtimeMs > STALL_AFTER_MS + } catch { + // No run.log yet: judge by run.toml age (written once at birth, then + // immutable). NOT the dir mtime — writing STOP (or any file, even a + // .DS_Store) into the dir bumps it and would flip a corpse back to + // "solving" for another 10 minutes. + try { + return Date.now() - statSync(path.join(dir, "run.toml")).mtimeMs > STALL_AFTER_MS + } catch { + return true + } + } +} + // --- run-status (lightweight live readout; spec: run-dir contract polling, permanently) --- function tomlScalar(text: string, key: string): number | null { @@ -206,14 +228,16 @@ export function runStatusBody(root: string, runs: string, slug: string | undefin } const out = refs.map((ref) => { const dir = path.join(runs, String(ref.lab ?? "default"), String(ref.run_id ?? "")) - if (existsSync(path.join(dir, "FINISHED"))) { - try { - const result = readFileSync(path.join(dir, "result.toml"), "utf8") - const fidelity = tomlScalar(result, "fidelity") - if (fidelity === null) throw new Error("no fidelity") - return { run_id: ref.run_id, status: "finished", fidelity, iteration: tomlScalar(result, "iterations") } - } catch { - return { run_id: ref.run_id, status: "failed", fidelity: null, iteration: null } + // ONE-SPINE (amicode #84): terminal state via the shared contract reader — + // FINISHED's status field decides (a failed run with a result.toml is NOT + // "finished"); torn FINISHED = still solving (re-poll); user-stop → "stopped". + const terminal = readTerminalState(dir) + if (terminal) { + return { + run_id: ref.run_id, + status: displayStatus(terminal.status), + fidelity: terminal.fidelity, + iteration: terminal.iterations, } } let live = { iteration: null as number | null, fidelity: null as number | null } @@ -222,6 +246,9 @@ export function runStatusBody(root: string, runs: string, slug: string | undefin } catch { /* not started yet */ } + if (isStalled(dir)) { + return { run_id: ref.run_id, status: "stalled", fidelity: live.fidelity, iteration: live.iteration } + } return { run_id: ref.run_id, status: "solving", fidelity: live.fidelity, iteration: live.iteration } }) return JSON.stringify({ ok: true, runs: out, error: null }) @@ -282,7 +309,16 @@ function parseLastPulse(log: string): { iter: number; dt: number; values: number for (let i = lines.length - 1; i >= 0; i--) { const m = lines[i].match(/^AMICODE_PULSE\s+iter=(\d+)\s+dt=([0-9eE+.\-]+)\s+a=(.+)$/) if (m) { - const values = m[3].split(",").map(Number).filter((v) => Number.isFinite(v)) + // Drives are ;-joined, knots ,-joined (solve_template.jl). A bare + // split(",") turned every seam token "x51;y1" into a dropped NaN and + // glued all drives into one corrupt trace. Flatten drive-by-drive; the + // client re-slices via pulse_meta (drives x knots). + const values = m[3].split(";").flatMap((drive) => + drive + .split(",") + .map(Number) + .filter((v) => Number.isFinite(v)), + ) return { iter: Number(m[1]), dt: Number(m[2]), values } } } @@ -313,28 +349,23 @@ export function runSeriesBody(runsRoot: string, run: string | undefined, lab: st const bestF = iters.length ? Math.min(...iters.map((p) => p.f)) : null const lastIter = iters.length ? iters[iters.length - 1] : null - const finished = existsSync(path.join(dir, "FINISHED")) - let status = "solving" + // ONE-SPINE (amicode #84): terminal state via the shared contract reader. + // FINISHED (the orchestrator's verdict) is the only terminal authority; its + // status field decides; torn FINISHED = keep polling; user-stop → "stopped". + let status = isStalled(dir) ? "stalled" : "solving" let fidelity: number | null = null let iteration: number | null = lastIter?.iter ?? null - if (finished) { - try { - const result = readFileSync(path.join(dir, "result.toml"), "utf8") - fidelity = tomlScalar(result, "fidelity") - const it = tomlScalar(result, "iterations") - if (it !== null) iteration = it - status = fidelity === null ? "failed" : "finished" - } catch { - status = "failed" - } + const terminal = readTerminalState(dir, log) + if (terminal) { + status = displayStatus(terminal.status) + fidelity = terminal.fidelity + if (terminal.iterations !== null) iteration = terminal.iterations } - // DONE line: authoritative fidelity even before the FINISHED sentinel lands. - if (fidelity === null) { + // DONE line: a pre-FINISHED fidelity HINT only (the sentinel may lag the + // script by a beat) — it never decides status; that's FINISHED's job. + if (fidelity === null && status === "solving") { const done = log.match(/^DONE\s+fidelity=([0-9eE+.\-]+)/m) - if (done) { - fidelity = Number(done[1]) - if (status === "solving") status = "finished" - } + if (done) fidelity = Number(done[1]) } const pulse = parseLastPulse(log) @@ -347,7 +378,7 @@ export function runSeriesBody(runsRoot: string, run: string | undefined, lab: st const startMs = runIdStartMs(run) let endMs: number | null = null try { - endMs = statSync(path.join(dir, finished ? "FINISHED" : "run.log")).mtimeMs + endMs = statSync(path.join(dir, terminal ? "FINISHED" : "run.log")).mtimeMs } catch { /* no file to stat → no elapsed */ } @@ -373,6 +404,99 @@ export function runSeriesBody(runsRoot: string, run: string | undefined, lab: st }) } +// --- run-cards (shareable gallery: every finished solve across problems) --- + +export function synthesizeRunCards(code: string, detail: string): string { + return JSON.stringify({ ok: false, cards: [], error: `${code}: ${detail}` }) +} + +/** Every COMPLETED run with a recorded fidelity, across all problem + * workspaces, shaped for the share-card renderer (headline F, convergence + * series, final pulse, platform/gate from the recorded entities). Stops and + * failures are deliberately absent — the gallery is a trophy case. */ +export function runCardsBody(root: string, runs: string): string { + if (!existsSync(root)) return synthesizeRunCards("no_problems_dir", `${root} does not exist`) + const cards: Record[] = [] + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const slug = entry.name + const metaFile = path.join(root, slug, "problem.json") + if (!existsSync(metaFile)) continue + let name = slug + try { + const meta = readJson(metaFile) as { name?: unknown } + if (typeof meta?.name === "string" && meta.name) name = meta.name + } catch { + /* slug fallback */ + } + let platform: string | null = null + let gate: string | null = null + for (const kind of ["system", "formulation", "run"]) { + try { + const e = readJson(path.join(root, slug, "entities", `${kind}.json`)) as { params?: Record } + const params = e?.params ?? {} + if (platform === null && typeof params.platform === "string") platform = params.platform + if (platform === null && typeof params.system === "string") platform = params.system + if (gate === null && typeof params.gate === "string") gate = params.gate + } catch { + /* entity absent */ + } + } + let refs: { run_id?: unknown; lab?: unknown }[] = [] + try { + const parsed = readJson(path.join(root, slug, "runs.json")) as { runs?: unknown } + if (Array.isArray(parsed?.runs)) refs = parsed.runs as typeof refs + } catch { + /* no runs yet */ + } + for (const ref of refs) { + const runId = String(ref.run_id ?? "") + if (!runId) continue + const dir = path.join(runs, String(ref.lab ?? "default"), runId) + const terminal = readTerminalState(dir) + if (!terminal || terminal.status !== "completed" || terminal.fidelity === null) continue + let log = "" + try { + log = readFileSync(path.join(dir, "run.log"), "utf8") + } catch { + /* card renders without curves */ + } + const iters: { iter: number; f: number }[] = [] + for (const line of log.split("\n")) { + const m = line.match(/^AMICODE_ITER\s+iter=(\d+)\s+f=([0-9eE+.\-]+)/) + if (!m) continue + const f = Number(m[2]) + if (Number.isFinite(f)) iters.push({ iter: Number(m[1]), f }) + } + const startMs = runIdStartMs(runId) + let endMs: number | null = null + try { + endMs = statSync(path.join(dir, "FINISHED")).mtimeMs + } catch { + /* elapsed stays null */ + } + const pulse = parseLastPulse(log) + cards.push({ + slug, + problem: name, + platform, + gate, + run_id: runId, + lab: String(ref.lab ?? "default"), + fidelity: terminal.fidelity, + iterations: terminal.iterations, + elapsed_ms: startMs !== null && endMs !== null ? Math.max(0, Math.round(endMs - startMs)) : null, + finished_at: endMs, + series: downsample(iters, 80), + pulse: pulse ? { dt: pulse.dt, values: pulse.values } : null, + pulse_meta: parsePulseMeta(log), + }) + } + } + cards.sort((a, b) => ((b.finished_at as number) ?? 0) - ((a.finished_at as number) ?? 0)) + return JSON.stringify({ ok: true, cards, error: null }) +} + // --- cached entrypoints for the routes (never reject; body is a JSON string) --- const caches = new Map() @@ -395,8 +519,16 @@ export function problemsResponse(): string { export function problemResponse(slug: string | undefined): string { return cached(`problem:${slug ?? "@active"}`, 10_000, () => problemBody(problemsRoot(), slug), synthesizeProblem) } +export function runCardsResponse(): string { + return cached("run-cards", 5_000, () => runCardsBody(problemsRoot(), runsRoot()), synthesizeRunCards) +} export function runStatusResponse(slug: string | undefined): string { - return cached(`run-status:${slug ?? "@active"}`, 1_000, () => runStatusBody(problemsRoot(), runsRoot(), slug), synthesizeRunStatus) + return cached( + `run-status:${slug ?? "@active"}`, + 1_000, + () => runStatusBody(problemsRoot(), runsRoot(), slug), + synthesizeRunStatus, + ) } export function runSeriesResponse(run: string | undefined, lab: string | undefined): string { return cached( diff --git a/packages/opencode/src/server/amicode/profile.ts b/packages/opencode/src/server/amicode/profile.ts index 27b0866868..023b5a70f1 100644 --- a/packages/opencode/src/server/amicode/profile.ts +++ b/packages/opencode/src/server/amicode/profile.ts @@ -11,7 +11,7 @@ // before the interview fills a profile in. "Amico remembers" is sourced LIVE // from the memory notes (feedback/user type) — the trust surface that makes the // friend persona real. -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" import { problemsRoot, runsRoot } from "./problems" @@ -69,7 +69,7 @@ function nameFromMounts(file: string): string | null { let pendingId: string | null = null for (const raw of readFileSync(file, "utf8").split("\n")) { const line = raw.trim() - if (line.startsWith("[[") ) { + if (line.startsWith("[[")) { pendingId = null personal = false continue @@ -138,7 +138,9 @@ function runStats(root: string): { const labDir = path.join(root, lab.name) let runDirs: string[] try { - runDirs = readdirSync(labDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) + runDirs = readdirSync(labDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) } catch { continue } @@ -251,6 +253,41 @@ function remembers(dirs: string[], limit: number): { title: string; detail: stri return [] } +/** In-place profile save (About-You card inline editing). Merges ONLY the + * editable identity fields into ~/.amico/profile.json (derived fields — + * platforms/stats/remembers — are computed, never stored), invalidates the + * response cache, and returns the fresh profile JSON. */ +export function saveProfile(fields: { + name?: string + affiliation?: string + focus?: string + scholar?: string + affiliation_logo?: string +}): string { + const fp = profileFile() + let current: any = {} + try { + if (existsSync(fp)) current = JSON.parse(readFileSync(fp, "utf8")) ?? {} + } catch { + current = {} + } + // A JSON primitive ("x", 42) survives the ?? {} and current[key]= would + // throw a 500 the client swallows — only an object merge makes sense. + if (typeof current !== "object" || current === null || Array.isArray(current)) current = {} + for (const key of ["name", "affiliation", "focus", "scholar", "affiliation_logo"] as const) { + const v = fields[key] + if (typeof v === "string") { + const t = v.trim() + if (t === "") delete current[key] + else current[key] = t + } + } + mkdirSync(path.dirname(fp), { recursive: true }) + writeFileSync(fp, JSON.stringify(current, null, 2) + "\n") + cache = undefined // next profileResponse() re-reads + return profileResponse() +} + export function profileBody(input: { profileFile: string mountsFile: string @@ -272,6 +309,8 @@ export function profileBody(input: { const you = { name, affiliation: typeof profile.affiliation === "string" ? profile.affiliation : null, + scholar: typeof profile.scholar === "string" ? profile.scholar : null, + affiliation_logo: typeof profile.affiliation_logo === "string" ? profile.affiliation_logo : null, focus: typeof profile.focus === "string" ? profile.focus : null, avatar: typeof profile.avatar === "string" ? profile.avatar : null, platforms: mix.map((p) => p.key), diff --git a/packages/opencode/src/server/amicode/run-terminal.ts b/packages/opencode/src/server/amicode/run-terminal.ts new file mode 100644 index 0000000000..43f5bcd025 --- /dev/null +++ b/packages/opencode/src/server/amicode/run-terminal.ts @@ -0,0 +1,90 @@ +// Terminal-state semantics for the run-dir contract — a documented MIRROR of +// amicode's canonical reader (packages/extension/src/run_dir_reader.ts, +// readTerminalState). ONE-SPINE RULE (amicode #84): the run-dir contract has a +// single terminal semantic, and every consumer — the extension's RunsManager +// AND this server's run-status/run-series endpoints — must agree on it: +// +// * FINISHED (written by amico-run, the orchestrator) is the ONLY terminal +// authority. Script output (e.g. the DONE line) never decides status. +// * FINISHED's `status` field decides the outcome; a missing/torn/mid-write +// FINISHED is NOT terminal — callers keep polling (never latch "failed" +// off a read race). +// * A cooperative user-stop exits 0 → FINISHED says "completed"; the +// AMICODE_STOPPED marker relabels it "stopped" so no surface reads a +// user-stop as a genuine convergence (and nothing promotes it). +// * Fidelity comes only from result.toml, and only when status is completed. +// +// If you change semantics here, change readTerminalState in amicode too. + +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" + +export interface TerminalState { + status: "completed" | "stopped" | "failed" | "aborted" + fidelity: number | null + iterations: number | null +} + +function tomlScalar(text: string, key: string): number | null { + const m = text.match(new RegExp(`^${key}\\s*=\\s*([0-9eE+.\\-]+)\\s*$`, "m")) + if (!m) return null + const v = Number(m[1]) + return Number.isFinite(v) ? v : null +} + +function tomlString(text: string, key: string): string | null { + const m = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"\\s*$`, "m")) + return m ? m[1] : null +} + +/** undefined = not terminal yet (no FINISHED, or torn/invalid mid-write) — + * keep polling; never report failed off a read race. */ +export function readTerminalState(dir: string, log?: string): TerminalState | undefined { + const fp = path.join(dir, "FINISHED") + if (!existsSync(fp)) return undefined + let finished: string + try { + finished = readFileSync(fp, "utf8") + } catch { + return undefined + } + const raw = tomlString(finished, "status") + // MIRROR (amicode finished.schema.json): the FINISHED enum is exactly + // completed|failed|aborted — "stopped" only ever arises via the + // AMICODE_STOPPED relabel below, never as a raw FINISHED value. Accepting a + // raw "stopped" here was drift: the canonical reader treats it as torn. + if (raw !== "completed" && raw !== "failed" && raw !== "aborted") return undefined + let status: TerminalState["status"] = raw + if (status === "completed") { + const body = + log ?? + (() => { + try { + return readFileSync(path.join(dir, "run.log"), "utf8") + } catch { + return "" + } + })() + if (body.includes("AMICODE_STOPPED")) status = "stopped" + } + let fidelity: number | null = null + let iterations: number | null = null + if (status === "completed") { + try { + const result = readFileSync(path.join(dir, "result.toml"), "utf8") + fidelity = tomlScalar(result, "fidelity") + // MIRROR (amicode result.schema.json): fidelity is bounded 0..1.0001 — + // an out-of-range scrape must not surface an F the extension refuses. + if (fidelity !== null && (fidelity < 0 || fidelity > 1.0001)) fidelity = null + iterations = tomlScalar(result, "iterations") + } catch { + /* result.toml absent: completed with no recorded fidelity */ + } + } + return { status, fidelity, iterations } +} + +/** Contract status → the run window's display vocabulary ("finished" et al). */ +export function displayStatus(s: TerminalState["status"]): string { + return s === "completed" ? "finished" : s +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index de8474c33f..9b6142bf68 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -213,6 +213,11 @@ const amicodeProblemsRoute = HttpRouter.use((router) => return HttpServerResponse.text(AmicodeProblems.runStatusResponse(slug), { contentType: "application/json" }) }), ) + yield* router.add("GET", "/amicode/run-cards", () => + Effect.sync(() => + HttpServerResponse.text(AmicodeProblems.runCardsResponse(), { contentType: "application/json" }), + ), + ) yield* router.add("GET", "/amicode/run-series", (request) => Effect.sync(() => { const params = new URL(request.url, "http://localhost").searchParams @@ -222,9 +227,25 @@ const amicodeProblemsRoute = HttpRouter.use((router) => }), ) yield* router.add("GET", "/amicode/profile", () => - Effect.sync(() => - HttpServerResponse.text(AmicodeProfile.profileResponse(), { contentType: "application/json" }), - ), + Effect.sync(() => HttpServerResponse.text(AmicodeProfile.profileResponse(), { contentType: "application/json" })), + ) + // In-place profile save (About-You card). Same raw-route idiom as the GETs: + // 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("POST", "/amicode/profile", (request) => + Effect.sync(() => { + const params = new URL(request.url, "http://localhost").searchParams + const field = (k: string) => (params.has(k) ? (params.get(k) ?? "") : undefined) + const body = AmicodeProfile.saveProfile({ + name: field("name"), + affiliation: field("affiliation"), + focus: field("focus"), + scholar: field("scholar"), + affiliation_logo: field("affiliation_logo"), + }) + return HttpServerResponse.text(body, { contentType: "application/json" }) + }), ) }), ).pipe(Layer.provide(authOnlyRouterLayer)) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 541d1f4bbb..1e667506fc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -351,14 +351,32 @@ function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) } -function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { - const properties = Object.fromEntries( - entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])), - ) +export function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { + // AMICODE: nullable-union normalization. Legacy tool args declare optional + // fields as `type: ["array","null"]` ("pass null to skip"). Passed through + // verbatim, Gemini's adapter turns that union into an any_of whose array + // branch has no `items` (items stays a sibling) — Google rejects the whole + // function declaration and EVERY turn on a Gemini model dies with an + // APIError (empty reply). Strip the "null" and mark the field optional + // instead — the singular-typed schema every provider accepts. + const properties: Record = {} + const required: string[] = [] + for (const [key, raw] of entries) { + if (!isJsonSchemaDefinition(raw)) continue + let node = raw + let optional = false + if (typeof node === "object" && node !== null && Array.isArray((node as { type?: unknown }).type)) { + const types = ((node as { type: unknown[] }).type as string[]).filter((t) => t !== "null") + optional = types.length < (node as { type: unknown[] }).type.length + node = { ...(node as object), type: types.length === 1 ? types[0] : types } as JSONSchema7Definition + } + properties[key] = node + if (!optional) required.push(key) + } return { type: "object", properties, - required: Object.keys(properties), + required, } } diff --git a/packages/opencode/test/server/amicode-problems.test.ts b/packages/opencode/test/server/amicode-problems.test.ts index 06316c862d..60b6e1602c 100644 --- a/packages/opencode/test/server/amicode-problems.test.ts +++ b/packages/opencode/test/server/amicode-problems.test.ts @@ -2,7 +2,14 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" -import { problemsBody, problemBody, runStatusBody, synthesizeProblems, synthesizeProblem } from "@/server/amicode/problems" +import { + problemsBody, + problemBody, + runStatusBody, + runCardsBody, + synthesizeProblems, + synthesizeProblem, +} from "@/server/amicode/problems" let root: string beforeEach(() => { @@ -142,11 +149,20 @@ describe("problemBody", () => { }) describe("runStatusBody", () => { - function seedRun(runsRoot: string, lab: string, id: string, opts: { finished?: boolean; result?: string; log?: string }) { + function seedRun( + runsRoot: string, + lab: string, + id: string, + opts: { finished?: boolean | string; result?: string; log?: string }, + ) { const dir = path.join(runsRoot, lab, id) mkdirSync(dir, { recursive: true }) writeFileSync(path.join(dir, "run.toml"), `run_id = "${id}"\nlab = "${lab}"\n`) - if (opts.finished) writeFileSync(path.join(dir, "FINISHED"), "") + if (opts.finished) + writeFileSync( + path.join(dir, "FINISHED"), + typeof opts.finished === "string" ? opts.finished : 'status = "completed"\nexit_code = 0\n', + ) if (opts.result !== undefined) writeFileSync(path.join(dir, "result.toml"), opts.result) if (opts.log !== undefined) writeFileSync(path.join(dir, "run.log"), opts.log) } @@ -162,7 +178,7 @@ describe("runStatusBody", () => { }) seedRun(runs, "default", "r-done", { finished: true, result: "fidelity = 0.9998\niterations = 60\n" }) seedRun(runs, "default", "r-live", { log: "noise\nAMICODE_ITER iter=12 f=3.2e-02 inf_pr=1e-9\n" }) - seedRun(runs, "default", "r-dead", { finished: true }) // FINISHED but no result.toml + seedRun(runs, "default", "r-dead", { finished: 'status = "failed"\nexit_code = 1\n' }) // failed says so in FINISHED const parsed = JSON.parse(runStatusBody(root, runs, "x-gate")) expect(parsed.ok).toBe(true) expect(parsed.runs).toEqual([ @@ -172,11 +188,11 @@ describe("runStatusBody", () => { ]) rmSync(runs, { recursive: true, force: true }) }) - test("missing run dir → solving with nulls; unknown slug → not_found", () => { + test("missing run dir → stalled (ghost ref, never solving-forever); unknown slug → not_found", () => { const runs = mkdtempSync(path.join(tmpdir(), "amicode-runs-")) seedProblem("x-gate", { runs: [{ run_id: "r-gone", lab: "default", recorded: "t" }] }) const parsed = JSON.parse(runStatusBody(root, runs, "x-gate")) - expect(parsed.runs).toEqual([{ run_id: "r-gone", status: "solving", fidelity: null, iteration: null }]) + expect(parsed.runs).toEqual([{ run_id: "r-gone", status: "stalled", fidelity: null, iteration: null }]) expect(JSON.parse(runStatusBody(root, runs, "zz")).error).toStartWith("not_found:") rmSync(runs, { recursive: true, force: true }) }) @@ -196,3 +212,55 @@ describe("synthesize", () => { }) }) }) + +describe("runCardsBody", () => { + test("collects only completed runs with fidelity, shaped for the card renderer", () => { + const runs = mkdtempSync(path.join(tmpdir(), "amicode-cards-")) + seedProblem("cz-gate", { + runs: [ + { run_id: "r20260707-010101Z-aaaa", lab: "default", recorded: "t" }, + { run_id: "r20260707-020202Z-bbbb", lab: "default", recorded: "t" }, + ], + }) + writeFileSync( + path.join(root, "cz-gate", "problem.json"), + JSON.stringify({ slug: "cz-gate", name: "CZ on Rydberg" }), + ) + mkdirSync(path.join(root, "cz-gate", "entities"), { recursive: true }) + writeFileSync( + path.join(root, "cz-gate", "entities", "system.json"), + JSON.stringify({ params: { platform: "rydberg" } }), + ) + writeFileSync(path.join(root, "cz-gate", "entities", "run.json"), JSON.stringify({ params: { gate: "CZ" } })) + const dirA = path.join(runs, "default", "r20260707-010101Z-aaaa") + mkdirSync(dirA, { recursive: true }) + writeFileSync(path.join(dirA, "run.toml"), 'run_id = "r20260707-010101Z-aaaa"\n') + writeFileSync( + path.join(dirA, "run.log"), + 'AMICODE_ITER iter=1 f=5.0e-01\nAMICODE_PULSE_META drives=2 knots=3 labels=["I","Q"]\nAMICODE_PULSE iter=2 dt=0.5 a=1,2,3;4,5,6\nAMICODE_ITER iter=2 f=1.0e-04\n', + ) + writeFileSync(path.join(dirA, "result.toml"), "fidelity = 0.9999\niterations = 2\n") + writeFileSync(path.join(dirA, "FINISHED"), 'status = "completed"\nexit_code = 0\n') + const dirB = path.join(runs, "default", "r20260707-020202Z-bbbb") + mkdirSync(dirB, { recursive: true }) + writeFileSync(path.join(dirB, "FINISHED"), 'status = "failed"\nexit_code = 1\n') // failures stay out of the trophy case + + const parsed = JSON.parse(runCardsBody(root, runs)) + expect(parsed.ok).toBe(true) + expect(parsed.cards).toHaveLength(1) + const card = parsed.cards[0] + expect(card).toMatchObject({ + slug: "cz-gate", + problem: "CZ on Rydberg", + platform: "rydberg", + gate: "CZ", + run_id: "r20260707-010101Z-aaaa", + fidelity: 0.9999, + iterations: 2, + }) + expect(card.series).toHaveLength(2) + expect(card.pulse.values).toEqual([1, 2, 3, 4, 5, 6]) + expect(card.pulse_meta.drives).toBe(2) + rmSync(runs, { recursive: true, force: true }) + }) +}) diff --git a/packages/opencode/test/tool/registry-schema.test.ts b/packages/opencode/test/tool/registry-schema.test.ts new file mode 100644 index 0000000000..8faa8bba53 --- /dev/null +++ b/packages/opencode/test/tool/registry-schema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { legacyJsonSchema } from "../../src/tool/registry" + +// AMICODE: nullable-union normalization — `type: ["array","null"]` must become +// a singular-typed optional field. Passed through raw, Gemini's adapter emits +// an any_of whose array branch lacks `items` and rejects the whole function +// declaration (every turn on a Gemini model died with an empty APIError reply). +describe("legacyJsonSchema", () => { + test("strips null from union types and marks the field optional", () => { + const schema = legacyJsonSchema([ + ["question", { type: "string", description: "q" }], + ["details", { type: ["array", "null"], items: { type: "string" }, description: "optional" }], + ]) + expect(schema.properties?.details).toEqual({ type: "array", items: { type: "string" }, description: "optional" }) + expect(schema.required).toEqual(["question"]) + }) + test("singular-typed fields pass through untouched and stay required", () => { + const schema = legacyJsonSchema([["options", { type: "array", items: { type: "string" } }]]) + expect(schema.properties?.options).toEqual({ type: "array", items: { type: "string" } }) + expect(schema.required).toEqual(["options"]) + }) +}) diff --git a/packages/ui/src/amicode/ask-card.tsx b/packages/ui/src/amicode/ask-card.tsx index e265a63678..501379d6d0 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 type { AskInput } from "./ask" +import { answeredOption, hasUserReplyAfter, type AskInput } from "./ask" +import { useData } from "../context/data" // AMICODE: question card for amicode_ask tool parts — question text + one // button per option (with optional dim detail line under each label). Click @@ -11,20 +12,26 @@ import type { AskInput } from "./ask" // registered. Display label is AMICO (the persona); component/data-* // identifiers stay amicode-*. -export function AmicodeAskCard(props: { ask: AskInput; messageID?: string }) { - const [picked, setPicked] = createSignal(undefined) +export function AmicodeAskCard(props: { ask: AskInput; messageID?: string; sessionID?: string }) { + // Answered/picked state derives from the PERSISTED transcript (reopen-safe): + // the ask-bridge singleton only exists while the header rail is mounted, so + // it must never gate answerability — it is only the submit transport. + const [clicked, setClicked] = createSignal(undefined) + const data = useData() + const messages = () => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []) + const answered = () => (props.messageID ? hasUserReplyAfter(messages(), props.messageID) : false) + const persistedPick = () => + props.messageID + ? answeredOption(messages(), (id) => data.store.part[id] ?? [], props.messageID, props.ask.options) + : undefined + const picked = () => clicked() ?? persistedPick() - const active = () => { - if (picked()) return false - const bridge = amicodeAskBridge() - if (!bridge || !props.messageID) return false - return !bridge.hasUserReplyAfter(props.messageID) - } + const active = () => !answered() && !clicked() const submit = (option: string) => { const bridge = amicodeAskBridge() if (!bridge || !active()) return - setPicked(option) + setClicked(option) bridge.send(option) } @@ -32,15 +39,15 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string }) {
AMICO · - Question + Question
-
+
{props.ask.question}
@@ -71,23 +78,23 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string }) { disabled={!active()} onClick={() => submit(option)} style={{ - "display": "flex", + display: "flex", "flex-direction": "column", "align-items": "flex-start", - "gap": "2px", + gap: "2px", "text-align": "left", - "border": + border: picked() === option ? "1px solid var(--v2-icon-icon-accent)" : "1px solid var(--v2-border-border-strong)", "border-radius": "6px", - "background": "var(--v2-background-bg-layer-02)", - "color": "var(--v2-text-text-base)", - "padding": "3px 10px", + background: "var(--v2-background-bg-layer-02)", + color: "var(--v2-text-text-base)", + padding: "3px 10px", "font-size": "12px", "line-height": "16px", - "cursor": active() ? "pointer" : "default", - "opacity": active() || picked() === option ? "1" : "0.55", + cursor: active() ? "pointer" : "default", + opacity: active() || picked() === option ? "1" : "0.55", }} > {option} @@ -98,7 +105,7 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string }) { style={{ "font-size": "11px", "line-height": "14px", - "color": "var(--v2-text-text-faint)", + color: "var(--v2-text-text-faint)", }} > {detail()} diff --git a/packages/ui/src/amicode/ask.test.ts b/packages/ui/src/amicode/ask.test.ts index 164f23a121..4c9142c1f6 100644 --- a/packages/ui/src/amicode/ask.test.ts +++ b/packages/ui/src/amicode/ask.test.ts @@ -60,9 +60,7 @@ describe("parseAskInput", () => { }) test("details stay aligned with their option through invalid-option filtering", () => { - expect( - parseAskInput({ question: "q", options: ["a", "", "b"], details: ["da", "dropped", "db"] }), - ).toEqual({ + expect(parseAskInput({ question: "q", options: ["a", "", "b"], details: ["da", "dropped", "db"] })).toEqual({ question: "q", options: ["a", "b"], details: ["da", "db"], @@ -114,3 +112,41 @@ describe("hasUserReplyAfter", () => { expect(hasUserReplyAfter([{ id: "msg_z", role: "user" }], "")).toBe(false) }) }) + +import { answeredOption } from "./ask" + +describe("answeredOption — persisted-pick rehydration (reopen)", () => { + const parts = (map: Record) => (id: string) => map[id] ?? [] + const OPTS = ["transmon", "fluxonium"] + test("returns the first later user reply when it matches an option", () => { + const msgs = [ + { id: "m1", role: "assistant" }, + { id: "m2", role: "user" }, + { id: "m3", role: "user" }, + ] + expect( + answeredOption( + msgs, + parts({ m2: [{ type: "text", text: " transmon " }], m3: [{ type: "text", text: "fluxonium" }] }), + "m1", + OPTS, + ), + ).toBe("transmon") + }) + test("a later non-option reply means answered-otherwise → undefined (card static, no highlight)", () => { + const msgs = [ + { id: "m1", role: "assistant" }, + { id: "m2", role: "user" }, + ] + expect( + answeredOption(msgs, parts({ m2: [{ type: "text", text: "actually let's do bosonic" }] }), "m1", OPTS), + ).toBeUndefined() + }) + test("no later user reply → undefined (card stays interactive)", () => { + const msgs = [ + { id: "m1", role: "assistant" }, + { id: "m0", role: "user" }, + ] + expect(answeredOption(msgs, parts({}), "m1", OPTS)).toBeUndefined() + }) +}) diff --git a/packages/ui/src/amicode/ask.ts b/packages/ui/src/amicode/ask.ts index 23ee4df702..478a8aee09 100644 --- a/packages/ui/src/amicode/ask.ts +++ b/packages/ui/src/amicode/ask.ts @@ -25,9 +25,7 @@ export function parseAskInput(input: unknown): AskInput | undefined { if (!Array.isArray(options)) return undefined const detailsValid = - Array.isArray(details) && - details.length === options.length && - details.every((detail) => typeof detail === "string") + Array.isArray(details) && details.length === options.length && details.every((detail) => typeof detail === "string") const pairs = options .map((option, index) => ({ option, detail: detailsValid ? (details[index] as string).trim() : undefined })) @@ -48,10 +46,7 @@ export function parseAskInput(input: unknown): AskInput | undefined { // i.e. some user message exists LATER (ULID order: lexicographically greater // id) than the card's message. Assistant text streamed after the ask call // must NOT lock the card (live-demo bug, 2026-07-03). -export function hasUserReplyAfter( - messages: readonly { id: string; role?: string }[], - messageID: string, -): boolean { +export function hasUserReplyAfter(messages: readonly { id: string; role?: string }[], messageID: string): boolean { if (typeof messageID !== "string" || messageID.length === 0) return false for (const message of messages) { if (message.role !== "user") continue @@ -60,3 +55,27 @@ export function hasUserReplyAfter( } return false } + +/** Which option (if any) the user's persisted reply picked: the FIRST user + * message after the card's message (ULID order) whose first text part, + * trimmed, matches one of the options. Rehydrates the answered highlight on + * session reopen — the ask-bridge singleton only exists while the header + * rail is mounted, but the transcript is always there. */ +export function answeredOption( + messages: readonly { id: string; role?: string }[], + partsForMessage: (id: string) => readonly { type?: string; text?: string }[], + messageID: string, + options: readonly string[], +): string | undefined { + if (typeof messageID !== "string" || messageID.length === 0) return undefined + const later = messages + .filter((m) => m.role === "user" && typeof m.id === "string" && m.id > messageID) + .sort((a, b) => (a.id < b.id ? -1 : 1)) + for (const m of later) { + const text = partsForMessage(m.id).find((p) => p.type === "text" && typeof p.text === "string")?.text + if (text === undefined) continue + const t = text.trim() + return options.includes(t) ? t : undefined // first reply decides; non-option reply = answered otherwise + } + return undefined +} diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index ee713dada3..c392996a2f 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -46,25 +46,25 @@ function Chip(props: { tool: string; status?: string; output?: string }) { if (parsed) openAmicodeEntity(parsed.entity, parsed.seq) }} style={{ - "display": "flex", + display: "flex", "align-items": "baseline", - "gap": "8px", + gap: "8px", "min-width": "0", - "border": "1px solid var(--v2-border-border-base)", + border: "1px solid var(--v2-border-border-base)", "border-left": "3px solid var(--v2-icon-icon-accent)", "border-radius": "6px", - "background": "var(--v2-background-bg-layer-01)", - "padding": "4px 12px", + background: "var(--v2-background-bg-layer-01)", + padding: "4px 12px", "font-size": "12px", "line-height": "16px", - "cursor": sentinel() ? "pointer" : "default", + cursor: sentinel() ? "pointer" : "default", }} > AMICO @@ -74,10 +74,7 @@ function Chip(props: { tool: string; status?: string; output?: string }) { when={sentinel()} fallback={ <> - + {stage()} @@ -90,9 +87,9 @@ function Chip(props: { tool: string; status?: string; output?: string }) { output?: string // passed by message-part.tsx's Dynamic (already wired) messageID?: string + sessionID?: string }) { const ask = createMemo(() => (props.tool === "amicode_ask" ? parseAskInput(props.input) : undefined)) const runRef = createMemo(() => (props.tool === "amicode_solve" ? runRefFromOutput(props.output) : undefined)) return ( }> - {(value) => } + + {(value) => } + {(ref) => } ) diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index d51e898cb4..c3c4e2b86a 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -87,7 +87,8 @@ export function AmicodeEntityRail(props: { ) const [lastGood, setLastGood] = createSignal(undefined) const current = createMemo(() => { - if (problemRaw.error) return { ok: false, entities: {}, scoreStages: [], events: [], runs: [], error: "fetch failed" } + if (problemRaw.error) + return { ok: false, entities: {}, scoreStages: [], events: [], runs: [], error: "fetch failed" } const raw = problemRaw.latest if (raw === undefined) return undefined return parseProblemResponse(raw) @@ -110,9 +111,7 @@ export function AmicodeEntityRail(props: { const snapshot = state() const hasRuns = snapshot.kind === "ready" && snapshot.view.runs.length > 0 const unfinished = - hasRuns && !runStatuses().length - ? true - : runStatuses().some((status) => status.status === "solving") + hasRuns && !runStatuses().length ? true : runStatuses().some((status) => status.status === "solving") if (hasRuns && unfinished && timer === undefined) { timer = setInterval(async () => { const statuses = parseRunStatusResponse(await props.fetchRunStatus().catch(() => undefined)) @@ -141,18 +140,18 @@ export function AmicodeEntityRail(props: {
@@ -177,13 +176,13 @@ export function AmicodeEntityRail(props: { type="button" data-slot="amicode-rail-retry" style={{ - "color": "var(--v2-text-text-base)", + color: "var(--v2-text-text-base)", "text-decoration": "underline", - "background": "none", - "border": "none", - "padding": "0", - "font": "inherit", - "cursor": "pointer", + background: "none", + border: "none", + padding: "0", + font: "inherit", + cursor: "pointer", }} onClick={() => void refetch()} > @@ -199,12 +198,12 @@ export function AmicodeEntityRail(props: { data-slot="amicode-rail-problem" style={{ "font-weight": "600", - "color": "var(--v2-text-text-base)", - "background": "none", - "border": "none", - "padding": "0", + color: "var(--v2-text-text-base)", + background: "none", + border: "none", + padding: "0", "font-size": "inherit", - "cursor": "pointer", + cursor: "pointer", "flex-shrink": "0", }} onClick={() => props.onOpenSwitcher()} @@ -223,11 +222,11 @@ export function AmicodeEntityRail(props: { data-stage={chip.kind} data-pending="true" style={{ - "display": "inline-flex", + display: "inline-flex", "align-items": "baseline", - "gap": "4px", + gap: "4px", "flex-shrink": "0", - "color": "var(--v2-text-text-faint)", + color: "var(--v2-text-text-faint)", }} > {chip.label} @@ -240,22 +239,22 @@ export function AmicodeEntityRail(props: { data-slot="amicode-rail-chip" data-stage={chip.kind} style={{ - "display": "inline-flex", + display: "inline-flex", "align-items": "baseline", - "gap": "4px", + gap: "4px", "flex-shrink": "0", - "background": "none", - "border": "none", - "padding": "0", - "font": "inherit", - "cursor": "pointer", + background: "none", + border: "none", + padding: "0", + font: "inherit", + cursor: "pointer", }} onClick={() => props.onOpenEntity(chip.kind)} > {chip.label} diff --git a/packages/ui/src/amicode/entity-view.tsx b/packages/ui/src/amicode/entity-view.tsx index 1ca1931bae..9b1abfe2a4 100644 --- a/packages/ui/src/amicode/entity-view.tsx +++ b/packages/ui/src/amicode/entity-view.tsx @@ -61,7 +61,11 @@ export function AmicodeEntityView(props: { +
} > {(view) => ( @@ -177,7 +181,10 @@ export function AmicodeEntityView(props: {
#{event.seq} diff --git a/packages/ui/src/amicode/footer.tsx b/packages/ui/src/amicode/footer.tsx new file mode 100644 index 0000000000..e22a2a0b81 --- /dev/null +++ b/packages/ui/src/amicode/footer.tsx @@ -0,0 +1,81 @@ +import { Mark } from "../components/logo" + +// amicode: slim home-page footer — brand link, quiet resource links, engine +// chip. Links open via the extension bridge (window.open is dead in the +// webview iframe); plain-browser contexts fall back to window.open. + +const LINKS: { label: string; url: string }[] = [ + { label: "harmoniqs.ai", url: "https://www.harmoniqs.ai" }, + { label: "Piccolo.jl", url: "https://github.com/harmoniqs/Piccolo.jl" }, + { label: "GitHub", url: "https://github.com/harmoniqs" }, +] + +function openExternal(url: string) { + if (window.parent !== window) window.parent.postMessage({ source: "amicode", kind: "open-external", url }, "*") + else window.open(url, "_blank", "noreferrer") +} + +export function AmicodeFooter() { + return ( +
+ +
+ {LINKS.slice(1).map((l) => ( + + ))} + + Piccolo engine + +
+
+ ) +} diff --git a/packages/ui/src/amicode/getting-started.tsx b/packages/ui/src/amicode/getting-started.tsx index fa17d72091..086a354ee2 100644 --- a/packages/ui/src/amicode/getting-started.tsx +++ b/packages/ui/src/amicode/getting-started.tsx @@ -1,4 +1,5 @@ import { For, Show } from "solid-js" +import { AmicodeTagline } from "./tagline" // AMICODE: start-screen getting-started block — tagline, how-it-works row, // and starter chips that submit their prompt as the first message of a new @@ -54,33 +55,38 @@ export function AmicodeGettingStarted(props: {
- Amico — a plucky pal for quantum computing practitioners.{" "} - Andiamo. + +
+
+ By Harmoniqs
{(step) => {step}} @@ -89,10 +95,10 @@ export function AmicodeGettingStarted(props: {
@@ -102,14 +108,14 @@ export function AmicodeGettingStarted(props: { data-slot="amicode-gs-starter" onClick={() => props.onStart(starter.prompt)} style={{ - "border": "1px solid var(--v2-icon-icon-accent)", + border: "1px solid var(--v2-icon-icon-accent)", "border-radius": "6px", - "background": "var(--v2-background-bg-layer-02)", - "color": "var(--v2-text-text-base)", - "padding": "4px 12px", + background: "var(--v2-background-bg-layer-02)", + color: "var(--v2-text-text-base)", + padding: "4px 12px", "font-size": "12px", "line-height": "16px", - "cursor": "pointer", + cursor: "pointer", }} > {starter.label} @@ -122,14 +128,14 @@ export function AmicodeGettingStarted(props: { data-slot="amicode-gs-resume" onClick={() => props.onResume?.()} style={{ - "border": "1px solid var(--v2-icon-icon-accent)", + border: "1px solid var(--v2-icon-icon-accent)", "border-radius": "6px", - "background": "var(--v2-background-bg-layer-02)", - "color": "var(--v2-text-text-base)", - "padding": "4px 12px", + background: "var(--v2-background-bg-layer-02)", + color: "var(--v2-text-text-base)", + padding: "4px 12px", "font-size": "12px", "line-height": "16px", - "cursor": "pointer", + cursor: "pointer", }} > Resume “{props.resumeName}” diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 01bf5a995f..0df447cc60 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type JSX } from "solid-js" +import { For, Show, createEffect, createMemo, on, type JSX, createSignal, onCleanup } from "solid-js" import { Mark } from "../components/logo" // AMICODE: home-screen card strip (the "central screen" Aaron wanted the H-bot @@ -24,6 +24,8 @@ export interface ProfileStats { export interface ProfileYou { name: string affiliation: string | null + scholar: string | null + affiliation_logo: string | null focus: string | null avatar: string | null platforms: string[] @@ -44,6 +46,8 @@ export function parseProfileResponse(raw: unknown): ProfileView { you: { name: typeof you.name === "string" ? you.name : "Practitioner", affiliation: typeof you.affiliation === "string" ? you.affiliation : null, + scholar: typeof you.scholar === "string" ? you.scholar : null, + affiliation_logo: typeof you.affiliation_logo === "string" ? you.affiliation_logo : null, focus: typeof you.focus === "string" ? you.focus : null, avatar: typeof you.avatar === "string" ? you.avatar : null, platforms: Array.isArray(you.platforms) ? you.platforms.filter((p: unknown) => typeof p === "string") : [], @@ -113,13 +117,13 @@ function sinceLabel(iso: string | null): string | undefined { // primitives // --------------------------------------------------------------------------- const CARD: JSX.CSSProperties = { - "display": "flex", + display: "flex", "flex-direction": "column", "min-width": "0", - "border": "1px solid var(--v2-border-border-base)", + border: "1px solid var(--v2-border-border-base)", "border-radius": "10px", - "background": "var(--v2-background-bg-layer-01)", - "padding": "14px 16px", + 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 EYEBROW: JSX.CSSProperties = { @@ -127,28 +131,26 @@ const EYEBROW: JSX.CSSProperties = { "font-weight": "700", "letter-spacing": "0.1em", "text-transform": "uppercase", - "color": "var(--v2-text-text-faint)", + color: "var(--v2-text-text-faint)", } -const DIVIDER: JSX.CSSProperties = { "height": "1px", "background": "var(--v2-border-border-base)", "margin": "12px 0" } +const DIVIDER: JSX.CSSProperties = { height: "1px", background: "var(--v2-border-border-base)", margin: "12px 0" } function Bullet(props: { children: JSX.Element; title?: string }) { return (
› - - {props.children} - + {props.children}
) } @@ -161,14 +163,14 @@ function PrimaryButton(props: { children: JSX.Element; onClick: () => void; slot onClick={() => props.onClick()} style={{ "align-self": "flex-start", - "border": "1px solid var(--v2-icon-icon-accent)", + border: "1px solid var(--v2-icon-icon-accent)", "border-radius": "6px", - "background": "var(--v2-background-bg-layer-02)", - "color": "var(--v2-text-text-base)", - "padding": "5px 12px", + background: "var(--v2-background-bg-layer-02)", + color: "var(--v2-text-text-base)", + padding: "5px 12px", "font-size": "12px", "line-height": "16px", - "cursor": "pointer", + cursor: "pointer", }} > {props.children} @@ -187,31 +189,109 @@ const AMICO_CAN = [ ] function MeetAmicoCard(props: { onStart: (prompt: string) => void }) { + // Hover: gold shine sweeps left→right + the card lifts; click anywhere on + // the card (except its inner CTA) opens a fresh session — the Amicode page. + const [hover, setHover] = createSignal(false) return ( -
+
setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={(e) => { + if ((e.target as HTMLElement).closest("button")) return + props.onStart("") + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !(e.target as HTMLElement).closest("button")) props.onStart("") + }} + style={{ + ...HERO_CARD, + position: "relative", + overflow: "hidden", + cursor: "pointer", + transform: hover() ? "translateY(-3px)" : "none", + "box-shadow": hover() ? "var(--v2-elevation-raised, 0 6px 20px rgba(0,0,0,0.18))" : "none", + transition: "transform 0.18s ease, box-shadow 0.18s ease", + }} + > + {/* gold shine: sweeps on hover-in; opacity-fades + snaps back (transform + intentionally untransitioned on leave) so it never sweeps backwards */} +