From 73bd755e46281c24422457b291be55df01cc978f Mon Sep 17 00:00:00 2001 From: aaron Date: Fri, 21 Aug 2026 03:12:56 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test(parity):=20assimilate=20the=20v1.18.10?= =?UTF-8?q?-amicode.14=20pin=20=E2=80=94=20re-record=20goldens,=20self-tra?= =?UTF-8?q?cking=20pin=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main bumped the vendored fork twice (.13/.14, 0.2.4 release). Re-recorded all 71 golden entries against the new binary: ZERO behavioral drift on the amicode route surface (the 13 body diffs are sandbox paths and wall-clock stamps, all replay-normalized; the about-you description commit was widget-side only). Both documented post-pin divergences STAND on .14 (auth route still serves the SPA; google auth_methods still browser-only) — normalizations + unit tests unchanged, comments updated. The contract test's pin assertion now reads opencode.lock.json instead of a hardcoded tag: a future pin bump without a re-record fails loudly ('recorded parity claim is stale') instead of silently testing the old binary's behavior. Also hardens the recorder: every fetch now carries a timeout (health poll 5s, requests 20s) — an earlier recording hung indefinitely when a boot wedged under machine load because a hanging fetch neither resolves nor rejects, so the catch never fired. Contract suite 74/74; full suite 1196/1196; typecheck clean. --- .../scripts/record_amicode_fixtures.mjs | 5 +-- .../test/amicode_service_contract.test.ts | 15 +++++--- .../test/fixtures/amicode/golden.json | 36 +++++++++---------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/extension/scripts/record_amicode_fixtures.mjs b/packages/extension/scripts/record_amicode_fixtures.mjs index e6ef25d8..5ed69c71 100644 --- a/packages/extension/scripts/record_amicode_fixtures.mjs +++ b/packages/extension/scripts/record_amicode_fixtures.mjs @@ -315,10 +315,10 @@ async function main() { let up = false; for (let i = 0; i < 60 && !up; i++) { try { - const r = await fetch(base + "/", { headers: { Authorization: auth } }); + const r = await fetch(base + "/", { headers: { Authorization: auth }, signal: AbortSignal.timeout(5_000) }); if (r.status === 200) up = true; } catch { - /* not listening yet */ + /* not listening yet (or wedged — bounded by the abort) */ } if (!up) await new Promise((r) => setTimeout(r, 500)); } @@ -348,6 +348,7 @@ async function main() { method: req.method, headers: { Authorization: auth, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) }, body, + signal: AbortSignal.timeout(20_000), // one wedged route must not hang the recording }); const respBody = await r.text(); // Record the request with {SANDBOX} restored so the replay re-substitutes diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts index 36702e33..5309e527 100644 --- a/packages/extension/test/amicode_service_contract.test.ts +++ b/packages/extension/test/amicode_service_contract.test.ts @@ -5,7 +5,7 @@ // /amicode/resolve-file. // // Golden fixtures recorded from the FORK binary (scripts/record_amicode_fixtures.mjs, -// vendored pin v1.18.10-amicode.11) against the SAME seeded sandbox this test +// the vendored pin at record time — see the self-tracking pin assertion) against the SAME seeded sandbox this test // builds (scripts/amicode_fixture_seed.mjs). Replay each recorded request // against the PORTED extension-host service and require deep-equal responses — // fork and port serving identical bytes from identical state is the whole @@ -91,8 +91,8 @@ describe("amicode service — golden-fixture parity with the fork", () => { }; /** auth_methods gained a "token" entry in fork source AFTER the vendored - * pin (v1.18.10-amicode.11 advertises browser only; the port follows - * current source). Removed from BOTH sides so the comparison is stable + * pins (.11 AND .14 advertise browser only; the port follows current + * source). Removed from BOTH sides so the comparison is stable * across the next pin bump — the token-paste flow itself is unit-tested * in amicode_service_connections.test.ts. */ const normalizePostPinDrift = (obj: any): any => { @@ -148,8 +148,13 @@ describe("amicode service — golden-fixture parity with the fork", () => { rmSync(sandbox, { recursive: true, force: true }); }); - it("fixtures were recorded from the expected fork pin", () => { - expect(meta.fork.tag).toBe("v1.18.10-amicode.11"); + it("fixtures were recorded from the CURRENT fork pin (re-record on pin bumps)", () => { + // Self-tracking: the fixtures must be re-recorded whenever the vendored + // pin moves (scripts/record_amicode_fixtures.mjs stamps the lock's tag). + // A failure here means the lock bumped but the goldens didn't follow — + // the recorded parity claim is stale, not broken. + const lock = JSON.parse(readFileSync(fileURLToPath(new URL("../opencode.lock.json", import.meta.url)), "utf8")); + expect(meta.fork.tag).toBe(lock.tag); expect(meta.entries.length).toBeGreaterThan(0); }); diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index 4fb4bed2..a3cc2fec 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -1,12 +1,12 @@ { - "recordedAt": "2026-08-20T21:12:06.674Z", + "recordedAt": "2026-08-21T07:10:41.723Z", "fork": { "version": "1.18.10", - "tag": "v1.18.10-amicode.11" + "tag": "v1.18.10-amicode.14" }, - "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd", - "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd", - "seededAt": 1787260325503, + "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12", + "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12", + "seededAt": 1787296240542, "entries": [ { "name": "cold read — synthesized identity + stats + remembers", @@ -75,7 +75,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/vaults/attachable-demo\"}" + "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/vaults/attachable-demo\"}" }, { "name": "vaults — post-attach (cache bust, new mount)", @@ -189,7 +189,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve mount-prefixed (tier 3)", @@ -200,7 +200,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" }, { "name": "resolve relative w/ dir part (tier 4 → project dir)", @@ -211,7 +211,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve bare typed-prefix — miss (tier 5)", @@ -354,7 +354,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":940325499,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":976240538,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — failed (result.toml ≠ finished)", @@ -376,7 +376,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":241925499,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":277840538,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — explicit lab", @@ -409,7 +409,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — valid PDF (refreshed listing)", @@ -424,7 +424,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787260326150,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787296241193,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — bad_filetype refusal", @@ -464,7 +464,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787260326150,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787296241193,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "widget registry — builtins with content hashes", @@ -630,7 +630,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-21T07:09:40.539Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-08-21T07:09:40.539Z\",\"stale\":false,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" }, { "name": "connections catalog — configured filtered out", @@ -823,7 +823,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-21T07:09:40.539Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]}],\"error\":null}" }, { "name": "project create — mkdir (git absent, best-effort)", @@ -837,7 +837,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" + "body": "{\"ok\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" }, { "name": "project create — collision", @@ -891,7 +891,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"parentDir\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/prior-project\"}]}" + "body": "{\"ok\":true,\"parentDir\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-rC2z12/AmicodeProjects/prior-project\"}]}" } ] } From e2abcb80028cc744e6c484ba96922f852888d135 Mon Sep 17 00:00:00 2001 From: aaron Date: Fri, 21 Aug 2026 03:38:02 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(app-bundle):=20M2=20slice=20(a)=20?= =?UTF-8?q?=E2=80=94=20the=20complete=20packages/ui=20overlay,=20equivalen?= =?UTF-8?q?ce-=20and=20build-proven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 slice (a) of #451: packages/app-bundle — the fork-owned app surface carried as an overlay on a pinned canonical base, the mechanism that retires the fork at cutover. Slice (a) scope: the COMPLETE packages/ui delta at v1.18.10-amicode.14 — 164 files (142 added, 22 modified; machine-derived, manifest.json carries the per-file classification). Not hand-picked: materialize(base, overlay) is byte-identical to the fork's packages/ui at the pin. BASE CORRECTION: the fork's true upstream base is v1.18.12~1 (b0b114923) — the 2026-08-04 merge landed upstream up to just-before the v1.18.12 tag (whose final commit only bumps version strings). The v1.18.12 release tarball is therefore the materialization base; the earlier v1.18.10-based inventory overcounted by folding in upstream's own 1.18.10→1.18.12 changes (corrected in docs/m2-app-extraction-inventory.md: 389 files, +41,953/−1,993, 244A/144M/1D). Proofs (both green): A. EQUIVALENCE — materialize(upstream v1.18.12 + overlay) produces a packages/ui byte-identical to the fork's at the pin (diff -r: zero). B. COMPOSITION — the materialized tree installs (bun install, 4695 packages) and builds (tsc -p tsconfig.build.json) cleanly, emitting 220 files incl. dist/amicode/*. Tooling: extract_overlay.mjs (git-archive AT the tag, never the working tree; per-file round-trip verification; deterministic hashes) + materialize.mjs (tarball cache per tag, overlay apply, manifest deletions, hash verification of every overlay file in the output). The inventory doc is stamped with slice (a) shipped. --- docs/m2-app-extraction-inventory.md | 41 +- packages/app-bundle/.gitignore | 1 + packages/app-bundle/README.md | 57 + packages/app-bundle/manifest.json | 349 ++++ .../overlay/packages/ui/package.json | 116 ++ .../ui/src/amicode/amico-presence.css | 104 ++ .../ui/src/amicode/amico-presence.stories.tsx | 141 ++ .../ui/src/amicode/amico-presence.test.ts | 66 + .../packages/ui/src/amicode/amico-presence.ts | 71 + .../ui/src/amicode/amico-wave.stories.tsx | 315 ++++ .../packages/ui/src/amicode/amico-wave.tsx | 73 + .../packages/ui/src/amicode/amicode.css | 1604 +++++++++++++++++ .../ui/src/amicode/approval-bridge.test.ts | 57 + .../ui/src/amicode/approval-bridge.ts | 58 + .../packages/ui/src/amicode/approval-card.tsx | 101 ++ .../packages/ui/src/amicode/approval.test.ts | 179 ++ .../packages/ui/src/amicode/approval.ts | 176 ++ .../packages/ui/src/amicode/ask-bridge.ts | 27 + .../packages/ui/src/amicode/ask-card.tsx | 113 ++ .../packages/ui/src/amicode/ask.test.ts | 152 ++ .../overlay/packages/ui/src/amicode/ask.ts | 81 + .../packages/ui/src/amicode/brain-data.ts | 14 + .../ui/src/amicode/brain-engine.test.ts | 315 ++++ .../packages/ui/src/amicode/brain-engine.ts | 1183 ++++++++++++ .../packages/ui/src/amicode/brain-ref.ts | 84 + .../overlay/packages/ui/src/amicode/bridge.ts | 9 + .../ui/src/amicode/calibration-view.tsx | 54 + .../packages/ui/src/amicode/card.stories.tsx | 106 ++ .../overlay/packages/ui/src/amicode/card.tsx | 442 +++++ .../ui/src/amicode/connection-icon.tsx | 45 + .../ui/src/amicode/connection-picker.tsx | 216 +++ .../ui/src/amicode/connections-paths.test.ts | 187 ++ .../ui/src/amicode/connections-tab.tsx | 600 ++++++ .../ui/src/amicode/connections.stories.tsx | 210 +++ .../ui/src/amicode/connections.test.ts | 616 +++++++ .../packages/ui/src/amicode/connections.ts | 582 ++++++ .../ui/src/amicode/context-tree-data.test.ts | 109 ++ .../ui/src/amicode/context-tree-data.ts | 172 ++ .../src/amicode/context-tree-engine.test.ts | 192 ++ .../ui/src/amicode/context-tree-engine.ts | 803 +++++++++ .../packages/ui/src/amicode/device-view.tsx | 68 + .../packages/ui/src/amicode/edit-row.test.ts | 58 + .../packages/ui/src/amicode/edit-row.ts | 60 + .../packages/ui/src/amicode/entity-rail.tsx | 358 ++++ .../packages/ui/src/amicode/entity-view.tsx | 261 +++ .../packages/ui/src/amicode/facets.test.ts | 152 ++ .../overlay/packages/ui/src/amicode/facets.ts | 244 +++ .../fixtures/formulation-migration.json | 105 ++ .../packages/ui/src/amicode/footer.tsx | 81 + .../amicode/formulation-projection.test.ts | 50 + .../ui/src/amicode/formulation-view.tsx | 335 ++++ .../packages/ui/src/amicode/frontier.test.ts | 64 + .../packages/ui/src/amicode/frontier.ts | 44 + .../ui/src/amicode/getting-started.tsx | 222 +++ .../packages/ui/src/amicode/home-cards.tsx | 1402 ++++++++++++++ .../ui/src/amicode/institution-lookup.ts | 58 + .../ui/src/amicode/onboarding-wizard.test.ts | 22 + .../ui/src/amicode/onboarding-wizard.tsx | 601 ++++++ .../packages/ui/src/amicode/problem.test.ts | 323 ++++ .../packages/ui/src/amicode/problem.ts | 766 ++++++++ .../packages/ui/src/amicode/rail-gate.test.ts | 61 + .../packages/ui/src/amicode/rail-gate.ts | 78 + .../ui/src/amicode/receipt-currency.test.ts | 67 + .../ui/src/amicode/receipt-currency.ts | 64 + .../ui/src/amicode/receipt-runs.test.ts | 143 ++ .../packages/ui/src/amicode/receipt-runs.ts | 116 ++ .../packages/ui/src/amicode/receipt.test.ts | 47 + .../packages/ui/src/amicode/receipt.ts | 117 ++ .../packages/ui/src/amicode/run-card.test.ts | 89 + .../packages/ui/src/amicode/run-card.tsx | 192 ++ .../packages/ui/src/amicode/run-gallery.tsx | 158 ++ .../packages/ui/src/amicode/run-plot.test.ts | 53 + .../packages/ui/src/amicode/run-plot.ts | 66 + .../ui/src/amicode/run-series.test.ts | 129 ++ .../packages/ui/src/amicode/run-series.ts | 99 + .../packages/ui/src/amicode/run-view.tsx | 57 + .../packages/ui/src/amicode/run-window.tsx | 234 +++ .../packages/ui/src/amicode/shell-row.test.ts | 60 + .../packages/ui/src/amicode/shell-row.ts | 51 + .../ui/src/amicode/solver-toggle.test.ts | 73 + .../packages/ui/src/amicode/solver-toggle.tsx | 138 ++ .../packages/ui/src/amicode/spinner.tsx | 70 + .../packages/ui/src/amicode/splash.tsx | 86 + .../packages/ui/src/amicode/stage.test.ts | 21 + .../overlay/packages/ui/src/amicode/stage.ts | 20 + .../ui/src/amicode/system-render.test.ts | 585 ++++++ .../packages/ui/src/amicode/system-render.ts | 537 ++++++ .../packages/ui/src/amicode/system-view.tsx | 131 ++ .../packages/ui/src/amicode/tagline.tsx | 80 + .../ui/src/amicode/thinking-line.stories.tsx | 49 + .../packages/ui/src/amicode/thinking-line.tsx | 97 + .../packages/ui/src/amicode/thinking.test.ts | 56 + .../packages/ui/src/amicode/thinking.ts | 132 ++ .../packages/ui/src/amicode/ui-bridge.ts | 81 + .../overlay/packages/ui/src/amicode/upload.ts | 12 + .../packages/ui/src/amicode/vaults-tab.tsx | 81 + .../packages/ui/src/amicode/vaults.test.ts | 95 + .../overlay/packages/ui/src/amicode/vaults.ts | 47 + .../ui/src/amicode/wave-geometry.test.ts | 170 ++ .../packages/ui/src/amicode/wave-geometry.ts | 122 ++ .../ui/src/amicode/widget-allowlist.ts | 38 + .../ui/src/amicode/widget-bridge.test.ts | 89 + .../packages/ui/src/amicode/widget-bridge.ts | 93 + .../ui/src/amicode/widget-config-form.tsx | 105 ++ .../packages/ui/src/amicode/widget-frame.tsx | 157 ++ .../packages/ui/src/amicode/widget-grid.tsx | 547 ++++++ .../ui/src/amicode/widget-preview-card.tsx | 102 ++ .../ui/src/amicode/widget-preview.test.ts | 37 + .../packages/ui/src/amicode/widget-preview.ts | 45 + .../ui/src/amicode/widget-schema.test.ts | 123 ++ .../packages/ui/src/amicode/widget-schema.ts | 173 ++ .../ui/src/amicode/widget-tokens.test.ts | 43 + .../packages/ui/src/amicode/widget-tokens.ts | 74 + .../packages/ui/src/assets/favicon/amico.svg | 8 + .../ui/src/assets/favicon/site.webmanifest | 21 + .../ui/src/components/amico-spinner.tsx | 4 + .../ui/src/components/amicode-brain-ref.tsx | 3 + .../ui/src/components/amicode-bridge.tsx | 3 + .../ui/src/components/amicode-card.tsx | 5 + .../components/amicode-connections-tab.tsx | 20 + .../ui/src/components/amicode-edit-row.tsx | 3 + .../ui/src/components/amicode-entity-rail.tsx | 4 + .../ui/src/components/amicode-entity-view.tsx | 5 + .../ui/src/components/amicode-footer.tsx | 2 + .../components/amicode-getting-started.tsx | 10 + .../ui/src/components/amicode-home-cards.tsx | 12 + .../components/amicode-onboarding-wizard.tsx | 2 + .../components/amicode-problem-switcher.tsx | 6 + .../ui/src/components/amicode-rail-gate.tsx | 3 + .../src/components/amicode-receipt-runs.tsx | 4 + .../ui/src/components/amicode-receipt.tsx | 3 + .../ui/src/components/amicode-run-card.tsx | 2 + .../ui/src/components/amicode-run-gallery.tsx | 2 + .../ui/src/components/amicode-run-window.tsx | 4 + .../ui/src/components/amicode-shell-row.tsx | 3 + .../src/components/amicode-solver-toggle.tsx | 13 + .../ui/src/components/amicode-splash.tsx | 4 + .../ui/src/components/amicode-thinking.tsx | 4 + .../ui/src/components/amicode-vaults-tab.tsx | 5 + .../ui/src/components/amicode-widget-grid.tsx | 16 + .../ui/src/components/brain-engine.tsx | 5 + .../packages/ui/src/components/brain-ref.tsx | 5 + .../ui/src/components/context-tree-data.tsx | 3 + .../ui/src/components/context-tree-engine.tsx | 3 + .../packages/ui/src/components/dialog.css | 183 ++ .../ui/src/components/dock-surface.css | 43 + .../packages/ui/src/components/favicon.tsx | 14 + .../packages/ui/src/components/icon.tsx | 175 ++ .../packages/ui/src/components/logo.css | 4 + .../packages/ui/src/components/logo.tsx | 122 ++ .../packages/ui/src/components/text-field.tsx | 129 ++ .../ui/src/components/thinking-line.tsx | 4 + .../packages/ui/src/context/dialog.tsx | 197 ++ .../ui/src/context/marked-math.test.ts | 71 + .../packages/ui/src/context/marked.tsx | 655 +++++++ .../overlay/packages/ui/src/i18n/en.ts | 196 ++ .../overlay/packages/ui/src/solid-dnd.d.ts | 12 + .../overlay/packages/ui/src/styles/index.css | 55 + .../overlay/packages/ui/src/styles/theme.css | 631 +++++++ .../overlay/packages/ui/src/theme/context.tsx | 383 ++++ .../packages/ui/src/theme/themes/oc-2.json | 476 +++++ .../overlay/packages/ui/src/util/clipboard.ts | 33 + .../ui/src/v2/components/dialog-v2.css | 179 ++ .../ui/src/v2/components/icon-button-v2.tsx | 39 + .../packages/ui/src/v2/components/icon.tsx | 225 +++ .../ui/src/v2/components/switch-v2.css | 130 ++ .../ui/src/v2/components/tooltip-v2.css | 69 + .../ui/src/v2/components/wordmark-v2.tsx | 32 + packages/app-bundle/package.json | 11 + .../app-bundle/scripts/extract_overlay.mjs | 111 ++ packages/app-bundle/scripts/materialize.mjs | 113 ++ 171 files changed, 25750 insertions(+), 14 deletions(-) create mode 100644 packages/app-bundle/.gitignore create mode 100644 packages/app-bundle/README.md create mode 100644 packages/app-bundle/manifest.json create mode 100644 packages/app-bundle/overlay/packages/ui/package.json create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.stories.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/approval-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/approval.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/approval.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/ask-bridge.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/ask-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/ask.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/ask.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/brain-data.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/brain-ref.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/bridge.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/calibration-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/card.stories.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connection-picker.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connections-paths.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connections.stories.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connections.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/connections.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/device-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/entity-rail.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/entity-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/facets.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/facets.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/fixtures/formulation-migration.json create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/footer.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/formulation-projection.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/formulation-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/frontier.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/frontier.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/getting-started.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/home-cards.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/institution-lookup.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/problem.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/problem.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/receipt.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-card.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-gallery.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-plot.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-plot.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-series.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-series.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/run-window.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/solver-toggle.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/solver-toggle.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/spinner.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/splash.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/stage.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/stage.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/system-render.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/system-render.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/system-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/tagline.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/thinking-line.stories.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/thinking-line.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/thinking.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/thinking.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/ui-bridge.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/upload.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/vaults-tab.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/vaults.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/vaults.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/wave-geometry.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/wave-geometry.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-allowlist.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-bridge.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-bridge.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-config-form.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-frame.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-grid.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-preview-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-preview.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-preview.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-tokens.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/amicode/widget-tokens.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/assets/favicon/amico.svg create mode 100644 packages/app-bundle/overlay/packages/ui/src/assets/favicon/site.webmanifest create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amico-spinner.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-brain-ref.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-bridge.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-connections-tab.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-edit-row.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-entity-rail.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-entity-view.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-footer.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-getting-started.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-home-cards.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-onboarding-wizard.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-problem-switcher.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-rail-gate.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-receipt-runs.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-receipt.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-run-card.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-run-gallery.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-run-window.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-solver-toggle.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-splash.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-thinking.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-vaults-tab.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/amicode-widget-grid.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/brain-engine.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/brain-ref.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/context-tree-data.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/context-tree-engine.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/dialog.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/dock-surface.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/favicon.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/icon.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/logo.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/logo.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/text-field.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/components/thinking-line.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/context/dialog.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/i18n/en.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/solid-dnd.d.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/styles/index.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/styles/theme.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/theme/context.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/theme/themes/oc-2.json create mode 100644 packages/app-bundle/overlay/packages/ui/src/util/clipboard.ts create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/dialog-v2.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/icon-button-v2.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/switch-v2.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/tooltip-v2.css create mode 100644 packages/app-bundle/overlay/packages/ui/src/v2/components/wordmark-v2.tsx create mode 100644 packages/app-bundle/package.json create mode 100644 packages/app-bundle/scripts/extract_overlay.mjs create mode 100644 packages/app-bundle/scripts/materialize.mjs diff --git a/docs/m2-app-extraction-inventory.md b/docs/m2-app-extraction-inventory.md index fdc0b93e..96ea4015 100644 --- a/docs/m2-app-extraction-inventory.md +++ b/docs/m2-app-extraction-inventory.md @@ -3,19 +3,29 @@ Program of record: [harmoniqs/amicode#451](https://github.com/harmoniqs/amicode/issues/451) · migration manifest: `docs/migration-canonical-opencode.md`. -Baseline diff: **upstream `v1.18.10` → shipped pin `v1.18.10-amicode.11`** (the vendored -binary's app surface), packages `app` / `ui` / `session-ui`: +Baseline diff: **upstream base `v1.18.12~1` (`b0b114923`) → shipped pin +`v1.18.10-amicode.14`**, packages `app` / `ui` / `session-ui`: -**411 files, +40,526 / −2,249 — 235 added · 172 modified · 4 deleted.** +**389 files, +41,953 / −1,993 — 244 added · 144 modified · 1 deleted.** -Generated 2026-08-20 (`git diff --name-status v1.18.10 v1.18.10-amicode.11` in -the fork; upstream tag fetched from `anomalyco/opencode`). +> **BASE CORRECTION (2026-08-21)** — the first inventory (411 files, +> +40,526/−2,249 vs `v1.18.10`) overcounted: it folded in upstream's own +> v1.18.10→v1.18.12 changes. The fork's 2026-08-04 upstream merge landed +> everything up to `b0b114923` (`v1.18.12~1` — the v1.18.12 tag's final +> commit only bumps version strings), so the amicode-only delta starts +> there. The v1.18.12 release tarball is tree-equal to the base for +> materialization purposes (see packages/app-bundle/README.md). + +Generated 2026-08-21 (`git diff --name-status b0b114923 v1.18.10-amicode.14`; +upstream tags fetched from `anomalyco/opencode`). The `packages/ui` slice +below is now machine-derived — `packages/app-bundle/manifest.json` carries +the authoritative per-file classification. ## Bucket A — added files (235): move as-is | Where | Count | Notes | |---|---|---| -| `packages/ui/src/amicode/**` | 108 | The pure-additive Amicode component library — the natural first slice | +| `packages/ui/src/amicode/**` | 108 | The pure-additive Amicode component library (within slice (a)'s 164-file ui overlay: 142A/22M — see `packages/app-bundle/`) | | `packages/app/src/components/**` | 27 | App-side components (vault-browser, split-frame, status-popover-body, settings-v2/permissions, …) | | `packages/app/src/pages/**` | 18 | Amicode pages + timeline ports | | `packages/app/src/utils/**` | 19 | global-clipboard (+tests), amicode utils | @@ -84,11 +94,14 @@ into the bundle's own locale tables, ending the upstream parity-test coupling. status-popover) — the files where compose-vs-fork judgment is needed d. i18n tables + deletions + e2e port -## Honest scoping note (2026-08-20, night session) - -This inventory is the M2 INPUT, not the extraction. The 411-file move is the -plan's declared long pole; the slices above are estimable now that the buckets -are counted. What is NOT done tonight: the bundle itself, its build, the -consumer flip (deck panes pointing at the service origin), and the CSP/origin -wiring — all tracked as the remaining M2 scope. M3 (cutover + ≥7-day dogfood -soak) and M5 (the one-push release) are gated on M2 by design. +## Honest scoping note (updated 2026-08-21) + +**Slice (a) SHIPPED** — `packages/app-bundle/`: the complete `packages/ui` +delta (164 files: 142A/22M/0D; 1,623 ui files unchanged from base) as an +overlay materialized onto canonical v1.18.12. Both proofs green: the +materialized ui is byte-identical to the fork's at the pin, and the tree +installs + typecheck-builds cleanly (220 emitted files). Remaining M2: slices +(b)–(d) (app + session-ui additive, true overlays, i18n/e2e), the bundle's +CI pin, the consumer flip (deck panes → service origin, CSP/`?auth_token=` +wiring). M3 (cutover + ≥7-day dogfood soak) and M5 (the one-push release) +are gated on M2 by design. diff --git a/packages/app-bundle/.gitignore b/packages/app-bundle/.gitignore new file mode 100644 index 00000000..ceddaa37 --- /dev/null +++ b/packages/app-bundle/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/packages/app-bundle/README.md b/packages/app-bundle/README.md new file mode 100644 index 00000000..773b375a --- /dev/null +++ b/packages/app-bundle/README.md @@ -0,0 +1,57 @@ +# @amicode/app-bundle — the fork-owned app overlay (M2, #451) + +The M2 artifact: the Amicode app surface, carried as an **overlay** on a +pinned **canonical opencode** base — the mechanism that retires the fork at +cutover while keeping every Amicode surface. + +## Slice (a) — the complete `packages/ui` delta — SHIPPED + +**Scope**: every file under `packages/ui` the fork changed vs the upstream +base — 164 files (142 added, 22 modified; machine-derived, see +`manifest.json`). Not a hand-picked list: `materialize(base, overlay)` is +byte-identical to the fork's `packages/ui` at the pin. + +**The base correction**: the fork's true upstream base is `v1.18.12~1` +(`b0b114923`) — the 2026-08-04 merge landed upstream up to just-before the +v1.18.12 tag (whose final commit only bumps version strings). The overlay +therefore materializes onto the **v1.18.12 release tarball**; the only +base-vs-fork differences in files the overlay doesn't own are version-string +bumps in files the overlay DOES own (`package.json`). Earlier diffs taken +against v1.18.10 overcounted by folding in upstream's own 1.18.10→1.18.12 +changes. + +### The two proofs (both green, 2026-08-21) + +1. **Equivalence** — `materialize(upstream v1.18.12, overlay)` produces a + `packages/ui` byte-identical to the fork's at `v1.18.10-amicode.14` + (`diff -r`: zero lines). +2. **Composition** — the materialized tree installs (`bun install`, 4,695 + packages) and builds (`tsc -p tsconfig.build.json`) cleanly, emitting 220 + files including `dist/amicode/*`. + +### Usage + +```sh +# re-extract the overlay from the fork at the pin (updates manifest.json) +pnpm --filter @amicode/app-bundle extract [--fork ] [--tag ] + +# materialize a full source tree: canonical base + overlay +node scripts/materialize.mjs --out [--tag v1.18.12] [--repo anomalyco/opencode] +``` + +The extractor reads files via `git archive` AT the tag (never the working +tree) and round-trip-verifies every file against `git show TAG:` — the +manifest hashes are the contract. The materializer fetches the canonical +tarball once per tag (`.cache/`, gitignored), applies the overlay +(adds/overwrites), applies manifest deletions, and verifies every overlay +file's hash in the output. + +### Later slices (docs/m2-app-extraction-inventory.md) + +- (b) `packages/app` + `packages/session-ui` additive files +- (c) the true overlays (home, session-header, message-part, timeline) — + decomposed from whole-file ownership into composable extensions where + upstream evolution demands it +- (d) i18n tables, the debug-bar deletion, e2e port +- the consumer flip: the amicode service serves the built bundle; deck panes + point at the service origin (CSP/`?auth_token=` wiring) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json new file mode 100644 index 00000000..1743e3e8 --- /dev/null +++ b/packages/app-bundle/manifest.json @@ -0,0 +1,349 @@ +{ + "schema": 2, + "slice": "a", + "scope": "packages/ui (complete delta)", + "fork_tag": "v1.18.10-amicode.14", + "fork_sha": "1fe58157f78ce62515baaee69a9c1461eed42282", + "upstream_base": "v1.18.12", + "upstream_base_sha": "0dd6950d1b06958fbcdcadf0ad56258257ab7fdb", + "extracted_at": "2026-08-21T07:33:33.803Z", + "counts": { + "added": 142, + "modified": 22, + "deleted": 0, + "unchanged": 1623 + }, + "deletions": [], + "classification": { + "packages/ui/package.json": "M", + "packages/ui/src/amicode/amico-presence.css": "A", + "packages/ui/src/amicode/amico-presence.stories.tsx": "A", + "packages/ui/src/amicode/amico-presence.test.ts": "A", + "packages/ui/src/amicode/amico-presence.ts": "A", + "packages/ui/src/amicode/amico-wave.stories.tsx": "A", + "packages/ui/src/amicode/amico-wave.tsx": "A", + "packages/ui/src/amicode/amicode.css": "A", + "packages/ui/src/amicode/approval-bridge.test.ts": "A", + "packages/ui/src/amicode/approval-bridge.ts": "A", + "packages/ui/src/amicode/approval-card.tsx": "A", + "packages/ui/src/amicode/approval.test.ts": "A", + "packages/ui/src/amicode/approval.ts": "A", + "packages/ui/src/amicode/ask-bridge.ts": "A", + "packages/ui/src/amicode/ask-card.tsx": "A", + "packages/ui/src/amicode/ask.test.ts": "A", + "packages/ui/src/amicode/ask.ts": "A", + "packages/ui/src/amicode/brain-data.ts": "A", + "packages/ui/src/amicode/brain-engine.test.ts": "A", + "packages/ui/src/amicode/brain-engine.ts": "A", + "packages/ui/src/amicode/brain-ref.ts": "A", + "packages/ui/src/amicode/bridge.ts": "A", + "packages/ui/src/amicode/calibration-view.tsx": "A", + "packages/ui/src/amicode/card.stories.tsx": "A", + "packages/ui/src/amicode/card.tsx": "A", + "packages/ui/src/amicode/connection-icon.tsx": "A", + "packages/ui/src/amicode/connection-picker.tsx": "A", + "packages/ui/src/amicode/connections-paths.test.ts": "A", + "packages/ui/src/amicode/connections-tab.tsx": "A", + "packages/ui/src/amicode/connections.stories.tsx": "A", + "packages/ui/src/amicode/connections.test.ts": "A", + "packages/ui/src/amicode/connections.ts": "A", + "packages/ui/src/amicode/context-tree-data.test.ts": "A", + "packages/ui/src/amicode/context-tree-data.ts": "A", + "packages/ui/src/amicode/context-tree-engine.test.ts": "A", + "packages/ui/src/amicode/context-tree-engine.ts": "A", + "packages/ui/src/amicode/device-view.tsx": "A", + "packages/ui/src/amicode/edit-row.test.ts": "A", + "packages/ui/src/amicode/edit-row.ts": "A", + "packages/ui/src/amicode/entity-rail.tsx": "A", + "packages/ui/src/amicode/entity-view.tsx": "A", + "packages/ui/src/amicode/facets.test.ts": "A", + "packages/ui/src/amicode/facets.ts": "A", + "packages/ui/src/amicode/fixtures/formulation-migration.json": "A", + "packages/ui/src/amicode/footer.tsx": "A", + "packages/ui/src/amicode/formulation-projection.test.ts": "A", + "packages/ui/src/amicode/formulation-view.tsx": "A", + "packages/ui/src/amicode/frontier.test.ts": "A", + "packages/ui/src/amicode/frontier.ts": "A", + "packages/ui/src/amicode/getting-started.tsx": "A", + "packages/ui/src/amicode/home-cards.tsx": "A", + "packages/ui/src/amicode/institution-lookup.ts": "A", + "packages/ui/src/amicode/onboarding-wizard.test.ts": "A", + "packages/ui/src/amicode/onboarding-wizard.tsx": "A", + "packages/ui/src/amicode/problem.test.ts": "A", + "packages/ui/src/amicode/problem.ts": "A", + "packages/ui/src/amicode/rail-gate.test.ts": "A", + "packages/ui/src/amicode/rail-gate.ts": "A", + "packages/ui/src/amicode/receipt-currency.test.ts": "A", + "packages/ui/src/amicode/receipt-currency.ts": "A", + "packages/ui/src/amicode/receipt-runs.test.ts": "A", + "packages/ui/src/amicode/receipt-runs.ts": "A", + "packages/ui/src/amicode/receipt.test.ts": "A", + "packages/ui/src/amicode/receipt.ts": "A", + "packages/ui/src/amicode/run-card.test.ts": "A", + "packages/ui/src/amicode/run-card.tsx": "A", + "packages/ui/src/amicode/run-gallery.tsx": "A", + "packages/ui/src/amicode/run-plot.test.ts": "A", + "packages/ui/src/amicode/run-plot.ts": "A", + "packages/ui/src/amicode/run-series.test.ts": "A", + "packages/ui/src/amicode/run-series.ts": "A", + "packages/ui/src/amicode/run-view.tsx": "A", + "packages/ui/src/amicode/run-window.tsx": "A", + "packages/ui/src/amicode/shell-row.test.ts": "A", + "packages/ui/src/amicode/shell-row.ts": "A", + "packages/ui/src/amicode/solver-toggle.test.ts": "A", + "packages/ui/src/amicode/solver-toggle.tsx": "A", + "packages/ui/src/amicode/spinner.tsx": "A", + "packages/ui/src/amicode/splash.tsx": "A", + "packages/ui/src/amicode/stage.test.ts": "A", + "packages/ui/src/amicode/stage.ts": "A", + "packages/ui/src/amicode/system-render.test.ts": "A", + "packages/ui/src/amicode/system-render.ts": "A", + "packages/ui/src/amicode/system-view.tsx": "A", + "packages/ui/src/amicode/tagline.tsx": "A", + "packages/ui/src/amicode/thinking-line.stories.tsx": "A", + "packages/ui/src/amicode/thinking-line.tsx": "A", + "packages/ui/src/amicode/thinking.test.ts": "A", + "packages/ui/src/amicode/thinking.ts": "A", + "packages/ui/src/amicode/ui-bridge.ts": "A", + "packages/ui/src/amicode/upload.ts": "A", + "packages/ui/src/amicode/vaults-tab.tsx": "A", + "packages/ui/src/amicode/vaults.test.ts": "A", + "packages/ui/src/amicode/vaults.ts": "A", + "packages/ui/src/amicode/wave-geometry.test.ts": "A", + "packages/ui/src/amicode/wave-geometry.ts": "A", + "packages/ui/src/amicode/widget-allowlist.ts": "A", + "packages/ui/src/amicode/widget-bridge.test.ts": "A", + "packages/ui/src/amicode/widget-bridge.ts": "A", + "packages/ui/src/amicode/widget-config-form.tsx": "A", + "packages/ui/src/amicode/widget-frame.tsx": "A", + "packages/ui/src/amicode/widget-grid.tsx": "A", + "packages/ui/src/amicode/widget-preview-card.tsx": "A", + "packages/ui/src/amicode/widget-preview.test.ts": "A", + "packages/ui/src/amicode/widget-preview.ts": "A", + "packages/ui/src/amicode/widget-schema.test.ts": "A", + "packages/ui/src/amicode/widget-schema.ts": "A", + "packages/ui/src/amicode/widget-tokens.test.ts": "A", + "packages/ui/src/amicode/widget-tokens.ts": "A", + "packages/ui/src/assets/favicon/amico.svg": "A", + "packages/ui/src/assets/favicon/site.webmanifest": "M", + "packages/ui/src/components/amico-spinner.tsx": "A", + "packages/ui/src/components/amicode-brain-ref.tsx": "A", + "packages/ui/src/components/amicode-bridge.tsx": "A", + "packages/ui/src/components/amicode-card.tsx": "A", + "packages/ui/src/components/amicode-connections-tab.tsx": "A", + "packages/ui/src/components/amicode-edit-row.tsx": "A", + "packages/ui/src/components/amicode-entity-rail.tsx": "A", + "packages/ui/src/components/amicode-entity-view.tsx": "A", + "packages/ui/src/components/amicode-footer.tsx": "A", + "packages/ui/src/components/amicode-getting-started.tsx": "A", + "packages/ui/src/components/amicode-home-cards.tsx": "A", + "packages/ui/src/components/amicode-onboarding-wizard.tsx": "A", + "packages/ui/src/components/amicode-problem-switcher.tsx": "A", + "packages/ui/src/components/amicode-rail-gate.tsx": "A", + "packages/ui/src/components/amicode-receipt-runs.tsx": "A", + "packages/ui/src/components/amicode-receipt.tsx": "A", + "packages/ui/src/components/amicode-run-card.tsx": "A", + "packages/ui/src/components/amicode-run-gallery.tsx": "A", + "packages/ui/src/components/amicode-run-window.tsx": "A", + "packages/ui/src/components/amicode-shell-row.tsx": "A", + "packages/ui/src/components/amicode-solver-toggle.tsx": "A", + "packages/ui/src/components/amicode-splash.tsx": "A", + "packages/ui/src/components/amicode-thinking.tsx": "A", + "packages/ui/src/components/amicode-vaults-tab.tsx": "A", + "packages/ui/src/components/amicode-widget-grid.tsx": "A", + "packages/ui/src/components/brain-engine.tsx": "A", + "packages/ui/src/components/brain-ref.tsx": "A", + "packages/ui/src/components/context-tree-data.tsx": "A", + "packages/ui/src/components/context-tree-engine.tsx": "A", + "packages/ui/src/components/dialog.css": "M", + "packages/ui/src/components/dock-surface.css": "M", + "packages/ui/src/components/favicon.tsx": "M", + "packages/ui/src/components/icon.tsx": "M", + "packages/ui/src/components/logo.css": "M", + "packages/ui/src/components/logo.tsx": "M", + "packages/ui/src/components/text-field.tsx": "M", + "packages/ui/src/components/thinking-line.tsx": "A", + "packages/ui/src/context/dialog.tsx": "M", + "packages/ui/src/context/marked-math.test.ts": "A", + "packages/ui/src/context/marked.tsx": "M", + "packages/ui/src/i18n/en.ts": "M", + "packages/ui/src/solid-dnd.d.ts": "A", + "packages/ui/src/styles/index.css": "M", + "packages/ui/src/styles/theme.css": "M", + "packages/ui/src/theme/context.tsx": "M", + "packages/ui/src/theme/themes/oc-2.json": "M", + "packages/ui/src/util/clipboard.ts": "A", + "packages/ui/src/v2/components/dialog-v2.css": "M", + "packages/ui/src/v2/components/icon-button-v2.tsx": "M", + "packages/ui/src/v2/components/icon.tsx": "M", + "packages/ui/src/v2/components/switch-v2.css": "M", + "packages/ui/src/v2/components/tooltip-v2.css": "M", + "packages/ui/src/v2/components/wordmark-v2.tsx": "M" + }, + "files": { + "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", + "packages/ui/src/amicode/amico-presence.css": "b3f345b607e318dcfbefcdc677fe21cc7d387b9946b0f9d625b0a842fe7e31b9", + "packages/ui/src/amicode/amico-presence.stories.tsx": "33c854edd9a061d79c53e160ce5685ddb8cd41f4e205c6eb1b0dc96fec5017bb", + "packages/ui/src/amicode/amico-presence.test.ts": "c7ea8c840004d06e231a7368b3c10e11b7dd7efcbcaca4867b0f30167bd2e2bb", + "packages/ui/src/amicode/amico-presence.ts": "b4708117445d3fa5cf9be8c9b2f51bfb1f6b01a76e2fac114cde93107a075e26", + "packages/ui/src/amicode/amico-wave.stories.tsx": "ae2885a0294c2ebeec5850386028b939ea4211289b3265bbf495b2a7d879e2a9", + "packages/ui/src/amicode/amico-wave.tsx": "478cd8b19a87679016d9dbb0aa494ba7d1f1ef67b3da1d09fb35d28e33de9017", + "packages/ui/src/amicode/amicode.css": "6a4be28bc521ab82d1d3800f326f372b41a85bfdaf9d71402a0b02ef8f7ddd1f", + "packages/ui/src/amicode/approval-bridge.test.ts": "5b5e5a41c827b7a3cdaf455dbf488cfa7b8d339f46e64f5a35a0ba9226eb0ebf", + "packages/ui/src/amicode/approval-bridge.ts": "ceaa6024916794ece1bc80a004b79c36406d2bc512f82e003c25d919c9050e48", + "packages/ui/src/amicode/approval-card.tsx": "e537eb262843aaa80e7c53be63a3aece4f83e367907cd22a26f0ff6c7674dbec", + "packages/ui/src/amicode/approval.test.ts": "d1a9eb383112234d750d07229e347a464bcccc1d633d90ebf653937ae0653773", + "packages/ui/src/amicode/approval.ts": "ebee2a1302c27e1f74d2892b3d84158dbaf672e13d4300ffc01ad8ce9a1ed644", + "packages/ui/src/amicode/ask-bridge.ts": "8ab7f64eefe55ae0968b9b960718a1f4ceaf6813102bbda42349a06a3638610f", + "packages/ui/src/amicode/ask-card.tsx": "599d249ecd528713cb57ba908ade45ae56cf4244690627a8a9eaa9dde8886adc", + "packages/ui/src/amicode/ask.test.ts": "1daa4ccf6cc41f17039c5ffb0a09243ba364b5af6e174ecd1040787afb7907e2", + "packages/ui/src/amicode/ask.ts": "5081435a8b09b2eba12240a9914bbe222303460bd21044bbf8482bc190ff2efe", + "packages/ui/src/amicode/brain-data.ts": "f23483cda811e148748d4f47435c6d01c03d52e9a65ad74cae2c0ea30ce0b8dc", + "packages/ui/src/amicode/brain-engine.test.ts": "3b244fbe15ac87529655acd688da9181af53a1dfeb3f9f87e5f77000db3aada6", + "packages/ui/src/amicode/brain-engine.ts": "0ec23c753167bd702739f5ee18e9ea862f19256841625be54f8aa8008d7bffc2", + "packages/ui/src/amicode/brain-ref.ts": "9d8aaa8b24fb0b74acc0c4cdafc265c1808c28c95d4ac9b815fd2e54ea89f46d", + "packages/ui/src/amicode/bridge.ts": "e7b93a15060a0cb1ab0be2b367f73e926d74b990a8af917a9966b451ee303b5b", + "packages/ui/src/amicode/calibration-view.tsx": "bc4193104fea43ac3e5476df0f9a91a55465b48ffffc80e4170047872a141812", + "packages/ui/src/amicode/card.stories.tsx": "b1591abb4af426b0eb08588ea1585336a392d755b5d308cb31f17cb65acf5a3e", + "packages/ui/src/amicode/card.tsx": "4d236acc454a051f7fed1cd6cac0e98dfa8df9994376d381672b27e75c6feb6f", + "packages/ui/src/amicode/connection-icon.tsx": "59977d3db33a67c39dd82efcaf03548bb83c2639327bf36dc6f3fa43ce5dc986", + "packages/ui/src/amicode/connection-picker.tsx": "d272e89d3a1fc914d194d803f41e723456ce1ce7079cba199d3fd69566236a3c", + "packages/ui/src/amicode/connections-paths.test.ts": "07c86e8e04700cdbd55bff8db342a8400d85114d76bc73fee8205e9162953f2a", + "packages/ui/src/amicode/connections-tab.tsx": "06489a47bbd6d482ba4d2dc2cf4e9e8e9c648be774f78cd76e2abc87451116e5", + "packages/ui/src/amicode/connections.stories.tsx": "fda54a41c4b46f5cab5278278f92c01d1fea70610f7b8641fa5855be6bd9d486", + "packages/ui/src/amicode/connections.test.ts": "a718b1b698178087fdac5294c1661ca2eb200491e2a076465a09ab52c70b8910", + "packages/ui/src/amicode/connections.ts": "c44875ae2a95aae3f62a95fcf59517178039d09d51ca84e816ea725ba965e408", + "packages/ui/src/amicode/context-tree-data.test.ts": "bd51dc84d6c14c64f38c8f467ef29d9d8f45dcb77cc83ec92bffc8aaa983b463", + "packages/ui/src/amicode/context-tree-data.ts": "d623578b957b9a49d6f7b930a2264b7bf0ea71fabf1a485d9666706c7b77485d", + "packages/ui/src/amicode/context-tree-engine.test.ts": "afa3199c18c7ca05a19fdf0663a5b4d6e1fd2303e038cf25641c29e254e610f4", + "packages/ui/src/amicode/context-tree-engine.ts": "41c7bb2b351dec597e1cf8c996e4bf90fb938de3ce4f637ebffd3fe67570985c", + "packages/ui/src/amicode/device-view.tsx": "1842053e9e25cd5c8076598449e44abf06008940a8f3f39403cbf435b1b8aa8d", + "packages/ui/src/amicode/edit-row.test.ts": "7c68fcf083d3854bfb70768b648110e001843a290dcfdc25869c1a4681445b94", + "packages/ui/src/amicode/edit-row.ts": "28d5f39470376968e7eabc3f50807a779177e9c9f2081302e4b5eb15306aef1b", + "packages/ui/src/amicode/entity-rail.tsx": "fee936650d0f05ab00e46ebb70333b2c92a57ab0da1e7fcee942d7e6b64004b7", + "packages/ui/src/amicode/entity-view.tsx": "e181575c8bd66a7cbdf89411d0cc1d12173a74a3bac9122163f2e27331e3becc", + "packages/ui/src/amicode/facets.test.ts": "e89bd4e49276f52ac0af4990b202919331a48302b4b34c1ec1d8406fa4fccabf", + "packages/ui/src/amicode/facets.ts": "074f7843a105961da3b4b89c67ea20de106968b4524f9094a5f57d1ecb0acee0", + "packages/ui/src/amicode/fixtures/formulation-migration.json": "e573355a5eeb29f1611e52f0fb716697281b16f899331fa7fb5ada9eac4e74bc", + "packages/ui/src/amicode/footer.tsx": "481c839d72fadc03cb6a5d92da52c8a36110b0ca5946481e38c3fa4eb6e72fa1", + "packages/ui/src/amicode/formulation-projection.test.ts": "bcf40f30a977268c1e0c009d94c16141d6d2957dd59abfaf8741014db7c9fc7e", + "packages/ui/src/amicode/formulation-view.tsx": "7064837eb1fd7118b49312c0697cfb0f4b96541e2c0a3f3e763441c16c64d8d5", + "packages/ui/src/amicode/frontier.test.ts": "229eb8e3f187113fcfc73f0a30f2690bab4266cb12c1c6c3802522dd7eb4af9c", + "packages/ui/src/amicode/frontier.ts": "78cd89deb26a2c6c4b8a18230012455b7aecd5f23eb007dbd0cd440b6b6ec36c", + "packages/ui/src/amicode/getting-started.tsx": "9cdb8df438521d33cf7732fd0ba64e21b4ba9d63de0c823e59d0dcd21ea90a7f", + "packages/ui/src/amicode/home-cards.tsx": "93ce40f0e67bf0d4ef9b8f95abb91d7d566649a6e1b690aa3a431205139c73c8", + "packages/ui/src/amicode/institution-lookup.ts": "3edf90b0778ea50b048975cc317a18a1aa82290cd4f5241cf9df74fd17866fe9", + "packages/ui/src/amicode/onboarding-wizard.test.ts": "268df538ea99280c5297f9b1dc06a236da2fb82830c2d49a107feca3f610279f", + "packages/ui/src/amicode/onboarding-wizard.tsx": "c1ccf8d936560b2f1d75381e27911310704bd3401dc4e3382ee3d9fbe793f0e0", + "packages/ui/src/amicode/problem.test.ts": "e7a15928df883fbed424744953a87ad0b5ba8a0c1039dc190ed00bfefe85eeee", + "packages/ui/src/amicode/problem.ts": "d40ca9f4f411b112d97c7d1067b5834ff22f763456df17df7fa0ad31768a3a51", + "packages/ui/src/amicode/rail-gate.test.ts": "7cf0ff5add76f27eb4022224a0f18532a7bc419dd5368788e7a0667f9913dcae", + "packages/ui/src/amicode/rail-gate.ts": "c24f99d8285a5484863078acedc5ed36d8de67084c2baaf2bb2f7b6d87e657cb", + "packages/ui/src/amicode/receipt-currency.test.ts": "7b6518fc7eb1f789540ed3b7f9f761e496ab09648f22d967d2f1a260099db09a", + "packages/ui/src/amicode/receipt-currency.ts": "379c6ff09b170a0dbb85fb093ee7a334339066db83e268534918967010469876", + "packages/ui/src/amicode/receipt-runs.test.ts": "2c25968b2ba9eaa7fe70ed4cf767aae88d3db9595e01d880215514fef7a4563f", + "packages/ui/src/amicode/receipt-runs.ts": "4aa31624ea3d61ab352bfda7cd0da48f7d367f123cf7929f52f4da84629a190b", + "packages/ui/src/amicode/receipt.test.ts": "7065899d0efd4b2b712bf412053d1b3071e6aee4be163f38a3e0a7dcdbbb45fa", + "packages/ui/src/amicode/receipt.ts": "cc5e452b229b29386f474772cd27caf6a83a13862540de9035cff610ef06e929", + "packages/ui/src/amicode/run-card.test.ts": "e0b3a7c94bbdddfa1cdcc81898404bad17319862f3c3a9f47255d17db1fe75fa", + "packages/ui/src/amicode/run-card.tsx": "b074e95fd8fe8b1907d1527fa88ff85b5dd6ec2ffaa30f749969f9130e83e592", + "packages/ui/src/amicode/run-gallery.tsx": "4a62e71e5ab69f28ac74eb9cbf38434ca9b3c922a8c5250c6c35ebdb15f45dde", + "packages/ui/src/amicode/run-plot.test.ts": "64ac70e7ff5784ef6c1c224136df2a4e8a43b4187d3ac0fc4382cb0464eb2953", + "packages/ui/src/amicode/run-plot.ts": "b4bc50f279cb864f7720e56ba69b993c7a4e599a71047162173b1b93a63598ac", + "packages/ui/src/amicode/run-series.test.ts": "2e7aec7cdf7eb056efbe52d180ff316482e3010e2442611daaf4bda444c82705", + "packages/ui/src/amicode/run-series.ts": "c94496068ae05f4600b9dd9fab4247b678dae7aaba31ef48e4389b0e7fefc52c", + "packages/ui/src/amicode/run-view.tsx": "5098ef006cec0f354dde8589df1a730b5663c05300c1cf298569e62eb633f1be", + "packages/ui/src/amicode/run-window.tsx": "748fee9b14152f0f173238acacf9883aefab7f36038b897efe6b007307916e17", + "packages/ui/src/amicode/shell-row.test.ts": "48ec6f0786edf81f163e350904bc2a93b1a55375b8115e688237ce8c2f1a7e64", + "packages/ui/src/amicode/shell-row.ts": "b39dc05d2b77ba6d44456248782b31f6219c891d56700203186a65ccb48319ad", + "packages/ui/src/amicode/solver-toggle.test.ts": "9aa734ab57f4f6f5d31b6c18b8d41bf3677f4fe1d13828ca555c08cc15146b25", + "packages/ui/src/amicode/solver-toggle.tsx": "0250d887e448685307bdf3cc27a5a0f803797f045fb29f5d10d9ededd6b2fff1", + "packages/ui/src/amicode/spinner.tsx": "f76ae9b6a3945017b854d35ea0e6b2f4a08ca7f01e0f77236dbe1ba33d244fd7", + "packages/ui/src/amicode/splash.tsx": "4d5485f48065d2c88a3ce41cc1abd81d5b58338dfd4ae240b1c211444fc689aa", + "packages/ui/src/amicode/stage.test.ts": "b1a82d5ddf216f6e6ce09b42d0850cea3622792ac33bac5b2eaf64d850372a37", + "packages/ui/src/amicode/stage.ts": "8f9c07187ddb2ab0aeab26a6287529059a363131ba0140fcefec360f6522500b", + "packages/ui/src/amicode/system-render.test.ts": "0e1e30063445a464cf5f4db2b4b6333136b971d942606afb578b0248da8f44bd", + "packages/ui/src/amicode/system-render.ts": "8e0e5287f9a3d6b166c65e999aeb5cdc002cea566f1543c616459878aad10427", + "packages/ui/src/amicode/system-view.tsx": "42d5232e69dbf446b9039fe966e53eb579253915f22c5b161595e938b83bfea1", + "packages/ui/src/amicode/tagline.tsx": "251fe67f072f6590049f58e4174c1da90e1fc1ef8814e5d3c06ce246035d868f", + "packages/ui/src/amicode/thinking-line.stories.tsx": "3bf821de97d2b58487466c44e17bf6e46c0309ce4b563cc8e4a725f1757190d2", + "packages/ui/src/amicode/thinking-line.tsx": "18d121b6d6b25775a9b85c172224b0e9e3f1fd7df8da07d8768a4bf441a857f6", + "packages/ui/src/amicode/thinking.test.ts": "ee926e4971e1af78d4317e90f28550b7d0be57cb7363f7b3c0d6869f10fb2167", + "packages/ui/src/amicode/thinking.ts": "ce2529285621620df168eaaa845cd2957b4fe5e61729fdfa736a8702ccf059b7", + "packages/ui/src/amicode/ui-bridge.ts": "5ed07b8e595e9650a27a32fbc72e7a076b885614ef07cf955d340578342943e2", + "packages/ui/src/amicode/upload.ts": "9a3a59541ba056a5069f5145cc10bbcc8d44caeb7cfce7a59d97415de2e4a149", + "packages/ui/src/amicode/vaults-tab.tsx": "9e93b7424f0d885250cf999a115b1801d5973f3fdb9095a5c29d74e7093af7e6", + "packages/ui/src/amicode/vaults.test.ts": "24826e6b8b083b0a7478e469f3d4fd7319c973da281cb569e3d885d83d3d46d4", + "packages/ui/src/amicode/vaults.ts": "6250b08a8d6493bd42c5407bfcb581a300757b273a587247e3666ddbe25c00af", + "packages/ui/src/amicode/wave-geometry.test.ts": "3a89a89310b8c9ce15b402ced6dec83d691bc4ccf50448f2a89e4c0ff1c80cc7", + "packages/ui/src/amicode/wave-geometry.ts": "79fcd8ce75e280f6de3fae108ae5db4b992e28a8826735a33dbbbebfa68c8f24", + "packages/ui/src/amicode/widget-allowlist.ts": "53c6554561fc694756cac154e1db5520e806dfe6dfb2ffa41ee77bafd18536fe", + "packages/ui/src/amicode/widget-bridge.test.ts": "67ef016e90aef247c16b5dea1362a549f36a5ec44faa5457357b4646d57ff110", + "packages/ui/src/amicode/widget-bridge.ts": "91f5949af16ae3d6c3e5acf3e6853518b9cf7cec19a2534c3636e30270d1e536", + "packages/ui/src/amicode/widget-config-form.tsx": "b62f99deed3abebf14e17788218e9583d277e759f01721d9c7ec9b1377b3c04d", + "packages/ui/src/amicode/widget-frame.tsx": "f4082458ecc50a43d48dc558d16dae5b4b14bf7c19500fc5807e4488ca564753", + "packages/ui/src/amicode/widget-grid.tsx": "075005dae4f54f45598a4aabe9e1cde1aa4f767a574dde3ce7e82a524f61fb7f", + "packages/ui/src/amicode/widget-preview-card.tsx": "2251c16a19252a882ac39fbea9abfb6d6aa368834ef99c3a7fccf6c20ea8f3b5", + "packages/ui/src/amicode/widget-preview.test.ts": "3ba95ada8fa9ce742a3246c52e1b1f5b4df1a9e0d8ad453f41e4468fec3b7e54", + "packages/ui/src/amicode/widget-preview.ts": "21fa605139b96f45675b11a73fa0e8e974be3444feed0f024b8003e96e44927b", + "packages/ui/src/amicode/widget-schema.test.ts": "b24049aeeda3fd2a922b5ce6601d99df9c8d3aa44b3e69dc8510a13120c7353a", + "packages/ui/src/amicode/widget-schema.ts": "e8a0efa3c86ccfae34d2a6feead87b556a2030c1a4e1bbd4b7c5839e116376a6", + "packages/ui/src/amicode/widget-tokens.test.ts": "acf02ce091e4f52597eb89eb3c28643c838632bbc56c73d8051d310568849b6d", + "packages/ui/src/amicode/widget-tokens.ts": "aecb4735d5c8442c95acf9549199649d02dc47eab67413f7037b3f441604df86", + "packages/ui/src/assets/favicon/amico.svg": "7c64b145bdd063bacd53318336a423f0654ec702de2b4e0c4a4a384694052804", + "packages/ui/src/assets/favicon/site.webmanifest": "beee758dd8811f9c76c27cbfa3801f92fc2eff537068b51c555aca4d90804550", + "packages/ui/src/components/amico-spinner.tsx": "75114d38aac924a37fee563e35375ebfd5e96cf3771690d01ad484f91df1c563", + "packages/ui/src/components/amicode-brain-ref.tsx": "e08345bdf064fccf7a0d0f7eab5b269c7eda47aad7d17b17ec91e14a613bb931", + "packages/ui/src/components/amicode-bridge.tsx": "c00ab12a45b9d092abc63a9b259e71bdf3582132aa1c288dcf8a2a520e5a5e80", + "packages/ui/src/components/amicode-card.tsx": "a09c4a734c55bf6fef0d04dcc0f0d4fce437e44a8745aa2ac2c936442d9a7d6e", + "packages/ui/src/components/amicode-connections-tab.tsx": "d184dfcd5c645332c2cc1c79356e551cd53b67a6b61ed16e78d041de5d13fafe", + "packages/ui/src/components/amicode-edit-row.tsx": "99c8c4e90ea02be21f74603c5a5ac1e2c030f487d62ce9da972c99dff505d892", + "packages/ui/src/components/amicode-entity-rail.tsx": "5377561b952d9e6bd9bd6a16cec91a37770f14195bb7bd1cd294eb20a1e78a3d", + "packages/ui/src/components/amicode-entity-view.tsx": "1ff46b6abdca65992606755e0d5e516483bbd8f3f8cef54b81cb6faa08d895dc", + "packages/ui/src/components/amicode-footer.tsx": "d7d7344613c7acbf2d4b0dd3f33c89f67fae6ac065bcea047e7dc26d13bca5a0", + "packages/ui/src/components/amicode-getting-started.tsx": "219524d12d3b7e35a3e6ac4a112c64b8a54ce1b695bb61dc4a0bcc64a7f37e2e", + "packages/ui/src/components/amicode-home-cards.tsx": "1551d6832131782c26e0ff0fe89d3a56777958ba764df9ff754821c412471e4a", + "packages/ui/src/components/amicode-onboarding-wizard.tsx": "9e93fc0a1ff1e4c7ce02444c27924f0073b675d07752032c37f52a4c45f94536", + "packages/ui/src/components/amicode-problem-switcher.tsx": "1dcf478871344108eb25e194f6c1a95e9977944d7f170627d46108ba3a07f1f3", + "packages/ui/src/components/amicode-rail-gate.tsx": "2850e9dda4adba54324be7e88f6fe47fdcda51507771ed2f6599b5773411ca9e", + "packages/ui/src/components/amicode-receipt-runs.tsx": "5c285c7b3bc1e164fdc24ac6f2daf1765a8b74998577b3868fe43b00adb00d26", + "packages/ui/src/components/amicode-receipt.tsx": "bd37403867623f81c8092660df10df7fb7b08e96e80271b492e03e8c36fb662e", + "packages/ui/src/components/amicode-run-card.tsx": "e9295cf59d46a71de0ee5b91c74eea3b4e42b70a9358f39a9a28c2578627f6b3", + "packages/ui/src/components/amicode-run-gallery.tsx": "366919dc8b1fc48d4aeb454bc8f92e5e23621a4570c9084a2889c88952d6f692", + "packages/ui/src/components/amicode-run-window.tsx": "5e5c623adc5d4e3fd35740e9b9cb5bae77e6f3f694850c5828f06a2616b4181f", + "packages/ui/src/components/amicode-shell-row.tsx": "0ce6525dd2cb8c1b04c7779ae477417a7776ec83768b8b028d52817e734587a9", + "packages/ui/src/components/amicode-solver-toggle.tsx": "5c5d2a8ba8201439f490ac27bf40a381ce1f0c82c924de253c3f042d05650a08", + "packages/ui/src/components/amicode-splash.tsx": "0f69ebdd0e4e6595f38b1492c31a28bd1320706c7f1e26411ec4d25d1ab2921f", + "packages/ui/src/components/amicode-thinking.tsx": "85ef2a86f8ebbaa79596ee3783cf5796adcb8160d4680b557db99001428ebd48", + "packages/ui/src/components/amicode-vaults-tab.tsx": "9ad6262db994e2ecc7d7068c95f4a31c6fbb178c531364b0dc3c8fec427a9a0c", + "packages/ui/src/components/amicode-widget-grid.tsx": "df589de093bca703e9fdbbffc6a13020e96ee0ef43e545718f91277ec0b7cd24", + "packages/ui/src/components/brain-engine.tsx": "955723ad42c6abfb785313f62277143142f1248987c45da30eadaf28d5137072", + "packages/ui/src/components/brain-ref.tsx": "c97a0ac0dad470c9b9e194d6ba94d1f1cfbb774bdbea3f798bece4a2a9968a60", + "packages/ui/src/components/context-tree-data.tsx": "ca261e7a5eeed09130af14bb755fc2258833d2ac9efd995418cb32927de05d86", + "packages/ui/src/components/context-tree-engine.tsx": "92ca28c968cb0c1c346c1693163abd4e604287f875b1d53a434966f1c48cef97", + "packages/ui/src/components/dialog.css": "f66f34aa3488b53e2ce1ad19d697a31c3f7cead0307adabaede0d710b17c7401", + "packages/ui/src/components/dock-surface.css": "bc8945d4e19eb122078f98e5700e0b542e9e80901782ec927340a048fa0d3be1", + "packages/ui/src/components/favicon.tsx": "809fd1d2621b0e28e69f8ac8092405b89a11fb7a6dbad2938121f7c0f2f5938d", + "packages/ui/src/components/icon.tsx": "0b78b15fcb531e989ac9d4e20bd36c0e14500bfb21358da85974343f26edd281", + "packages/ui/src/components/logo.css": "f788db461b78bbe33ca2df8d8ff92104fe5c569c7feae124b43c24a5e7e9ab86", + "packages/ui/src/components/logo.tsx": "de12f1af573c8bdee867a4c4c2ac40ba0064b5ffb0f2a0c72f89aea6446ebc63", + "packages/ui/src/components/text-field.tsx": "5ad1870d4858cc582a0cc7bc0fd1c5332b5e5c96901131639d7bc8f2a38350cf", + "packages/ui/src/components/thinking-line.tsx": "956e483aa21029161c26830544cbc2df23fb20a3f342d1618675b6f238d87709", + "packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620", + "packages/ui/src/context/marked-math.test.ts": "0fe460c09956335a469b382ff631809381875465cbcaf7e7c599f86240b0227c", + "packages/ui/src/context/marked.tsx": "b4f693cba3f8020596f860a00293e43855ec75d0bd214e379e36250d02e2be4c", + "packages/ui/src/i18n/en.ts": "42bf7f6816f016d4c201475a41edf1e448bf500aa6f50cb50824ae70cdf8d0e8", + "packages/ui/src/solid-dnd.d.ts": "f1faf22b0773252852ff0865f68a00fa479777f5d0b4602ea4d1660c366793ff", + "packages/ui/src/styles/index.css": "ddafc16fcf2facc68094050e3dba52507016ef148e10d6d7ff5a10a6e8e8b394", + "packages/ui/src/styles/theme.css": "6fca3fb6b91255410f91897a4dcba6a3a91d8387809f36991922896a02e4748e", + "packages/ui/src/theme/context.tsx": "f7488be52021215f12b0cf32d85b869285fb7a878e9d3985082cc8154806395e", + "packages/ui/src/theme/themes/oc-2.json": "539a59886cbee9f2e54362075486d47ac69884ef8a9282f767f7cfd3f1f598a8", + "packages/ui/src/util/clipboard.ts": "218d321e1b0567b76514057ef22cfd23cb843fefc36bed7aab91f072e3233ad0", + "packages/ui/src/v2/components/dialog-v2.css": "ae6f7b1de755929ba529b9dbb0e837c80f620f00a77188b9d2c59f10b0f28bbd", + "packages/ui/src/v2/components/icon-button-v2.tsx": "966e535e3efe503f054045be4476fc20d2fad36092410b52f75e4cf8de6fce6f", + "packages/ui/src/v2/components/icon.tsx": "2a5105e6f5502f511658fa3abd83b1c7ef5047ac67d5520fbf4836ccc47bad19", + "packages/ui/src/v2/components/switch-v2.css": "96406f51a412f931c99c005ecd6a889898ad80160143cca0a81cacfa13714f8f", + "packages/ui/src/v2/components/tooltip-v2.css": "613cbe940ad99dfba4c8bf9312347f2da2625a7e0f9bfefdd0c9cc0a9b4713a8", + "packages/ui/src/v2/components/wordmark-v2.tsx": "6037a68db862ddd67235cbae8dae5a95c9f7403e7c5f268b78fdaaf013950166" + } +} diff --git a/packages/app-bundle/overlay/packages/ui/package.json b/packages/app-bundle/overlay/packages/ui/package.json new file mode 100644 index 00000000..37e0737e --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/package.json @@ -0,0 +1,116 @@ +{ + "name": "@opencode-ai/ui", + "version": "1.18.12", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode.git", + "directory": "packages/ui" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "**/*.css" + ], + "files": [ + "dist/**/*.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + "!src/**/*.stories.ts", + "!src/**/*.stories.tsx", + "src/**/*.css", + "src/theme/themes/*.json", + "src/components/app-icons/sprite.svg", + "src/components/file-icons/sprite.svg", + "src/components/provider-icons/sprite.svg", + "src/assets/icons/app/*", + "src/assets/fonts/*", + "src/assets/audio/*", + "LICENSE" + ], + "exports": { + "./package.json": "./package.json", + "./*": "./src/components/*.tsx", + "./i18n/*": "./src/i18n/*.ts", + "./hooks": "./src/hooks/index.ts", + "./context": "./src/context/index.ts", + "./context/*": "./src/context/*.tsx", + "./storybook/scaffold": "./src/storybook/scaffold.tsx", + "./storybook/fixtures": "./src/storybook/fixtures.ts", + "./styles": "./src/styles/index.css", + "./styles/tailwind": "./src/styles/tailwind/index.css", + "./theme": "./src/theme/index.ts", + "./theme/*": "./src/theme/*.ts", + "./theme/context": "./src/theme/context.tsx", + "./icons/provider": "./src/components/provider-icons/types.ts", + "./icons/file-type": "./src/components/file-icons/types.ts", + "./icons/app": "./src/components/app-icons/types.ts", + "./fonts/*": "./src/assets/fonts/*", + "./audio/*": "./src/assets/audio/*", + "./v2/*.css": "./src/v2/components/*.css", + "./v2/*": "./src/v2/components/*.tsx", + "./v2/styles/*": "./src/v2/styles/*" + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "typecheck": "tsgo --noEmit", + "test": "bun test src --only-failures", + "dev": "vite", + "generate:tailwind": "bun run script/tailwind.ts", + "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts" + }, + "devDependencies": { + "@solidjs/meta": "catalog:", + "@tailwindcss/vite": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "solid-js": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "1.4.0", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-icons-spritesheet": "3.0.1", + "vite-plugin-solid": "catalog:" + }, + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/session-ui": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/event-listener": "2.4.5", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@thisbeyond/solid-dnd": "0.7.5", + "diff": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "katex": "0.16.27", + "luxon": "catalog:", + "marked": "catalog:", + "marked-katex-extension": "5.1.6", + "marked-shiki": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "motion-dom": "12.34.3", + "motion-utils": "12.29.2", + "remeda": "catalog:", + "remend": "catalog:", + "shiki": "catalog:", + "solid-list": "catalog:", + "solid-sonner": "catalog:", + "strip-ansi": "7.1.2" + }, + "peerDependencies": { + "@solidjs/meta": "^0.29.0", + "solid-js": "^1.9.0" + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css new file mode 100644 index 00000000..f6a2ead8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css @@ -0,0 +1,104 @@ +/* AMICODE — Amico third-actor presence (spec-20260712-014749). + * The offset accent lane (Amico's voice in the flow) + the rail wake/settle + * motion. New file so it doesn't collide with the actively-edited amicode.css. + * All colors are v2 tokens; motion is gated behind prefers-reduced-motion using + * the same idiom as amicode.css. Namespaced .amc-* so nothing leaks. */ + +/* ---- the offset accent lane (Amico rides alongside) ---------------------- */ +/* Amico's in-flow contributions live here: indented, with an accent spine and a + * single H-mark head. Attribution by position + accent, never a repeated label. */ +.amc-lane { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0 8px 12px; + padding: 2px 0 2px 12px; + border-left: 2px solid color-mix(in srgb, var(--v2-icon-icon-accent) 55%, transparent); +} +.amc-lane-head { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--v2-text-text-muted); + font-size: 11px; +} +.amc-lane-head .amc-mark { + width: 13px; + height: 13px; + color: var(--v2-icon-icon-accent); +} +/* the one-line receipt left behind on settle */ +.amc-receipt { + font-size: 12px; + color: var(--v2-text-text-base); +} +.amc-receipt .amc-receipt-metric { + color: var(--v2-text-text-accent); + font-variant-numeric: tabular-nums; +} + +/* ---- rail wake / settle -------------------------------------------------- */ +/* The rail is Amico's body; it wakes when Amico steps in and quiets on settle. + * Visibility (dormant => absent) is owned by the rail's own show gate; these + * classes only carry the motion + the live/quiet accent treatment. */ +.amc-rail.is-waking { will-change: opacity, transform; } +.amc-rail.is-live .amc-livedot { + background: var(--v2-icon-icon-accent); +} +.amc-rail.is-quiet .amc-livedot { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); +} + +/* entity chips: pending (—), live (●, pulsing), settled (✓, steady) */ +.amc-entity { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--v2-text-text-muted); +} +.amc-entity[data-status="pending"] { color: var(--v2-text-text-faint); } +.amc-entity[data-status="settled"] { color: var(--v2-text-text-base); } +.amc-entity-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--v2-border-border-base); +} +.amc-entity[data-status="live"] .amc-entity-dot { background: var(--v2-icon-icon-accent); } +.amc-entity[data-status="settled"] .amc-entity-dot { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 70%, transparent); +} + +/* ---- motion (gated) ------------------------------------------------------ */ +@keyframes amc-lane-in { + from { opacity: 0; transform: translateX(-6px); } + to { opacity: 1; transform: none; } +} +@keyframes amc-lane-settle { + from { opacity: 1; } + to { opacity: 0.72; } +} +@keyframes amc-rail-wake { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: none; } +} +@keyframes amc-livedot-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); } + 50% { opacity: 0.5; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 10%, transparent); } +} + +@media (prefers-reduced-motion: no-preference) { + .amc-lane.is-stepping-in { animation: amc-lane-in 260ms ease-out both; } + .amc-lane.is-settling { animation: amc-lane-settle 400ms ease-out both; } + .amc-rail.is-waking { animation: amc-rail-wake 240ms ease-out both; } + /* the real rail is session-sticky (mounts once when Amico first engages) — so + targeting it directly plays the wake exactly once, on entry, with no edit to + entity-rail.tsx (keeps that hot file out of this change). */ + [data-component="amicode-entity-rail"] { animation: amc-rail-wake 260ms ease-out both; } + .amc-rail.is-live .amc-livedot, + .amc-entity[data-status="live"] .amc-entity-dot { + animation: amc-livedot-pulse 1.4s ease-in-out infinite; + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx new file mode 100644 index 00000000..237e4a4f --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx @@ -0,0 +1,141 @@ +// @ts-nocheck +// Self-contained preview of the Amico third-actor presence (spec-20260712). +// Mock data only — NOT wired to real messages — so the interaction/feel is +// reviewable without touching message-part.tsx / entity-rail.tsx (which the +// dashboard agent is actively editing). Shows each presence state + an +// auto-playing pop-in / pop-out sequence. +import { createSignal, onCleanup, onMount, For, Show } from "solid-js" +import { AmicoMark } from "./spinner" +import { ThinkingLine } from "./thinking-line" +import { railVisible, showsWorkingPresence, type PresenceState } from "./amico-presence" +import "./amico-presence.css" + +export default { + title: "Amicode/AmicoPresence", + id: "amicode-amico-presence", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### Amico as a third actor + +Amico pops IN when a turn enters its domain, works, and pops OUT — the rail is +its body (identity + liveness), the message flow is the shared stage (no per-card +AMICO stamps). States: **dormant** (never engaged, rail absent) · **stepping_in** +(rail wakes, presence leans in) · **on** (rail live, thinking line in the lane) · +**settling** (liveness recedes, artifacts settle, receipt left) · **idle** (rail +persists quiet). Mock data; wiring is a coordinated follow-up.`, + }, + }, + }, +} + +const ENTITIES = (state: PresenceState) => { + const live = state === "on" + const done = state === "settling" || state === "idle" + return [ + { name: "System", val: done || live ? "transmon·3lvl" : "", status: done ? "settled" : live ? "live" : "pending" }, + { name: "Formulation", val: done ? "gate" : "", status: done ? "settled" : live ? "live" : "pending" }, + { name: "Run", val: done ? "F=0.99998" : "", status: done ? "settled" : live ? "live" : "pending" }, + { name: "Pulse", val: done ? "412ns" : "", status: done ? "settled" : "pending" }, + ] +} + +function MockRail(props: { state: PresenceState }) { + const live = () => showsWorkingPresence(props.state) + return ( + +
+ + AMICO + + x-gate-transmon-warm ▾ + + {(e) => ( + + + {e.name}{e.val ? ` ${e.val}` : " —"} + + )} + +
+
+ ) +} + +function MockFlow(props: { state: PresenceState }) { + return ( +
+ {/* user (right) */} +
+ optimize an X gate on my transmon — use what you know from my history +
+ {/* normal chat prose (full width) */} + +
+ Warm-starting from x-gate-transmon-v5 (F=0.999972). Here's the plan… +
+
+ +
The capital of France is Paris. [normal chat — Amico stays offstage]
+
+ + {/* Amico lane */} + +
+ {/* stepping_in gets a bare lane-head; the thinking block (wave+verb) owns the working indicator now */} + +
stepping in…
+
+ + + +
+
+ +
+
+
ran it — F=0.99998, 412ns · [pulse card] [open in inspector]
+
+
+
+ ) +} + +function Stage(props: { state: PresenceState; label?: string }) { + return ( +
+ +
{props.label}
+
+ + +
+ ) +} + +export const Dormant = () => +export const SteppingIn = () => +export const On = () => +export const Settling = () => +export const Idle = () => + +// Auto-playing pop-in / pop-out loop so the motion is visible at a glance. +export const FullSequence = () => { + const order: PresenceState[] = ["dormant", "stepping_in", "on", "on", "settling", "idle", "idle"] + const [i, setI] = createSignal(0) + onMount(() => { + const t = setInterval(() => setI((n) => (n + 1) % order.length), 1800) + onCleanup(() => clearInterval(t)) + }) + return ( +
+
▶ {order[i()]}
+ +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.test.ts new file mode 100644 index 00000000..4b1de490 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { + classifyPresence, + transitionFor, + railVisible, + showsWorkingPresence, + type StablePresence, +} from "./amico-presence" + +describe("classifyPresence", () => { + test("never engaged, off-domain -> dormant", () => { + expect(classifyPresence({ everEngaged: false, inDomainTurn: false, working: false })).toBe("dormant") + expect(classifyPresence({ everEngaged: false, inDomainTurn: false, working: true })).toBe("dormant") + }) + + test("in-domain and working -> on (regardless of prior engagement)", () => { + expect(classifyPresence({ everEngaged: false, inDomainTurn: true, working: true })).toBe("on") + expect(classifyPresence({ everEngaged: true, inDomainTurn: true, working: true })).toBe("on") + }) + + test("engaged earlier, now off-domain -> idle (NOT dormant — rail persists)", () => { + expect(classifyPresence({ everEngaged: true, inDomainTurn: false, working: false })).toBe("idle") + }) + + test("resolved in-domain turn (in-domain, not working) -> idle -> drives settling", () => { + // once a solve finishes, working flips false; the turn is engaged => idle + expect(classifyPresence({ everEngaged: true, inDomainTurn: true, working: false })).toBe("idle") + }) +}) + +describe("transitionFor", () => { + const cases: Array<[StablePresence, StablePresence, "stepping_in" | "settling" | null]> = [ + ["dormant", "on", "stepping_in"], + ["idle", "on", "stepping_in"], + ["on", "idle", "settling"], + ["on", "dormant", "settling"], + ["dormant", "idle", null], + ["idle", "dormant", null], + ["on", "on", null], + ["idle", "idle", null], + ] + for (const [prev, next, want] of cases) { + test(`${prev} -> ${next} => ${want}`, () => { + expect(transitionFor(prev, next)).toBe(want) + }) + } +}) + +describe("railVisible", () => { + test("absent only in dormant", () => { + expect(railVisible("dormant")).toBe(false) + for (const s of ["stepping_in", "on", "settling", "idle"] as const) { + expect(railVisible(s)).toBe(true) + } + }) +}) + +describe("showsWorkingPresence", () => { + test("only on / stepping_in", () => { + expect(showsWorkingPresence("on")).toBe(true) + expect(showsWorkingPresence("stepping_in")).toBe(true) + expect(showsWorkingPresence("settling")).toBe(false) + expect(showsWorkingPresence("idle")).toBe(false) + expect(showsWorkingPresence("dormant")).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.ts new file mode 100644 index 00000000..8145fd06 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.ts @@ -0,0 +1,71 @@ +// AMICODE: the Amico "presence" state machine — the shared brain the entity rail +// and the message flow both read so they can't drift out of sync (spec +// spec-20260712-014749-amico-third-actor). Amico is a third actor: it pops IN +// when a turn enters its domain, works, and pops OUT — leaving the transcript +// clean and its artifacts settled in the rail. +// +// Pure + DOM-free so it's unit-testable. Two pieces: +// classifyPresence() — the STABLE state from a snapshot of turn signals. +// transitionFor() — the transient pop-in / pop-out treatment to play when +// the stable state changes (STEPPING_IN / SETTLING). +// The component holds the previous stable state, computes the next via +// classify(), plays any transient via transitionFor(), then rests at next. + +export type PresenceState = "dormant" | "stepping_in" | "on" | "settling" | "idle" + +/** The transient (animated) states, distinct from the three stable ones. */ +export type PresenceTransition = "stepping_in" | "settling" + +/** The three stable states a snapshot can resolve to (transients are played + * between them by the component, driven by transitionFor). */ +export type StablePresence = "dormant" | "on" | "idle" + +export interface PresenceSignals { + /** Has this session EVER engaged Amico? (any amicode_* part seen — mirrors the + * rail's session-sticky `amicodeParts().any > 0`.) Distinguishes DORMANT + * (rail absent) from IDLE (rail persists with settled entities). */ + everEngaged: boolean + /** Does the CURRENT turn carry an amicode_* part? The only in-domain signal + * observable in the UI today (spec §Domain trigger). */ + inDomainTurn: boolean + /** Is the current turn still streaming/working? */ + working: boolean +} + +/** + * Stable presence from a snapshot of signals. + * - in-domain & working -> "on" (rail live, thinking line in flow) + * - engaged this session, else off -> "idle" (rail persists, quiet) + * - never engaged -> "dormant"(rail absent; just user + chat) + * + * A resolved in-domain turn (inDomainTurn && !working) is engaged-but-not-working + * => "idle"; the ON->IDLE change is what makes transitionFor emit "settling". + */ +export function classifyPresence(signals: PresenceSignals): StablePresence { + if (signals.inDomainTurn && signals.working) return "on" + return signals.everEngaged ? "idle" : "dormant" +} + +/** + * The transient to play when the stable state changes: + * - (dormant|idle) -> on => "stepping_in" (Amico pops in: rail wakes, presence leans in) + * - on -> (idle|dormant) => "settling" (Amico pops out: liveness recedes, artifacts settle) + * - anything else => null (no pop; e.g. dormant<->idle is silent) + */ +export function transitionFor(prev: StablePresence, next: StablePresence): PresenceTransition | null { + if (prev !== "on" && next === "on") return "stepping_in" + if (prev === "on" && next !== "on") return "settling" + return null +} + +/** True while the rail should be shown at all (i.e. the session has engaged, or + * is engaging now). DORMANT is the only state with no rail. */ +export function railVisible(state: PresenceState): boolean { + return state !== "dormant" +} + +/** True while Amico's live working presence (the thinking line) belongs in the + * flow — only in the ON (and its lead-in STEPPING_IN) states. */ +export function showsWorkingPresence(state: PresenceState): boolean { + return state === "on" || state === "stepping_in" +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.stories.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.stories.tsx new file mode 100644 index 00000000..c54348fb --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.stories.tsx @@ -0,0 +1,315 @@ +// @ts-nocheck +// AmicoWave: the harmonic working indicator. Geometry/timing live in wave-geometry.ts and are +// unit-tested there; this file is the only place the SVG + CSS actually render in isolation, so +// it is also the only place a CSS regression (wrong ink, broken quadrature, id collisions across +// simultaneous mounts) would be visible before it ships. The glyph mounts inside the thinking +// block (thinking-line.tsx) in the app — these stories remain the focused way to see it. +// +// ---- why Default/Schemes exist ------------------------------------------------------------- +// A review caught a critical color bug that no unit test could have caught: the component +// used to pick its dark-scheme ink via `:root:not([data-theme="light"])`, a selector copied +// from a block in v2/styles/theme.css that lives INSIDE a /* */ comment — dead CSS — so it +// matched in every state and the glyph rendered brand lemon (~1.1:1 contrast) on light +// backgrounds. It's fixed to `color: var(--v2-icon-icon-accent)`, which the active theme +// (oc-2) maps to grey-800 on light and #FFF676 on dark. Default and Schemes below exist so +// that mapping can never silently regress again. +// +// ---- the wrinkle: how these stories actually re-resolve the tokens ------------------------ +// The obvious approach — set `data-color-scheme="light"`/`"dark"` on a wrapper element, per +// theme/context.tsx (`dataset.colorScheme`) and v2/styles/theme.css's +// `[data-color-scheme="light"]` / `[data-color-scheme="dark"]` blocks — turns out to be a +// no-op INSIDE STORYBOOK. Storybook's preview only imports `@opencode-ai/ui/styles/tailwind`, +// whose theme file is the v1 `styles/theme.css`; the v2 file that defines those +// `[data-color-scheme]` blocks is only ever pulled in by the real app's +// `v2/styles/tailwind.css` chain (packages/app/src/index.css), which Storybook never loads. +// Verified empirically with Playwright against a running Storybook: a nested +// `data-color-scheme="light"` (or "dark") div's `--v2-*` custom properties were untouched by +// the attribute and simply inherited whatever the global theme toolbar had already put on +// — both a "light" and a "dark" test div resolved to the SAME `--v2-icon-icon-accent`. +// So a story built the literal way the review described would silently show the SAME color +// twice, which is exactly the failure mode it was meant to catch. +// +// Instead, each scheme pane below calls the SAME resolver the app uses at runtime — +// `resolveThemeVariantV2` over the real oc-2.json theme (theme/v2/resolve.ts, the function +// theme/context.tsx's applyThemeCss calls) — and applies the FULL resulting token set +// (188 keys: primitive ramps + semantic aliases, self-contained) as inline custom properties +// on that pane's own wrapper. That correctly re-resolves `--v2-icon-icon-accent` (and every +// other v2 token used inside) independent of whatever the global Storybook theme toggle is +// doing, because inline-set custom properties on an element always win for that subtree. This +// is arguably MORE faithful than the dead attribute would have been: it reads the live oc-2 +// mapping from its source file, so it tracks theme changes instead of a hand-copied hex. +import { createSignal, For, onMount } from "solid-js" +import { AmicoWave } from "./amico-wave" +import { MODE_WAVELENGTHS, WAVE_BOX } from "./wave-geometry" +import oc2ThemeJson from "../theme/themes/oc-2.json" +import { resolveThemeVariantV2 } from "../theme/v2/resolve" +import type { DesktopTheme } from "../theme/types" + +const oc2Theme = oc2ThemeJson as DesktopTheme + +/** The real oc-2 --v2-* token set for one color scheme, self-contained (primitive ramps + + * semantic aliases both included), so it can be applied to any wrapper and resolve correctly + * with zero dependency on ambient state — in particular, independent of the Storybook global + * theme toolbar. This is the exact function theme/context.tsx calls to paint ; here we + * scope its output to a
instead. */ +function schemeVars(scheme: "light" | "dark"): Record { + const isDark = scheme === "dark" + const tokens = resolveThemeVariantV2(isDark ? oc2Theme.dark : oc2Theme.light, isDark) + const vars: Record = {} + for (const [key, value] of Object.entries(tokens)) vars[`--${key}`] = value + return vars +} + +// Scale + freeze helpers used by Schemes/Modes/Magnified/ReducedMotion below. AmicoWave sets +// width/height as SVG presentation attributes (30x12); a plain CSS class rule already beats +// those with no !important needed, per the CSS spec's presentation-attribute priority rule. +const StoryCss = () => ( + +) + +function SchemePane(props: { scheme: "light" | "dark"; children: unknown }) { + return ( +
+
+ {props.scheme} scheme +
+ {props.children} +
+ ) +} + +// Mimics the real mount site (thinking-line.tsx): glyph and bold gerund on the +// top row; the muted meta below, starting under the glyph — reusing the actual +// .amc-thinking* classes from amicode.css. DOM order is word-first so the +// block's first baseline is the verb's (see thinking-line.tsx). +const ThinkingRow = () => ( + + Percolating… + + 5m 13s · ↑ 2.4k tokens + +) + +export default { + title: "Amicode/AmicoWave", + id: "amicode-amico-wave", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### AmicoWave + +The harmonic working indicator: a 30×12px standing wave in quadrature (a lead curve and a +fainter companion, a quarter period out of phase, so one is at full swing exactly when the +other crosses zero — what keeps the glyph from reading as a blink at 12px). It climbs through +three modes (1/2/3 full wavelengths across the box) on a slower cadence. All geometry and +timing come from wave-geometry.ts via CSS custom properties set inline by the component — +never restated here. + +Not yet mounted anywhere in the app directly — it renders inside the thinking block +(thinking-line.tsx, mounted by the app timeline and the session-ui lane) — so these stories +remain the focused way to see the glyph itself. + +**Hard invariant, guarded by ManyInstances below:** no SVG \`\`, no \`id\` attributes. +SVG ids are document-global, and several indicators mount at once in real use (one thinking +line plus one tool header per in-flight tool call) — an id would collide across instances.`, + }, + }, + }, +} + +// --------------------------------------------------------------------------------------------- +// Default — the glyph at natural size beside its real-use text, in both schemes side by side +// so an ink regression (wrong token, or a hardcoded color that ignores scheme entirely) is +// visible without needing the Schemes story's numeric readout. +export const Default = () => ( +
+ + + + + + +
+) + +// --------------------------------------------------------------------------------------------- +// Schemes — the contrast check. getComputedStyle(el).color reads what the browser ACTUALLY +// resolved for that scheme's pane, not what the token mapping merely intends. This is the +// story that would have caught the original bug: the buggy selector lived in a dead comment, +// so the glyph's color never actually changed with scheme — it would have printed the SAME +// resolved color under both panes here. +function ContrastSwatch(props: { scheme: "light" | "dark" }) { + const [resolved, setResolved] = createSignal("…") + let wrap: HTMLDivElement | undefined + onMount(() => { + const svg = wrap?.querySelector('[data-component="amico-wave"]') + if (svg) setResolved(getComputedStyle(svg).color) + }) + return ( +
+
+ {props.scheme} +
+
+ +
+ + getComputedStyle → {resolved()} + +
+ ) +} + +export const Schemes = () => ( + <> + +
+ + +
+ +) + +// --------------------------------------------------------------------------------------------- +// Modes — each of the three standing modes, frozen and isolated, at 4x, so the shape reads. +// Wavelength is indexed by full wavelengths across the 30px box (1, 2, 3) — deliberately NOT +// the physical harmonic number; see wave-geometry.ts's MODE_WAVELENGTHS comment for why. +function FrozenMode(props: { pin: number; wavelength: number; waves: number }) { + return ( +
+
+ +
+
+ mode {props.pin} — λ={props.wavelength}px, {props.waves} full wavelength{props.waves === 1 ? "" : "s"} across + the box +
+
+ ) +} + +export const Modes = () => ( + <> + +
+ + {(wavelength, i) => } + +
+ +) + +// --------------------------------------------------------------------------------------------- +// Magnified — one live glyph at 6x so the quadrature is visible at a glance: the faint +// companion is at full swing exactly when the bold lead crosses the axis. +export const Magnified = () => ( + <> + +
+ +

+ The companion (faint, 0.4 opacity) is a quarter period behind the lead — it peaks + exactly when the lead crosses zero, which is what stops the glyph reading as a blink at + the real 12px size. +

+
+ +) + +// --------------------------------------------------------------------------------------------- +// ManyInstances — the regression guard for the no-/no-id rule. SVG ids are +// document-global: if one is ever introduced (for a mask, a gradient, anything), every +// instance after the first resolves to the FIRST element's definition and visibly breaks — +// invisibly on inspection of a single instance, but obvious the moment two or more are mounted +// at once, which is the normal case in the real app (one thinking line plus one tool header +// per in-flight tool call). +export const ManyInstances = () => ( +
+

+ 12 live instances, mounted simultaneously. AmicoWave has no SVG <defs> and no id + attribute anywhere — this is the guard for that: if either is ever added, ids collide + across instances and every glyph after the first will visibly break here (even though a + single isolated instance would still look correct). +

+
+ {() => } +
+
+) + +// --------------------------------------------------------------------------------------------- +// ReducedMotion — a mockup of the CSS's own @media (prefers-reduced-motion: reduce) fallback +// (amicode.css): one static mode-1 curve, companion hidden, no animation. This is authored +// locally to DEMONSTRATE the intended fallback, not to test it live — Storybook cannot force +// the OS/browser reduced-motion setting. To see the real fallback, enable "reduce motion" at +// the OS level (or the equivalent DevTools rendering emulation) and reload; every AmicoWave in +// every story on this page will then freeze the same way. +export const ReducedMotion = () => ( + <> + +
+
+ +
+

+ Mockup only — driven by the OS/browser prefers-reduced-motion setting in + the real component, which Storybook cannot toggle. The elapsed counter in the thinking + line keeps ticking under reduced motion; only this glyph's own animation stops. +

+
+ +) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.tsx new file mode 100644 index 00000000..a85da8dd --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-wave.tsx @@ -0,0 +1,73 @@ +// AMICODE: the harmonic working indicator — a standing wave in quadrature, shown while +// Amico works. Mounted inside the thinking block (thinking-line.tsx), which replaced the +// old shimmer treatment there; the tool-header surface remains unwired. +// +// Markup only: all geometry and timing come from ./wave-geometry, handed to the CSS as +// custom properties so there is exactly one source of truth. Two paths per mode (lead + +// companion out of phase by a quarter period); the companion is at full swing exactly when +// the lead crosses zero, which is what stops the glyph reading as a blink at 12px. +// +// NO and NO ids — several indicators mount at once (one thinking line plus one tool +// header per tool call) and SVG ids are document-global, so ids would collide and every +// instance would resolve to the first definition. If a variant ever needs masking it must +// use CSS mask-image, not an SVG . +import { For } from "solid-js" +import { + WAVE_BOX, + WAVE_LEAD_STROKE, + WAVE_COMPANION_STROKE, + WAVE_COMPANION_OPACITY, + WAVE_PERIOD_MS, + WAVE_EASING, + MODE_PATHS, + companionDelayMs, + modeCadenceMs, + modeDelaysMs, +} from "./wave-geometry" + +const DELAYS = modeDelaysMs() + +export function AmicoWave(props: { class?: string }) { + return ( + + ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css b/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css new file mode 100644 index 00000000..9d0eccb1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css @@ -0,0 +1,1604 @@ +/* AMICODE surfaces — the in-chat receipt card (card.tsx), the entity-view + * dialog body (entity-view.tsx), the problem-header rail (entity-rail.tsx), the + * question card (ask-card.tsx) and the run window (run-window.tsx). One shared + * visual language: a 1px hairline with a 3px accent rail, the H-mark as the + * signature (and, while working, the spinner), state carried in form as well as + * words. All colors come from the v2 theme tokens; only failure earns the one + * red rail. Namespaced by [data-component="amicode-*"] so nothing leaks. + * + * Imported from styles/index.css (components layer). Interaction/animation/ + * pseudo-element rules live here because inline styles can't express them. */ + +@keyframes amc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.32; } +} +@keyframes amc-blip { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); } + 50% { opacity: 0.45; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 12%, transparent); } +} +@keyframes amc-shimmer { + 100% { transform: translateX(100%); } +} + +/* ---- shared signature (mark + wordmark) ---------------------------------- */ +.amc-mark { + width: 13px; + height: 13px; + flex-shrink: 0; + color: var(--v2-icon-icon-accent); + fill: currentColor; +} +@media (prefers-reduced-motion: no-preference) { + .amc-mark.is-running { animation: amc-pulse 1.2s ease-in-out infinite; } +} +.amc-sig { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.amc-wordmark { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.11em; + color: var(--v2-text-text-accent); +} +.amc-rule { + width: 1px; + align-self: stretch; + margin: 2px 10px; + background: var(--v2-border-border-base); + flex-shrink: 0; +} + +/* ---- thinking block (Claude-Code-esque working indicator) ---------------- */ +/* Two-row grid shown while a reply streams (thinking-line.tsx): the harmonic + * wave and cycling gerund ride the top row; the live meta line (elapsed · + * tokens · esc) sits below, starting under the wave. Anti-glitch invariants, + * earned the hard way: + * - GRID, never flex-wrap — the old flex row once let a greedy sibling + * heading squeeze the meta until "↑ 8.7k tokens" shattered across three + * lines, and baseline alignment scattered the wave/verb; + * - the verb reserves the longest gerund's width (16ch covers + * "Fidelitymaxxing…") so word rotation never shifts the layout around it; + * - the meta renders from mount (elapsed always ticks), so the block's height + * is constant whether or not tokens/hints are present; + * - nowrap + ellipsis on the meta for absurdly narrow panes. + * DOM order is word-first so the block's first baseline is the VERB's — the + * sibling activity heading (session-turn-thinking) aligns with the verb, not + * the wave's edge. Explicit grid placement keeps the visual order. */ +.amc-thinking { + display: inline-grid; + grid-template-columns: auto 1fr; + column-gap: 8px; + row-gap: 2px; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 11px; + letter-spacing: 0.01em; + color: var(--v2-text-text-muted); +} +.amc-thinking .amc-wave { grid-column: 1; grid-row: 1; } +.amc-thinking-word { + grid-column: 2; + grid-row: 1; + font-weight: 600; + white-space: nowrap; + min-width: 16ch; + color: var(--v2-text-text-accent); +} +.amc-thinking-meta { + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--v2-text-text-faint); + font-variant-numeric: tabular-nums; +} +/* inline children (no flex) so the whole meta truncates as one line of text */ +.amc-thinking-meta > * + * { margin-left: 6px; } +.amc-thinking-sep { opacity: 0.55; } +.amc-thinking-hint { font-style: italic; } + +/* ---- an opened skill file (message-part.tsx, ToolRegistry "skill") ------- */ +/* Expanding a skill used to dump its instructions as bare markdown straight into the + * transcript, because [data-component="tool-output"] carries no surface of its own — no + * border, no background, no padding. So an opened skill read as more conversation. It is a + * FILE, so it gets a file's surface: hairline, tint, padding, and a bounded height that + * scrolls rather than pushing the transcript around. + * + * Scoped to skills — every other tool's output keeps the plain treatment. The chip ABOVE this + * stays flush left with the other Amico chips; an earlier attempt indented the whole + * activation behind an accent spine and broke the shared left edge the chips read along. + * + * Compound selector, not a bare class: `[data-component="tool-output"]` is also (0,1,0), so a + * lone `.amc-skill-file` would tie on specificity and be decided by stylesheet order. */ +[data-component="tool-output"].amc-skill-file { + margin-top: 6px; + padding: 10px 12px; + max-height: 420px; + overflow-y: auto; + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + /* tool-output sets `white-space: pre` for raw command output; rendered markdown must wrap. */ + white-space: normal; +} + +/* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */ +/* A standing wave in quadrature: two identical curves, the companion a quarter period out + * of phase, so one is at full swing whenever the other crosses zero. + * + * Every duration, delay and opacity here comes from wave-geometry.ts via a custom property + * set inline by the component — do NOT restate those numbers in this file. */ +.amc-wave { + display: block; + flex-shrink: 0; + /* The ink role, already resolved by the active theme: oc-2.json maps + * v2-icon-icon-accent to grey-800 on light and #FFF676 on dark. The brand lemon is a + * fill, never an ink (1.1:1 on white), and this token is what encodes that — no theme + * selector needed here. Same treatment as amico-presence.css. */ + color: var(--v2-icon-icon-accent); +} + +/* .amc-wave-ln / .amc-wave-mode are component-private — only amico-wave.tsx ever sets the + * custom properties these animation shorthands read. Both shorthands carry a var(), which + * makes the whole shorthand a pending-substitution value: if any referenced custom property + * is ever missing (e.g. someone hand-writes in a fixture without the + * component's inline style block), the ENTIRE shorthand goes invalid and silently falls back + * to no animation — a static glyph, no console warning. */ +.amc-wave-ln { + transform-box: view-box; + transform-origin: 50% 50%; + animation: amc-wave-stand var(--amc-wave-period) var(--amc-wave-ease) infinite; +} +.amc-wave-ln[data-role="companion"] { + opacity: var(--amc-wave-comp-op); + animation-delay: var(--amc-wave-quad); +} +@keyframes amc-wave-stand { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(-1); } +} + +.amc-wave-mode { + animation: amc-wave-mode var(--amc-wave-cadence) steps(1) infinite; +} +/* THE ONE UNAVOIDABLE DUPLICATION. 33.3333% is MODE_VISIBLE_PCT from wave-geometry.ts, and it + * cannot be a var(): @keyframes selectors are not a property-value context, so custom + * properties are invalid there. Writing 33% instead would leave a ~23ms window each cadence + * with NO mode visible — a per-cycle flicker. A test in wave-geometry.test.ts reads this file + * and asserts this literal still matches the constant, so the two cannot drift. */ +@keyframes amc-wave-mode { + 0%, 33.3333% { opacity: 1; } + 33.3334%, 100% { opacity: 0; } +} + +/* Static mode-1 curve, companion hidden. The elapsed counter in the thinking line keeps + * ticking — it is information, not decoration. */ +@media (prefers-reduced-motion: reduce) { + .amc-wave-ln, + .amc-wave-mode { animation: none !important; } + .amc-wave-ln[data-role="companion"] { opacity: 0 !important; } + .amc-wave-mode:not(:first-child) { opacity: 0 !important; } +} + +/* ============================================================ + THE CHIP — [data-component="amicode-card"] + ============================================================ */ +[data-component="amicode-card"] { + -webkit-appearance: none; + appearance: none; + font: inherit; + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; + text-align: left; + /* Rail-free and tint-free: neutral hairline + surface, no gold accent. */ + border: 1px solid var(--v2-border-border-base); + /* medium corners suit a short single-line pill; --radius-lg over-rounds it (Kate 2026-07-24) */ + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + padding: 4px 8px; + font-size: 12px; + line-height: 16px; + color: var(--v2-text-text-base); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); + transition: + background 0.16s ease, + border-color 0.16s ease, + box-shadow 0.16s ease; +} +[data-component="amicode-card"][data-clickable="true"] { + cursor: pointer; +} +[data-component="amicode-card"][data-clickable="true"]:hover { + background: var(--v2-background-bg-layer-02); + border-color: var(--v2-border-border-strong); + box-shadow: var(--v2-elevation-raised, 0 1px 2px rgba(0, 0, 0, 0.25)); +} +[data-component="amicode-card"][data-clickable="true"]:active { + /* pressed = elevation drop, never a bounds-moving transform */ + box-shadow: none; +} +[data-component="amicode-card"]:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 2px; +} +[data-component="amicode-card"][data-state="error"] { + border-color: color-mix(in srgb, var(--v2-state-fg-danger) 38%, var(--v2-border-border-base)); + background: color-mix(in srgb, var(--v2-state-fg-danger) 6%, var(--v2-background-bg-layer-01)); +} +[data-component="amicode-card"][data-state="error"] .amc-mark, +[data-component="amicode-card"][data-state="error"] .amc-wordmark { + color: var(--v2-state-fg-danger); +} +[data-component="amicode-card"] .amc-mark { + width: 13px; + height: 13px; +} + +[data-component="amicode-card"] .amc-body { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + overflow: hidden; +} +[data-component="amicode-card"] .amc-label { + font-weight: 650; + color: var(--v2-text-text-base); + white-space: nowrap; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-detail { + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +[data-component="amicode-card"][data-state="running"] .amc-detail { + color: var(--v2-text-text-accent); +} +[data-component="amicode-card"][data-state="error"] .amc-detail { + color: var(--v2-state-fg-danger); +} +/* a collapsed run's count (receipt-runs.ts) — short and always-visible, so it + never loses the ellipsis race against a long diff value beside it */ +[data-component="amicode-card"] .amc-count { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +/* structured diff pieces */ +[data-component="amicode-card"] .amc-diff { + display: inline-flex; + align-items: baseline; + gap: 4px; + white-space: nowrap; +} +[data-component="amicode-card"] .amc-diff + .amc-diff { + padding-left: 7px; + margin-left: 2px; + border-left: 1px solid var(--v2-border-border-muted); +} +[data-component="amicode-card"] .amc-diff .k { color: var(--v2-text-text-muted); } +[data-component="amicode-card"] .amc-diff .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-base); +} +[data-component="amicode-card"] .amc-diff .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-card"] .amc-diff .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 1px; +} + +[data-component="amicode-card"] .amc-trail { + display: inline-flex; + align-items: center; + gap: 7px; + margin-left: 12px; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-tick { + width: 12px; + height: 12px; + color: var(--v2-state-fg-success); + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-livedot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--v2-icon-icon-accent); +} +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-card"][data-state="running"] .amc-livedot { animation: amc-blip 1.2s ease-in-out infinite; } +} +[data-component="amicode-card"] .amc-chev { + color: var(--v2-text-text-faint); + font-size: 15px; + line-height: 1; + opacity: 0; + transform: translateX(-3px); + transition: opacity 0.16s ease, transform 0.16s ease, color 0.16s ease; +} +[data-component="amicode-card"][data-clickable="true"]:hover .amc-chev, +[data-component="amicode-card"][data-clickable="true"]:focus-visible .amc-chev { + opacity: 1; + transform: translateX(0); + color: var(--v2-text-text-muted); +} +/* keyboard/touch: no hover — keep the affordance visible so it never hides */ +@media (hover: none) { + [data-component="amicode-card"][data-clickable="true"] .amc-chev { opacity: 0.6; transform: none; } +} + +/* ============================================================ + ENTITY VIEW — [data-component="amicode-entity-view"] (dialog body) + ============================================================ */ +[data-component="amicode-entity-view"] { + display: flex; + flex-direction: column; + padding: 4px 20px 20px; + color: var(--v2-text-text-base); +} + +[data-component="amicode-entity-view"] .amc-ev-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 2px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 600; + padding: 3px 7px; + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier[data-tier="free"] { + background: var(--v2-state-bg-warning); + color: var(--v2-state-fg-warning); +} + +[data-component="amicode-entity-view"] .amc-ev-formula { + margin: 8px 0; + padding: 16px 16px; + border-radius: var(--radius-lg); + background: var(--v2-background-bg-layer-01); + border: 1px solid var(--v2-border-border-muted); + overflow-x: auto; +} + +[data-component="amicode-entity-view"] .amc-ev-sec { + display: flex; + align-items: center; + gap: 10px; + margin: 12px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-sec::after { + content: ""; + flex: 1; + height: 1px; + background: var(--v2-border-border-muted); +} +/* a section header that LEADS a modal (Run, Pulse, system-without-identity) + shouldn't carry the full inter-section top gap — trim it so the dialog doesn't + open with dead space. Only applies when the header is the first element. */ +[data-component="amicode-entity-view"] .amc-ev-sec:first-child { margin-top: 4px; } + +[data-component="amicode-entity-view"] .amc-ev-group { + margin: 8px 4px 4px; + font-size: 11px; + font-weight: 600; + color: var(--v2-text-text-muted); +} + +[data-component="amicode-entity-view"] .amc-field { + display: grid; + grid-template-columns: 10rem 1fr auto; + align-items: baseline; + gap: 16px; + padding: 4px 8px 4px 4px; + border-radius: var(--radius-md); + transition: background 0.12s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover { background: var(--v2-background-bg-layer-01); } +[data-component="amicode-entity-view"] .amc-field .amc-fk { min-width: 0; } +/* match the shared entity row: an uppercase micro-label, wire key as faint sub */ +[data-component="amicode-entity-view"] .amc-field .amc-fk .name { + font-size: 0.68rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-field .amc-fk .raw { + display: block; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-faint); + margin-top: 1px; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv { + font-size: 0.82rem; + color: var(--v2-text-text-base); + text-align: left; + font-variant-numeric: tabular-nums; + word-break: break-word; + min-width: 0; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed { color: var(--v2-text-text-accent); } +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed::before { + content: "•"; + color: var(--v2-icon-icon-accent); + padding-right: 6px; + font-size: 10px; + vertical-align: middle; +} + +[data-component="amicode-entity-view"] .amc-edit { + -webkit-appearance: none; + appearance: none; + justify-self: end; + font: inherit; + font-size: 11px; + color: var(--v2-text-text-muted); + background: transparent; + border: 1px solid var(--v2-border-border-muted); + border-radius: var(--radius-md); + padding: 2px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + opacity: 0.55; + transition: opacity 0.14s ease, color 0.14s ease, border-color 0.14s ease, background 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit { opacity: 1; } +[data-component="amicode-entity-view"] .amc-edit:hover { + color: var(--v2-text-text-accent); + border-color: var(--accent-edge); + background: var(--accent-fill-soft); +} +[data-component="amicode-entity-view"] .amc-edit:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 1px; + opacity: 1; +} +[data-component="amicode-entity-view"] .amc-edit .amc-edit-label { + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.18s ease, opacity 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit .amc-edit-label, +[data-component="amicode-entity-view"] .amc-edit:focus-visible .amc-edit-label { + max-width: 90px; + opacity: 1; +} + +/* Collapsed-by-default provenance: one summary line + a show/hide toggle. */ +[data-component="amicode-entity-view"] .amc-ev-hist-head { + display: flex; + align-items: baseline; + gap: 10px; + margin: 18px 4px 0; +} +[data-component="amicode-entity-view"] .amc-ev-hist-summary { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--v2-text-text-faint); + overflow-wrap: anywhere; +} +[data-component="amicode-entity-view"] .amc-ev-hist-toggle { + flex-shrink: 0; + background: none; + border: none; + padding: 2px 4px; + font-size: 11px; + font-weight: 600; + color: var(--v2-text-text-accent); + cursor: pointer; +} +[data-component="amicode-entity-view"] .amc-ev-hist-toggle:hover { text-decoration: underline; } +[data-component="amicode-entity-view"] .amc-ev-hist-toggle:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 2px; + border-radius: var(--radius-sm); +} +[data-component="amicode-entity-view"] .amc-timeline { + display: flex; + flex-direction: column; + margin-top: 8px; + margin-left: 6px; + padding-left: 2px; + border-left: 1.5px solid var(--v2-border-border-muted); +} +[data-component="amicode-entity-view"] .amc-event { + position: relative; + padding: 8px 8px 8px 20px; + border-radius: var(--radius-md); +} +[data-component="amicode-entity-view"] .amc-event::before { + content: ""; + position: absolute; + left: -5px; + top: 15px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--v2-background-bg-base); + border: 1.5px solid var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored] { + background: var(--accent-fill-soft); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored]::before { + background: var(--v2-icon-icon-accent); + border-color: var(--v2-icon-icon-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 18%, transparent); +} +[data-component="amicode-entity-view"] .amc-event .erow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-component="amicode-entity-view"] .amc-event .seq { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-muted); + background: var(--v2-background-bg-layer-02); + border-radius: var(--radius-sm); + padding: 1px 6px; +} +[data-component="amicode-entity-view"] .amc-event .src { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event .when { + font-size: 11px; + color: var(--v2-text-text-faint); + margin-left: auto; +} +[data-component="amicode-entity-view"] .amc-event .summary { + font-size: 13px; + color: var(--v2-text-text-base); + margin-top: 3px; +} +[data-component="amicode-entity-view"] .amc-event .summary .k { color: var(--v2-text-text-muted); } +[data-component="amicode-entity-view"] .amc-event .summary .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 12px; +} +[data-component="amicode-entity-view"] .amc-event .summary .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-entity-view"] .amc-event .summary .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 4px; +} + +[data-component="amicode-entity-view"] .amc-ev-foot { + display: flex; + align-items: center; + gap: 9px; + margin-top: 18px; + padding: 12px 4px 8px; + border-top: 1px solid var(--v2-border-border-muted); + font-size: 12px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-foot .amc-mark { + width: 13px; + height: 13px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-foot b { + color: var(--v2-text-text-base); + font-weight: 600; +} + +/* error + skeleton states */ +[data-component="amicode-entity-view"] .amc-ev-error { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} +[data-component="amicode-entity-view"] .amc-ev-error .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--v2-state-fg-danger); +} +[data-component="amicode-entity-view"] .amc-ev-error .msg { + flex: 1; + font-size: 14px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-retry { + -webkit-appearance: none; + appearance: none; + font: inherit; + font-size: 12px; + color: var(--v2-text-text-base); + background: transparent; + border: 1px solid var(--v2-border-border-strong); + border-radius: var(--radius-md); + padding: 3px 10px; + cursor: pointer; + flex-shrink: 0; +} +[data-component="amicode-entity-view"] .amc-ev-retry:hover { background: var(--v2-background-bg-layer-02); } +[data-component="amicode-entity-view"] .amc-ev-retry:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 1px; +} + +[data-component="amicode-entity-view"] .amc-sk { + height: 14px; + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-02); + position: relative; + overflow: hidden; + margin: 12px 0; +} +[data-component="amicode-entity-view"] .amc-sk.w60 { width: 60%; } +[data-component="amicode-entity-view"] .amc-sk.w40 { width: 40%; } +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-entity-view"] .amc-sk::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--v2-text-text-base) 8%, transparent), transparent); + animation: amc-shimmer 1.4s infinite; + } +} + +/* ============================================================ + SHARED CARD SURFACES — ask, run window + ============================================================ */ +[data-component="amicode-ask-card"], +[data-component="amicode-run-window"] { + /* Rail-free and tint-free: neutral hairline + surface across the AMICO family. */ + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-lg); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); +} + +/* The entity rail is NOT a card (Kate 2026-07-24): no box, no problem name — it + attaches directly under the session-title header (the rail mounts inside that + sticky header, which owns the side inset + bottom padding). Just the pills. */ +[data-component="amicode-entity-rail"] { + display: flex; + margin-top: -6px; + font-size: var(--font-size-xs); + line-height: 16px; +} +[data-component="amicode-entity-rail"] .amc-rail-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* In-transcript entity view (Kate 2026-07-24): the receipt renders the full + verdict-first entity view inline where it was recorded — no click, no modal — + for the CURRENT instance of a kind. Same neutral card surface as the AMICO + family, medium radius, roomy padding; the entity-view root is a bare flex + column, so the surface lives here. Wide content scrolls inside, never the page. */ +[data-component="amicode-entity-inline"] { + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + padding: var(--space-4); + max-width: 100%; + overflow-x: auto; +} +/* The card owns the inset (16px); the entity-view's own dialog padding would + double it inline, so zero it here (the modal keeps its padding). */ +[data-component="amicode-entity-inline"] [data-component="amicode-entity-view"] { + padding: 0; +} + +/* Rail entity chips (Kate 2026-07-24): STATIC label only — no value, no + icon. + A recorded (clickable) chip is a solid-bordered pill on the layer-01 surface + (the yellow fill was retired 2026-07-24 — neutral surface, hover carries the + feedback) and opens the current version; a not-yet-recorded chip is inert with + a dotted border. Focus ring comes from the shared rail button rule below. */ +[data-component="amicode-entity-rail"] .amc-rail-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + padding: 2px 11px; + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-full); + background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: + background 0.14s ease, + border-color 0.14s ease; +} +/* entity glyph — inherits the chip ink via currentColor (neutral, never a yellow + foreground on light); sized just under the label so the text still leads. */ +[data-component="amicode-entity-rail"] .amc-rail-chip [data-component="icon"], +[data-component="amicode-entity-rail"] .amc-rail-chip [data-slot="icon-svg"] { + width: 14px; + height: 14px; + flex-shrink: 0; +} +[data-slot="amicode-rail-retry"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-slot="amicode-rail-retry"]:hover { + background: var(--v2-background-bg-layer-02); + border-color: var(--v2-border-border-strong); +} +[data-slot="amicode-rail-retry"]:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 1px; +} +[data-component="amicode-entity-rail"] button.amc-rail-chip:hover { + border-color: var(--v2-border-border-strong); + background: var(--v2-background-bg-layer-02); +} +/* not recorded yet → inert, dotted border, no fill */ +[data-component="amicode-entity-rail"] .amc-rail-chip.is-empty { + border: 1px dotted var(--v2-border-border-base); + background: none; + color: var(--v2-text-text-faint); + font-weight: 500; + cursor: default; +} +/* …except a not-recorded chip that IS a button (the pulse chip, which opens the + Pulse Inspector before a pulse is banked): keep the dotted not-yet look, but it + must READ clickable at rest — hover feedback alone failed the glance test + (Kate 2026-07-28: "doesn't appear clickable"). Three signals: full-strength + ink at weight 600 (faint ink is how a chip says "inert", and this one isn't; + the glyph inherits currentColor so it bolds with the label), an always-visible + trailing chevron (the shape says "this goes somewhere" — same glyph family as + the receipt card's, but never hover-gated here), and the pointer cursor. The + dotted border still carries the not-yet-banked state on its own. */ +[data-component="amicode-entity-rail"] button.amc-rail-chip.is-empty { + cursor: pointer; + color: var(--v2-text-text-base); + font-weight: 600; +} +[data-component="amicode-entity-rail"] .amc-rail-chip .amc-chev { + color: var(--v2-text-text-muted); + font-size: var(--font-size-md); + line-height: 1; + transition: color 0.16s ease; +} +[data-component="amicode-entity-rail"] button.amc-rail-chip:hover .amc-chev, +[data-component="amicode-entity-rail"] button.amc-rail-chip:focus-visible .amc-chev { + color: var(--v2-text-text-base); +} +[data-component="amicode-entity-rail"] button:focus-visible, +[data-component="amicode-ask-card"] button:focus-visible, +[data-component="amicode-run-window"] button:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +/* ask-card option hover */ +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled) { + transition: background 0.14s ease, border-color 0.14s ease, transform 0.12s ease; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):hover { + border-color: var(--accent-edge) !important; + background: var(--v2-background-bg-layer-03) !important; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):active { + background: var(--accent-fill-soft) !important; +} + +/* run window: subtle hover to signal it opens the Run entity */ +[data-component="amicode-run-window"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-component="amicode-run-window"]:hover { + border-color: var(--v2-border-border-strong); + background: var(--v2-background-bg-layer-02); +} + +/* ---- System hero (spec §6.1): identity + schematic + table + Hamiltonian ---- */ +[data-component="amicode-system-view"] { display: flex; flex-direction: column; gap: 8px; } +[data-component="amicode-system-view"] .amc-systbl { display: flex; flex-direction: column; } +[data-component="amicode-system-view"] .amc-systbl-row { + display: grid; grid-template-columns: 3rem 5rem auto 1fr; gap: 8px; align-items: baseline; + padding: 4px; border-top: 1px solid color-mix(in srgb, var(--v2-border-border-base) 55%, transparent); + font-variant-numeric: tabular-nums; +} +[data-component="amicode-system-view"] .amc-systbl-row.amc-coupling { grid-template-columns: 8rem 5rem 1fr; } +[data-component="amicode-system-view"] .amc-c-id { font-weight: 600; color: var(--v2-text-text-base); } +[data-component="amicode-system-view"] .amc-c-role, +[data-component="amicode-system-view"] .amc-c-lvl { color: var(--v2-text-text-muted); font-size: 0.8rem; } +[data-component="amicode-system-view"] .amc-c-params { color: var(--v2-text-text-base); font-size: 0.8rem; } +[data-component="amicode-system-view"] .amc-ev-formula { margin-top: 4px; } +/* Provenance on a section header. A RECORDED Hamiltonian is shown bare; an + inferred one — the canonical form for the platform, which nobody stated — + has to say so, or the card is asserting physics it guessed. Sits before the + ::after rule so the hairline still fills the remaining width. */ +[data-component="amicode-entity-view"] .amc-ev-sec-note { + font-size: 9px; + letter-spacing: 0.04em; + text-transform: none; + color: var(--v2-text-text-faint); + font-style: italic; +} +/* The "no model recorded" placeholder reads as a prompt, not as an equation. */ +[data-component="amicode-system-view"] .amc-ev-formula.is-empty { + color: var(--v2-text-text-faint); + font-size: 0.82rem; + font-style: italic; + background: transparent; + border-style: dashed; +} +/* Conventions the recorded terms assume (frame, units, basis ordering). */ +[data-component="amicode-system-view"] .amc-ev-formula-note { + margin: -2px 4px 4px; + font-size: 0.72rem; + color: var(--v2-text-text-faint); +} + +/* ---- Formulation hero (spec §6.2): mode badges + objective + constraints ---- */ +[data-component="amicode-formulation-view"] { display: flex; flex-direction: column; gap: 8px; } +[data-component="amicode-entity-view"] .amc-modebar { display: flex; flex-wrap: wrap; gap: 6px; } +[data-component="amicode-entity-view"] .amc-badge { + display: inline-flex; align-items: baseline; gap: 4px; padding: 2px 8px; border-radius: var(--radius-full); + border: 1px solid var(--v2-border-border-base); background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); font-size: 0.76rem; line-height: 1.35; +} +[data-component="amicode-entity-view"] .amc-badge .k { color: var(--v2-text-text-muted); } +[data-component="amicode-entity-view"] .amc-badge .v { color: var(--v2-text-text-base); font-weight: 600; } +[data-component="amicode-formulation-view"] .amc-obj { display: flex; flex-direction: column; gap: 8px; } +[data-component="amicode-formulation-view"] .amc-constraints { display: flex; flex-direction: column; gap: 8px; } + +/* ---- shared entity row primitive — used by formulation, system, run, device, + calibration and the generic field table so every entity modal reads the same: + a quiet uppercase micro-label in a fixed left column + value in the right + column (an optional faint physics symbol / wire-key beside it). ---- */ +[data-component="amicode-entity-view"] .amc-term { + display: flex; flex-direction: row; align-items: baseline; gap: 16px; +} +[data-component="amicode-entity-view"] .amc-term-head { + flex: 0 0 10rem; min-width: 0; display: flex; flex-direction: column; gap: 1px; +} +[data-component="amicode-entity-view"] .amc-obj-name, +[data-component="amicode-entity-view"] .amc-term-name { + font-size: 0.68rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--v2-text-text-muted); font-variant-numeric: tabular-nums; +} +/* secondary annotation under a name (ensemble "M = 3", a wire key, …) */ +[data-component="amicode-entity-view"] .amc-term-sub { + font-size: 0.62rem; color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; +} +/* value column — plain text/number, with an optional faint physics symbol */ +[data-component="amicode-entity-view"] .amc-term-val { + flex: 1 1 auto; min-width: 0; display: flex; align-items: baseline; gap: 6px; + font-size: 0.82rem; color: var(--v2-text-text-base); font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +[data-component="amicode-entity-view"] .amc-term-sym { flex: none; font-size: 0.72rem; color: var(--v2-text-text-faint); } +[data-component="amicode-entity-view"] .amc-term-val.is-unset { color: var(--v2-text-text-faint); font-style: italic; } + +/* formulation-only: the KaTeX equation as the value (Solve is now mode badges) */ +[data-component="amicode-formulation-view"] .amc-obj-latex, +[data-component="amicode-formulation-view"] .amc-term-latex { flex: 1 1 auto; min-width: 0; margin: 0; } + +/* ---- overflow hardening: keep the entity-view dialog from bleeding past its + width. Grid/flex children default to min-width:auto (content-based), so a long + value (history notes, params) forces the content wider than the dialog and + clips the right edge (timestamps). Let children shrink + wrap long values. ---- */ +[data-component="amicode-entity-view"] { min-width: 0; max-width: 100%; overflow-x: hidden; } +[data-component="amicode-entity-view"] .amc-timeline, +[data-component="amicode-entity-view"] .amc-event, +[data-component="amicode-entity-view"] .amc-event .erow { min-width: 0; max-width: 100%; } +[data-component="amicode-entity-view"] .amc-event .when { white-space: nowrap; } +[data-component="amicode-entity-view"] .amc-event .summary, +[data-component="amicode-entity-view"] .amc-event .summary .v { overflow-wrap: anywhere; min-width: 0; } +[data-component="amicode-system-view"] .amc-systbl-row > * { min-width: 0; } +[data-component="amicode-system-view"] .amc-c-params, +[data-component="amicode-formulation-view"] .amc-obj-latex, +[data-component="amicode-formulation-view"] .amc-term-latex, +[data-component="amicode-formulation-view"] .amc-term-name { overflow-wrap: anywhere; min-width: 0; } +[data-component="amicode-formulation-view"] .amc-modebar { max-width: 100%; } +[data-component="amicode-system-view"] .amc-ev-formula, +[data-component="amicode-formulation-view"] .amc-obj-latex, +[data-component="amicode-formulation-view"] .amc-term-latex { overflow-x: auto; } + +/* ---- Widget grid edit mode (spec T3.1/T2.6) ------------------------------ + Rebuilt for clarity: instead of a heavy row of buttons floating above each + card (which reads as its own UI and drowns the content), the cards stay + visible and a light scrim + dashed ring signal "you're arranging, not + using". A single grouped control pill floats in each card's top-right + corner. Overlaying host DOM over an iframe is safe for CLICKS — only a + click-drag THROUGH the frame gets eaten, and reorder here is button-based. + The scrim also intercepts pointer events so the widget beneath can't be + clicked while arranging. ------------------------------------------------- */ +[data-component="amicode-widget-cell"] { position: relative; } +.amc-wg-framewrap { position: relative; border-radius: var(--radius-lg); transition: outline-color 0.16s ease; } +[data-component="amicode-widget-cell"][data-editing="true"] .amc-wg-framewrap { + outline: 1px dashed var(--v2-border-border-base); + outline-offset: 3px; +} +.amc-wg-scrim { + position: absolute; inset: 0; z-index: 1; border-radius: var(--radius-lg); cursor: default; + background: color-mix(in srgb, var(--v2-background-bg-base) 44%, transparent); +} +.amc-wg-placeholder .amc-wg-empty { color: var(--v2-text-text-faint); font-weight: 500; margin-left: 4px; } +.amc-wg-pill { + position: absolute; top: 7px; right: 7px; z-index: 2; + display: flex; align-items: center; gap: 1px; padding: 3px; + border-radius: var(--radius-lg); background: var(--v2-background-bg-layer-01); + border: 1px solid var(--v2-border-border-base); + box-shadow: 0 3px 12px color-mix(in srgb, var(--v2-background-bg-base) 70%, transparent); +} +.amc-wg-pill button { + display: inline-flex; align-items: center; justify-content: center; + min-width: 24px; height: 24px; padding: 0 5px; + border: none; background: transparent; border-radius: var(--radius-md); + color: var(--v2-text-text-muted); font-size: 12px; line-height: 1; cursor: pointer; +} +.amc-wg-pill button:hover:not(:disabled) { + background: var(--v2-background-bg-layer-02); color: var(--v2-text-text-base); +} +.amc-wg-pill button:disabled { opacity: 0.28; cursor: default; } +.amc-wg-pill .amc-wg-danger:hover:not(:disabled) { color: var(--v2-state-fg-danger); } + +[data-component="amicode-widget-editbar"] { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + padding: 8px 12px; border-radius: var(--radius-lg); + border: 1px solid var(--accent-edge); + background: var(--accent-fill-soft); + font-size: 11px; color: var(--v2-text-text-muted); +} +[data-component="amicode-widget-editbar"] b { color: var(--v2-text-text-base); font-weight: 600; } +[data-component="amicode-widget-editbar"] .amc-wg-editdone { + margin-left: auto; border: 1px solid var(--v2-border-border-base); border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-02); color: var(--v2-text-text-base); + font-size: 11px; padding: 3px 12px; cursor: pointer; +} +[data-component="amicode-widget-editbar"] .amc-wg-editdone:hover { border-color: var(--v2-border-border-strong); } + +.amc-wg-placeholder { + display: flex; align-items: center; min-height: 68px; + border: 1px dashed var(--v2-border-border-base); border-radius: var(--radius-lg); + background: var(--v2-background-bg-layer-01); + padding: 12px 12px; font-size: 11px; color: var(--v2-text-text-faint); +} + +[data-component="amicode-widget-tray"] { display: flex; flex-direction: column; gap: 8px; } +[data-component="amicode-widget-tray"] .amc-wg-traylabel { font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--v2-text-text-faint); } + +/* the geometric unit grid (board and tray share it): fixed 140px row units, + stable auto-fill columns; tiles occupy 1×1, heroes 2×2. Rows can only grow + for in-place chrome (config forms), never for card content. */ +.amc-wg-grid, +.amc-wg-traygrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + grid-auto-rows: minmax(140px, auto); + gap: 8px; +} +.amc-wg-grid > *, +.amc-wg-traygrid > * { min-width: 0; } +.amc-wg-grid > [data-span="hero"], +.amc-wg-traygrid > [data-span="hero"] { grid-column: span 2; grid-row: span 2; } + +/* height chain: block → cell → framewrap → iframe (fill mode) — the unit + grid owns heights; widget-reported content height never moves the layout. + The config form (move mode) sits below the framewrap and is the one thing + allowed to grow a row. */ +.amc-wg-grid .amc-wg-sortable { height: 100%; } +.amc-wg-grid [data-component="amicode-widget-cell"] { height: 100%; display: flex; flex-direction: column; } +.amc-wg-grid [data-component="amicode-widget-cell"] .amc-wg-framewrap { flex: 1 1 auto; min-height: 0; } + +/* tray candidates preview at board scale in the same unit rhythm; the + overlay button is the whole card's single click target */ +.amc-wg-traycard { position: relative; } +.amc-wg-traycard .amc-wg-framewrap { height: 100%; } +.amc-wg-trayadd { + position: absolute; inset: 0; display: flex; align-items: flex-end; + border: 0; border-radius: var(--radius-lg); background: transparent; + cursor: pointer; padding: 6px; +} +.amc-wg-trayadd span { + font-size: 11px; padding: 3px 11px; border-radius: var(--radius-full); + border: 1px solid var(--v2-border-border-base); + background: var(--v2-background-bg-layer-02); color: var(--v2-text-text-muted); +} +.amc-wg-trayadd:hover { outline: 1px dashed var(--v2-border-border-strong); outline-offset: -1px; } +.amc-wg-trayadd:hover span { border-color: var(--v2-border-border-strong); color: var(--v2-text-text-base); } +.amc-wg-trayadd:focus-visible { outline: 1px solid var(--v2-border-border-strong); outline-offset: 2px; } + +/* the ghost tile — standing "add widget" entry point at the end of the tile + row; dashed like the tray it opens, sized to sit flush with 140px tiles. + Create-new in the tray reuses it (amc-wg-traycreate). The label sits at + LOW contrast (Kate 2026-07-15) — a whisper until hovered. */ +.amc-wg-ghost { + min-height: 140px; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; + border: 1px dashed var(--v2-border-border-base); border-radius: var(--radius-lg); + background: transparent; color: var(--v2-text-text-faint); + font-size: 11px; cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease; +} +.amc-wg-ghost:hover { + border-color: var(--v2-border-border-strong); color: var(--v2-text-text-base); + background: var(--v2-background-bg-layer-01); +} +.amc-wg-ghost:focus-visible { outline: 1px solid var(--v2-border-border-strong); outline-offset: 2px; } +.amc-wg-ghost .amc-wg-ghostplus { font-size: 18px; line-height: 1; } +.amc-wg-ghost .amc-wg-ghostlabel { color: color-mix(in srgb, var(--v2-text-text-faint) 55%, transparent); transition: color 0.16s ease; } +.amc-wg-ghost:hover .amc-wg-ghostlabel { color: inherit; } + +/* ---- In-chat widget preview (Stage 2 chat authoring) --------------------- */ +[data-component="amicode-widget-preview"] { + display: flex; flex-direction: column; gap: 8px; + border: 1px solid var(--v2-border-border-base); border-radius: var(--radius-lg); + background: var(--v2-background-bg-layer-01); padding: 12px 12px; max-width: 100%; +} +[data-component="amicode-widget-preview"] .amc-wp-head { + display: flex; align-items: baseline; gap: 8px; min-width: 0; +} +[data-component="amicode-widget-preview"] .amc-wp-eyebrow { + font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--v2-text-text-faint); +} +[data-component="amicode-widget-preview"] .amc-wp-name { + font-size: 12px; font-weight: 600; color: var(--v2-text-text-base); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; +} +[data-component="amicode-widget-preview"] .amc-wp-badge { + font-size: 10px; color: var(--v2-text-text-muted); + border: 1px solid var(--v2-border-border-base); border-radius: var(--radius-full); padding: 1px 7px; +} +[data-component="amicode-widget-preview"] .amc-wp-stage { + border-radius: var(--radius-lg); overflow: hidden; + /* faint checker so a widget's own transparent areas read as "canvas" */ + background: + repeating-linear-gradient(45deg, + color-mix(in srgb, var(--v2-background-bg-base) 40%, transparent) 0 6px, + transparent 6px 12px); +} +[data-component="amicode-widget-preview"] .amc-wp-note, +[data-component="amicode-widget-preview"] .amc-wp-hint { + font-size: 11px; color: var(--v2-text-text-faint); +} +[data-component="amicode-widget-preview"] .amc-wp-warnings { + margin: 0; padding-left: 16px; font-size: 11px; color: var(--v2-state-fg-warning); +} +[data-component="amicode-widget-preview"] .amc-wp-foot { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; +} +[data-component="amicode-widget-preview"] .amc-wp-pin { + border: 1px solid var(--accent-edge); + border-radius: var(--radius-md); background: var(--accent-fill-soft); + color: var(--v2-text-text-base); font-size: 11px; font-weight: 600; padding: 4px 12px; cursor: pointer; +} +[data-component="amicode-widget-preview"] .amc-wp-pin:hover:not(:disabled) { + background: var(--accent-fill-hover); +} + +/* THE text input recipe — one geometry for every amicode text field (the + * onboarding wizard, the About-You profile editor, the Connections credential + * forms). Compact modifier for dense in-card forms. Focus ring included so no + * caller ever strips an outline again. */ +.amc-input { + width: 100%; + box-sizing: border-box; + background: var(--v2-background-bg-layer-02, transparent); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md); + padding: 8px 12px; + font: inherit; + font-size: 13px; + color: var(--v2-text-text-base); +} +.amc-input--compact { + padding: 4px 8px; + font-size: 12px; +} +.amc-input::placeholder { + color: var(--v2-text-text-faint); +} +.amc-input:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -1px; +} +.amc-input:disabled { + opacity: 0.5; + cursor: default; +} + +/* home cards: interaction states the inline styles can't express. The yellow + * CTA's feedback is elevation + opacity — the fill itself never changes hue. */ +[data-slot="amicode-card-meet-cta"]:hover { + box-shadow: 0 3px 14px var(--accent-fill-hover); +} +[data-slot="amicode-card-meet-cta"]:active { + opacity: 0.9; +} +[data-slot="amicode-card-meet-cta"]:focus-visible, +[data-component="amicode-card-meet"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} +[data-component="amicode-action-card"][data-clickable="true"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-component="amicode-action-card"][data-clickable="true"]:hover { + background: var(--v2-background-bg-layer-02); + border-color: var(--v2-border-border-strong); +} +[data-component="amicode-action-card"][data-clickable="true"]:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 2px; +} +[data-component="amc-secondary-btn"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-component="amc-secondary-btn"]:hover { + background: var(--accent-fill-soft); +} +[data-component="amc-secondary-btn"]:focus-visible, +[data-slot="amicode-you-suggestion"]:focus-visible { + outline: 1px solid var(--v2-border-border-focus); + outline-offset: 1px; +} +[data-slot="amicode-you-suggestion"]:hover, +[data-slot="amicode-you-suggestion"][data-active="true"] { + background: var(--v2-background-bg-layer-03, var(--v2-background-bg-layer-02)); +} +[data-component="amicode-widget-preview"] .amc-wp-pin:disabled { cursor: default; } +[data-component="amicode-widget-preview"] .amc-wp-pin[data-state="pinned"] { + color: var(--v2-state-fg-success); + border-color: color-mix(in srgb, var(--v2-state-fg-success) 40%, var(--v2-border-border-base)); + background: transparent; +} +[data-component="amicode-widget-preview"] .amc-wp-pin[data-state="error"] { color: var(--v2-state-fg-danger); } + +/* ---- Home chrome strip (nav redesign 2026-07-15) ------------------------ + * Comfortable widths: brand mark left, the three controls (defaults capsule · + * sessions · settings) inline right, hamburger hidden. + * + * Below the breakpoint the three controls collapse behind a single hamburger + * and re-flow into a vertical dropdown anchored to the strip's right edge. The + * controls are rendered once (home.tsx) and only their layout changes, so each + * keeps its own popover/flyout state. The query reads the home panel's OWN + * inline size (container: amicode-chrome), not the viewport — the panel narrows + * independently when the sidebar/inspector open. */ +[data-slot="amicode-chrome-actions"] { display: flex; align-items: center; gap: 12px; min-width: 0; } +[data-slot="amicode-chrome-hamburger"] { display: none; } + +@container amicode-chrome (max-width: 519.98px) { + /* Collapsed: actions leave the flow and become a dropdown the hamburger toggles. */ + [data-slot="amicode-chrome-actions"] { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 40; + flex-direction: column; + align-items: stretch; + gap: 10px; + min-width: 220px; + max-width: min(320px, 92cqw); + padding: 12px; + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-lg); + background: var(--v2-background-bg-base); + box-shadow: 0 14px 40px -18px rgba(0, 0, 0, 0.55); + } + [data-slot="amicode-chrome-actions"]:not([data-open="true"]) { display: none; } + [data-slot="amicode-chrome-hamburger"] { display: inline-flex; } + /* The pill controls fill the column so it reads as a menu; each keeps its + own popover, which still anchors to its trigger. The Connections pill + (#174) is the popover trigger itself — a direct child, no wrapper. */ + [data-slot="amicode-chrome-actions"] > [data-component="amicode-defaults-capsule"], + [data-slot="amicode-chrome-actions"] > [data-slot="amicode-chrome-sessions"], + [data-slot="amicode-chrome-actions"] [data-slot="amicode-defaults-face"], + [data-slot="amicode-chrome-actions"] [data-action="home-sessions-toggle-flyout"], + [data-slot="amicode-chrome-actions"] > [data-action="home-connections-toggle"] { width: 100%; } + /* The settings gear keeps its natural size, parked at the row start. */ + [data-slot="amicode-chrome-actions"] > [data-action="home-open-settings"] { align-self: flex-start; } +} + +/* ---- per-card ⋯ menu (customize rethink 2026-07-15) ---------------------- + * The dashboard has NO standing edit button: hover/focus a card and its ⋯ + * appears (host-side, above the sandboxed frame like .amc-wg-pill); the menu + * carries direct actions plus the add/arrange entries that open edit mode. */ +.amc-wg-menuroot { + position: absolute; + top: 7px; + right: 7px; + z-index: 2; +} +.amc-wg-more { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; + border-radius: var(--radius-md); + border: 1px solid var(--v2-border-border-base); + background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0; + box-shadow: 0 3px 12px color-mix(in srgb, var(--v2-background-bg-base) 70%, transparent); +} +[data-component="amicode-widget-cell"]:hover .amc-wg-more, +.amc-wg-menuroot:focus-within .amc-wg-more, +.amc-wg-more[aria-expanded="true"] { + opacity: 1; +} +.amc-wg-more:hover { + color: var(--v2-text-text-base); + background: var(--v2-background-bg-layer-02); +} +.amc-wg-more:focus-visible { + outline: 1px solid var(--v2-border-border-strong); + outline-offset: 1px; +} +@media (prefers-reduced-motion: no-preference) { + .amc-wg-more { + transition: opacity 0.14s ease-out; + } +} +.amc-wg-menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 3; + min-width: 190px; + display: flex; + flex-direction: column; + gap: 1px; + padding: 5px; + background: var(--v2-background-bg-base); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-lg); + box-shadow: 0 12px 34px -14px rgba(0, 0, 0, 0.55); +} +.amc-wg-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + border: none; + background: transparent; + border-radius: var(--radius-md); + padding: 6px 9px; + font-size: 12px; + color: var(--v2-text-text-base); + cursor: pointer; + white-space: nowrap; +} +.amc-wg-menu button:hover:not(:disabled) { + background: var(--v2-background-bg-layer-02); +} +.amc-wg-menu button:disabled { + opacity: 0.35; + cursor: default; +} +.amc-wg-menu button:focus-visible { + outline: 1px solid var(--v2-border-border-strong); + outline-offset: -1px; +} +.amc-wg-menu button.amc-wg-danger:hover:not(:disabled) { + color: var(--v2-state-fg-danger); +} +.amc-wg-menu .amc-wg-menusep { + height: 1px; + background: var(--v2-border-border-base); + margin: 4px 2px; +} + +/* ---- widget grid: drag reorder + Add-widget menu (widget UX PR) ---------- + * Move mode: the whole scrimmed card is the drag handle. No overlay ghost, no + * fade (Kate: widgets must look the SAME while dragging) — the card itself + * follows the pointer via the sortable transform, riding above its neighbors. + * Reduced motion kills the slide transitions. */ +.amc-wg-sortable { touch-action: none; min-width: 0; } +.amc-wg-sortable.amc-wg-active { position: relative; z-index: 5; } +.amc-wg-sortable:focus-visible { outline: 1px solid var(--v2-border-border-strong); outline-offset: 2px; border-radius: var(--radius-lg); } +[data-component="amicode-widget-cell"][data-editing="true"] .amc-wg-scrim { cursor: grab; } +.amc-wg-sortable.amc-wg-active .amc-wg-scrim { cursor: grabbing; } +@media (prefers-reduced-motion: reduce) { + .amc-wg-sortable { transition: none !important; } +} + +/* ---- Ring-2 verdict heroes (Run / Device / Calibration) -------------------- + * Verdict-first redesign: each of these dialogs now leads with the one fact you + * opened it to learn, and the raw field table drops behind "Show all fields". + * Shared pill/meta/kv atoms live under all three component scopes; tones map to + * the v2 semantic state tokens (ok=success, warn=warning, critical=danger). */ + +[data-component="amicode-run-view"], +[data-component="amicode-device-view"], +[data-component="amicode-calibration-view"] { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Run: big fidelity + status badge */ +[data-component="amicode-run-view"] .amc-run-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 12px; +} +[data-component="amicode-run-view"] .amc-run-fid { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 1.5rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--v2-text-text-base); + line-height: 1.1; +} +[data-component="amicode-run-view"] .amc-run-fk { color: var(--v2-text-text-faint); font-weight: 500; } +[data-component="amicode-run-view"] .amc-run-status { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-full); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); +} +/* verdict tones (shared across run status + device/calibration pills) */ +[data-component="amicode-run-view"] .amc-run-status[data-tone="ok"], +[data-component="amicode-device-view"] .amc-vpill[data-tone="ok"], +[data-component="amicode-calibration-view"] .amc-vpill[data-tone="ok"] { + background: var(--v2-state-bg-success); + color: var(--v2-state-fg-success); +} +[data-component="amicode-run-view"] .amc-run-status[data-tone="warn"], +[data-component="amicode-device-view"] .amc-vpill[data-tone="warn"], +[data-component="amicode-calibration-view"] .amc-vpill[data-tone="warn"] { + background: var(--v2-state-bg-warning); + color: var(--v2-state-fg-warning); +} +[data-component="amicode-run-view"] .amc-run-status[data-tone="critical"], +[data-component="amicode-device-view"] .amc-vpill[data-tone="critical"] { + background: var(--v2-state-bg-danger); + color: var(--v2-state-fg-danger); +} +[data-component="amicode-run-view"] .amc-run-status[data-tone="active"] { + /* solving/in-flight → the PR#52 accent state fill (yellow warmth + neutral + ink), never a yellow foreground on a light surface. */ + background: var(--accent-fill-soft); + color: var(--v2-text-text-base); +} + +/* Device + Calibration shared pills / meta / key-value rows */ +[data-component="amicode-device-view"] .amc-verdict-pills, +[data-component="amicode-calibration-view"] .amc-verdict-pills { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-component="amicode-device-view"] .amc-vpill, +[data-component="amicode-calibration-view"] .amc-vpill { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-full); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); +} +[data-component="amicode-device-view"] .amc-vmeta, +[data-component="amicode-calibration-view"] .amc-vmeta { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-device-view"] .amc-device-links { + display: flex; + flex-direction: column; + gap: 8px; +} +[data-component="amicode-calibration-view"] .amc-calib-source { + display: flex; + flex-direction: column; +} +[data-component="amicode-device-view"] .amc-device-note { + font-size: 12px; + color: var(--v2-text-text-muted); + overflow-wrap: anywhere; +} + +/* ---- System physics spec (physics-forward System hero) --------------------- */ +[data-component="amicode-system-view"] .amc-sys-physics { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ---- BETA channel tag (titlebar) ---------------------------------------- */ +/* Brand gold rather than the generic interactive blue, so the pre-release + marker reads as part of the Amico identity. The amber scale has no + theme-switched alias, so both schemes are spelled out: step 11 is the + readable text step on each ground, step 9 tints the pill. */ +[data-slot="amicode-beta-tag"] { + color: var(--amber-light-11); + background: color-mix(in srgb, var(--amber-light-9) 16%, transparent); +} +[data-color-scheme="dark"] [data-slot="amicode-beta-tag"] { + color: var(--amber-dark-11); + background: color-mix(in srgb, var(--amber-dark-9) 18%, transparent); +} + +/* ---- DEV channel tag (titlebar) ----------------------------------------- */ +/* Green pill signals developer mode is active — visually distinct from the + amber BETA so you never confuse a dev build with a release. Uses the + semantic success scale (auto light/dark via theme.css). */ +[data-slot="amicode-dev-tag"] { + color: var(--text-on-success-base); + background: color-mix(in srgb, var(--surface-success-strong) 16%, transparent); +} + +/* ---- Developer Tools rebuild status + buttons ─────────────────────────── */ +.devtools-section-header { + display: flex; + align-items: center; + gap: 12px; +} + +.devtools-rebuild-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + font-family: var(--font-mono, monospace); +} +.devtools-rebuild-status--building { color: var(--text-warning-base, #ee9d2b); } +.devtools-rebuild-status--done { color: var(--text-on-success-base, #2dba26); } +.devtools-rebuild-status--failed { color: var(--text-danger-base, #e5484d); } + +.devtools-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} +.devtools-status-dot--orange { + background: var(--surface-warning-strong, #ee9d2b); + animation: devtools-pulse 1.2s ease-in-out infinite; +} +.devtools-status-dot--green { + background: var(--surface-success-strong, #12c905); +} +.devtools-status-dot--red { + background: var(--surface-danger-strong, #e5484d); +} + +@keyframes devtools-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + + .devtools-rebuild-row { + display: flex; + gap: 8px; + padding: 16px 0 4px; +} + +/* ───────────────────────────────────────────────────────────────────────────── + APPROVAL CARD — [data-component="amicode-approval-card"] (approval-card.tsx) + The capability-warrant card (spec-20260727-164748 §9.5). Sibling of the ask + card: same in-transcript weight, but the state lives in data-state so the + granted/expired/unavailable cases are styleable without extra classes. + ───────────────────────────────────────────────────────────────────────────── */ +[data-component="amicode-approval-card"] { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--amc-rule, rgba(0, 0, 0, 0.1)); + border-radius: 10px; + background: var(--amc-card-bg, transparent); +} +[data-component="amicode-approval-card"] .amc-ap-head { + display: flex; + align-items: center; + gap: 8px; +} +[data-component="amicode-approval-card"] .amc-ap-title { + font-weight: 600; +} +[data-component="amicode-approval-card"] .amc-ap-plan { + margin-left: auto; + font-family: var(--font-mono, monospace); + font-size: 0.85em; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; + white-space: nowrap; +} +[data-component="amicode-approval-card"] .amc-ap-why { + margin: 0; + opacity: 0.85; +} +[data-component="amicode-approval-card"] .amc-ap-bounds { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.9em; +} +[data-component="amicode-approval-card"] .amc-ap-bounds dt { + opacity: 0.55; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.85em; +} +[data-component="amicode-approval-card"] .amc-ap-bounds dd { + margin: 0; + font-family: var(--font-mono, monospace); +} +[data-component="amicode-approval-card"] .amc-ap-foot { + display: flex; + align-items: center; + gap: 10px; +} +[data-component="amicode-approval-card"] .amc-ap-note { + font-size: 0.85em; + opacity: 0.6; +} +/* A granted warrant is settled, not celebratory — the gate still decides whether + its bounds cover any particular launch, so this must not read as "cleared". */ +[data-component="amicode-approval-card"][data-state="granted"] .amc-ap-title { + opacity: 0.7; +} +[data-component="amicode-approval-card"] button[data-slot="amicode-approval-approve"]:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.test.ts new file mode 100644 index 00000000..0b983a6b --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { + amicodeWarrants, + hasApprovalBridge, + registerAmicodeApprovalBridge, + submitApproval, + type ApprovalBridge, +} from "./approval-bridge" +import type { ApprovalRequest, Warrant } from "./approval" + +const REQ: ApprovalRequest = { plan_hash: "9f2c", bounds: { max_solves: 8 } } +const WARRANT: Warrant = { + plan_hash: "9f2c", + bounds: { max_solves: 8 }, + expires_at: "2026-07-27T21:00:00Z", + issued_by: "user:ui", +} + +const stub = (): { bridge: ApprovalBridge; sent: ApprovalRequest[] } => { + const sent: ApprovalRequest[] = [] + return { bridge: { approve: (r) => sent.push(r), warrants: () => [WARRANT] }, sent } +} + +describe("approval bridge", () => { + test("no bridge → not actionable, no warrants, and submitting is a silent no-op", () => { + expect(hasApprovalBridge()).toBe(false) + expect(amicodeWarrants()).toEqual([]) + expect(() => submitApproval(REQ)).not.toThrow() // must not appear to have approved + }) + + test("registering exposes the host's warrants and routes approvals to it", () => { + const { bridge, sent } = stub() + const dispose = registerAmicodeApprovalBridge(bridge) + try { + expect(hasApprovalBridge()).toBe(true) + expect(amicodeWarrants()).toEqual([WARRANT]) + submitApproval(REQ) + expect(sent).toEqual([REQ]) + } finally { + dispose() + } + expect(hasApprovalBridge()).toBe(false) + }) + + test("a stale disposer cannot wipe a newer registration", () => { + const first = stub() + const second = stub() + const disposeFirst = registerAmicodeApprovalBridge(first.bridge) + const disposeSecond = registerAmicodeApprovalBridge(second.bridge) + disposeFirst() // late unmount of the older card + expect(hasApprovalBridge()).toBe(true) + submitApproval(REQ) + expect(second.sent).toEqual([REQ]) + expect(first.sent).toEqual([]) + disposeSecond() + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.ts new file mode 100644 index 00000000..11a0bc27 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-bridge.ts @@ -0,0 +1,58 @@ +import { createSignal } from "solid-js" +import type { ApprovalRequest, Warrant } from "./approval" + +// AMICODE: module-level bridge between the approval card (rendered deep inside the +// message-part dispatch, where no app context is reachable) and the host that can +// actually reach the ledger. Same shape and lifetime as ./ask-bridge.ts. +// +// WHY A SEPARATE BRIDGE FROM ask-bridge (spec-20260727-164748 §9.5). The ask bridge's +// `send` submits text as the user's next CHAT MESSAGE. Routing an approval through it +// would mean the agent reads the message and then writes the ledger row itself, so the +// only durable provenance for the approval would read "the agent says the user +// approved". `approve` instead reaches `amico ledger approve` on the host, which is the +// ledger's single writer (#212). The distinction is the point; do not "simplify" these +// two bridges into one. +// +// The read-only interlock is inherited from the same discipline: when no bridge is +// registered — share page, headless embed, any surface with no host — the card renders +// non-actionable rather than optimistically enabled. Absence of transport is the +// safe state, so it is the default. + +export interface ApprovalBridge { + /** Mint a warrant. Goes to the ledger via the host, NEVER through the chat. */ + approve: (request: ApprovalRequest) => void + /** Warrants the host currently knows about — the card's state is derived from + * these rather than stored, so it stays correct across replay and reload. */ + warrants: () => readonly Warrant[] +} + +const [current, setCurrent] = createSignal(undefined) + +export const amicodeApprovalBridge = current + +/** Register the host transport. Returns a disposer that only clears the signal if it + * is still the one it installed, so an unmount cannot wipe a newer registration. */ +export function registerAmicodeApprovalBridge(bridge: ApprovalBridge): () => void { + setCurrent(bridge) + return () => { + if (current() === bridge) setCurrent(undefined) + } +} + +/** Warrants known to the host, or an empty list when there is no transport. Empty + * means "no warrant", which the state machine reads as `pending` on a live surface + * and `unavailable` on a read-only one — never as "approved". */ +export function amicodeWarrants(): readonly Warrant[] { + return current()?.warrants() ?? [] +} + +/** True when a host transport is present. The card's actionability gate. */ +export function hasApprovalBridge(): boolean { + return current() !== undefined +} + +/** Send an approval. A no-op without a bridge — a read-only surface must never + * appear to have approved something it could not record. */ +export function submitApproval(request: ApprovalRequest): void { + current()?.approve(request) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/approval-card.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-card.tsx new file mode 100644 index 00000000..6d6a5735 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/approval-card.tsx @@ -0,0 +1,101 @@ +import { Show, createMemo } from "solid-js" +import { amicodeApprovalBridge } from "./approval-bridge" +import { approvalState, boundsText, isActionable, type ApprovalRequest } from "./approval" +import { AmicoMark } from "./spinner" + +// AMICODE: the capability-warrant card for amicode_request_approval tool parts +// (spec-20260727-164748 §9.5). Structurally the ask card's sibling — request text +// plus one action — with three deliberate differences: +// +// 1. TRANSPORT. The ask card submits the chosen option as the user's next CHAT +// MESSAGE. This one writes the ledger through ./approval-bridge.ts. If it went +// through the chat, the agent would read the message and write the row itself, +// leaving the ledger's only provenance reading "the agent says the user +// approved". That is the whole reason the two bridges are separate. +// +// 2. STATE SOURCE. The ask card derives answered-ness from message order. This one +// derives from the LEDGER — is there a live warrant for this plan_hash? — which +// is equally replay-correct and survives a reload, and means the card cannot +// disagree with what the gate will see. +// +// 3. NO COVERAGE VERDICT. The card never says whether the granted bounds are +// enough; that is the gate's call (§5.1 rule 2). It shows what was requested +// and, once granted, what was actually granted — which may be narrower. A card +// that rendered "approved ✓" over a warrant the gate will still refuse would be +// worse than no card. +// +// Display label is AMICO (persona); component/data-* identifiers stay amicode-*. + +export function AmicodeApprovalCard(props: { request: ApprovalRequest }) { + // No clock signal: the state recomputes on any bridge/warrant change, and an + // expiry crossing while the card sits idle resolves on the next interaction + // rather than needing a timer per card. + const state = createMemo(() => + approvalState(props.request, amicodeApprovalBridge()?.warrants() ?? [], Date.now(), amicodeApprovalBridge() !== undefined), + ) + const actionable = () => isActionable(state()) + + const approve = () => { + const bridge = amicodeApprovalBridge() + if (!bridge || !actionable()) return + bridge.approve(props.request) + } + + const granted = () => { + const s = state() + return s.kind === "granted" || s.kind === "expired" ? s.warrant : undefined + } + + return ( +
+
+ + + + Approved + + + + {props.request.plan_hash} + +
+ + + {(why) =>

{why()}

} +
+ +
+
Requested
+
{boundsText(props.request.bounds)}
+ {/* Shown only once a warrant exists, and shows the GRANTED bounds — which + can legitimately differ from the request. */} + + {(w) => ( + <> +
Granted
+
{boundsText(w().bounds)}
+ + )} +
+
+ +
+ + + read-only surface — approvals are disabled here + the gate decides whether these bounds cover a given launch + this warrant has lapsed + +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/approval.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/approval.test.ts new file mode 100644 index 00000000..c089a59e --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/approval.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test" +import { + approvalState, + boundsText, + isActionable, + parseApprovalInput, + railWarrantChip, + warrantFor, + type Warrant, +} from "./approval" + +describe("parseApprovalInput", () => { + test("reads plan_hash, bounds and rationale from the tool input", () => { + expect( + parseApprovalInput({ + plan_hash: " 9f2c ", + bounds: { max_solves: 8, tier: "hpc", max_size_class: "MEDIUM", device: "ro" }, + rationale: " CZ ladder sweep ", + }), + ).toEqual({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "hpc", max_size_class: "MEDIUM", device: "ro" }, + rationale: "CZ ladder sweep", + }) + }) + + test("no plan_hash → undefined (caller falls back to the chip)", () => { + for (const bad of [undefined, null, "plain", {}, { plan_hash: "" }, { plan_hash: " " }, { plan_hash: 4 }]) { + expect(parseApprovalInput(bad), JSON.stringify(bad)).toBeUndefined() + } + }) + + test("bounds are optional — a request with none still parses", () => { + expect(parseApprovalInput({ plan_hash: "h" })).toEqual({ plan_hash: "h", bounds: {} }) + }) + + // A bound the card shows but the gate does not enforce (or the reverse) is worse + // than an absent one, so unusable values are DROPPED rather than coerced. + test("unusable bound values are dropped, not guessed", () => { + expect( + parseApprovalInput({ + plan_hash: "h", + bounds: { max_solves: 0, tier: " ", max_size_class: "LARGE", device: "yes", nonesuch: 1 }, + }), + ).toEqual({ plan_hash: "h", bounds: {} }) + }) + + test("a fractional max_solves is dropped rather than floored", () => { + expect(parseApprovalInput({ plan_hash: "h", bounds: { max_solves: 1.5 } })?.bounds).toEqual({}) + }) + + test("non-object bounds are ignored without rejecting the request", () => { + expect(parseApprovalInput({ plan_hash: "h", bounds: "lots" })).toEqual({ plan_hash: "h", bounds: {} }) + }) +}) + +const NOW = Date.parse("2026-07-27T20:00:00Z") +const iso = (offsetMin: number) => new Date(NOW + offsetMin * 60_000).toISOString() + +const w = (over: Partial = {}): Warrant => ({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "free" }, + expires_at: iso(30), + issued_by: "user:ui", + ...over, +}) + +const req = { plan_hash: "9f2c", bounds: { max_solves: 8, tier: "free" } } + +describe("warrantFor", () => { + test("ignores warrants for other plans", () => { + expect(warrantFor("9f2c", [w({ plan_hash: "other" })], NOW)).toBeUndefined() + }) + test("prefers the live warrant over a lapsed one", () => { + const live = w({ expires_at: iso(10) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(-60) }), live], NOW)).toBe(live) + }) + test("among live warrants, takes the one expiring latest", () => { + const later = w({ expires_at: iso(90) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(10) }), later], NOW)).toBe(later) + }) + test("falls back to the most recent lapsed warrant when none is live", () => { + const recent = w({ expires_at: iso(-5) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(-600) }), recent], NOW)).toBe(recent) + }) +}) + +describe("approvalState", () => { + // The read-only interlock, inherited from ask-bridge by construction. + test("no bridge → unavailable, even when a live warrant exists", () => { + expect(approvalState(req, [w()], NOW, false)).toEqual({ kind: "unavailable" }) + expect(isActionable(approvalState(req, [w()], NOW, false))).toBe(false) + }) + + test("no warrant → pending, and actionable", () => { + const s = approvalState(req, [], NOW, true) + expect(s).toEqual({ kind: "pending" }) + expect(isActionable(s)).toBe(true) + }) + + test("live warrant → granted, and NOT actionable", () => { + const s = approvalState(req, [w()], NOW, true) + expect(s.kind).toBe("granted") + expect(isActionable(s)).toBe(false) + }) + + test("lapsed warrant → expired, and actionable again", () => { + const s = approvalState(req, [w({ expires_at: iso(-1) })], NOW, true) + expect(s.kind).toBe("expired") + expect(isActionable(s)).toBe(true) + }) + + test("expiry exactly at now counts as expired — a warrant must not outlive its instant", () => { + expect(approvalState(req, [w({ expires_at: iso(0) })], NOW, true).kind).toBe("expired") + }) + + // Fail-closed, matching the gate's treatment of an unresolved estimate (§4.4). + test("an unparseable expiry reads as expired, never as live", () => { + expect(approvalState(req, [w({ expires_at: "not-a-date" })], NOW, true).kind).toBe("expired") + }) + + // The card must not pre-empt the gate's §5.1 rule 2 verdict. + test("a NARROWER granted warrant is still 'granted' — coverage is the gate's call", () => { + const narrow = w({ bounds: { max_solves: 2 } }) + const s = approvalState({ plan_hash: "9f2c", bounds: { max_solves: 8 } }, [narrow], NOW, true) + expect(s.kind).toBe("granted") + // and it reports what was ACTUALLY granted, not what was asked + expect(s.kind === "granted" && s.warrant.bounds).toEqual({ max_solves: 2 }) + }) +}) + +describe("boundsText", () => { + test("renders only declared bounds", () => { + expect(boundsText({ max_solves: 8, tier: "free" })).toBe("8 solves · tier free") + expect(boundsText({ max_solves: 1 })).toBe("1 solve") + expect(boundsText({ max_size_class: "MEDIUM", device: "ro" })).toBe("up to MEDIUM · device ro") + }) + test("empty bounds say so rather than implying unlimited", () => { + expect(boundsText({})).toBe("no bounds declared") + }) +}) + +describe("railWarrantChip", () => { + const w2 = (over: Partial = {}): Warrant => ({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "free", max_size_class: "MEDIUM" }, + expires_at: iso(30), + issued_by: "user:ui", + ...over, + }) + + test("shows consumption against the declared bounds", () => { + expect(railWarrantChip([w2({ solves_used: 3 })], NOW)).toBe("3 of 8 solves · tier free · up to MEDIUM") + }) + + test("omits the count when the surface did not supply one, rather than guessing 0", () => { + expect(railWarrantChip([w2()], NOW)).toBe("8 solves · tier free · up to MEDIUM") + }) + + test("no live warrant → no chip", () => { + expect(railWarrantChip([], NOW)).toBeUndefined() + expect(railWarrantChip([w2({ expires_at: iso(-1) })], NOW)).toBeUndefined() + }) + + // A chip saying "warranted" with nothing behind it would imply an authorization + // the gate does not grant, since an omitted bound is a refusal not a default. + test("a live warrant declaring NO bounds produces no chip", () => { + expect(railWarrantChip([w2({ bounds: {} })], NOW)).toBeUndefined() + }) + + test("among live warrants, reports the one expiring latest (what the gate checks)", () => { + const later = w2({ expires_at: iso(90), bounds: { max_solves: 2 }, solves_used: 1 }) + expect(railWarrantChip([w2({ solves_used: 7 }), later], NOW)).toBe("1 of 2 solves") + }) + + test("an unparseable expiry is not live", () => { + expect(railWarrantChip([w2({ expires_at: "nope" })], NOW)).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/approval.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/approval.ts new file mode 100644 index 00000000..7a0a32da --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/approval.ts @@ -0,0 +1,176 @@ +// AMICODE: approval-card logic (spec-20260727-164748 §9.5). Pure — no transport, +// no rendering — mirroring ./ask.ts beside ./ask-card.tsx and ./ask-bridge.ts. +// +// A capability warrant is what lets a gated launch through amico-run's --spec gate. +// The card is where a human mints one. Two properties are inherited from the ask +// card deliberately, and one is deliberately NOT: +// +// INHERITED — the read-only interlock. ask-bridge renders buttons disabled when +// no bridge is registered ("questions never submit from read-only surfaces"), so +// an approval is non-actionable on a share page or headless host by construction +// rather than by a check someone has to remember. +// +// INHERITED — state derived from the durable log, never stored in the UI. The ask +// card computes answered-ness from message order (hasUserReplyAfter) instead of +// holding a flag, which makes it replay-correct. Here the durable log is the +// ledger: the card's state is a function of the approval records that exist. +// +// NOT INHERITED — the transport. The ask card submits the chosen option as the +// user's next CHAT MESSAGE. An approval delivered that way would be interpreted +// by the agent, which would then write the ledger row, making the provenance read +// "the agent says the user approved". Approvals go straight to the ledger via +// ./approval-bridge.ts (→ `amico ledger approve`). + +/** Reads an approval request from the tool part's INPUT args, mirroring + * parseAskInput — the ask card's pattern, not a sentinel, because the request is + * the agent's ASK rather than a record of something that happened. + * + * Tolerant in the same way: anything unusable → undefined, and the caller falls + * back to the collapsed chip. But NOT tolerant about bounds: an unparseable bound + * is DROPPED rather than guessed, because a bound the card displays and the gate + * does not enforce (or vice versa) is worse than an absent one. */ +export function parseApprovalInput(input: unknown): ApprovalRequest | undefined { + if (typeof input !== "object" || input === null) return undefined + const raw = input as Record + const planHash = raw.plan_hash + if (typeof planHash !== "string" || planHash.trim().length === 0) return undefined + + const bounds: WarrantBounds = {} + const b = typeof raw.bounds === "object" && raw.bounds !== null ? (raw.bounds as Record) : {} + + if (typeof b.max_solves === "number" && Number.isInteger(b.max_solves) && b.max_solves >= 1) + bounds.max_solves = b.max_solves + if (typeof b.tier === "string" && b.tier.trim().length > 0) bounds.tier = b.tier.trim() + if (b.max_size_class === "SMALL" || b.max_size_class === "MEDIUM") bounds.max_size_class = b.max_size_class + if (b.device === "none" || b.device === "ro" || b.device === "rw") bounds.device = b.device + + const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim() : undefined + return { plan_hash: planHash.trim(), bounds, ...(rationale ? { rationale } : {}) } +} + +/** What a warrant may authorise — the fleet spec §2.1 vocabulary for `device`. */ +export interface WarrantBounds { + max_solves?: number; + tier?: string; + max_size_class?: "SMALL" | "MEDIUM"; + device?: "none" | "ro" | "rw"; +} + +/** The agent's ask: "approve this plan, with these bounds, for this reason." */ +export interface ApprovalRequest { + plan_hash: string; + bounds: WarrantBounds; + rationale?: string; +} + +/** An `approval` ledger row, as surfaced to the UI. */ +export interface Warrant { + plan_hash: string; + bounds: WarrantBounds; + expires_at: string; + issued_by: string; + /** `solve` rows recorded under this plan. OPTIONAL: absent on any surface that + * does not supply it, and the rail chip then omits the count rather than + * rendering a wrong "0 of 8". */ + solves_used?: number; +} + +export type ApprovalState = + /** No transport registered — share page, headless embed. Never actionable. */ + | { kind: "unavailable" } + /** No live warrant for this plan. The one actionable state. */ + | { kind: "pending" } + /** A live warrant exists. Locked; `warrant` carries what was ACTUALLY granted, + * which may be narrower than what was requested. */ + | { kind: "granted"; warrant: Warrant } + /** A warrant existed and lapsed. Actionable again — re-approving is a new bet. */ + | { kind: "expired"; warrant: Warrant }; + +function expiryMs(w: Warrant): number { + const t = Date.parse(w.expires_at); + // An unparseable expiry is treated as ALREADY EXPIRED: a warrant whose lifetime + // cannot be established must not read as live. Same fail-closed direction the + // gate applies to an unresolved estimate (spec §4.4). + return Number.isNaN(t) ? -Infinity : t; +} + +/** The newest live warrant for `planHash`, else the newest lapsed one, else none. */ +export function warrantFor(planHash: string, warrants: readonly Warrant[], now: number): Warrant | undefined { + let live: Warrant | undefined; + let lapsed: Warrant | undefined; + for (const w of warrants) { + if (w.plan_hash !== planHash) continue; + const exp = expiryMs(w); + if (exp > now) { + if (!live || exp > expiryMs(live)) live = w; + } else if (!lapsed || exp > expiryMs(lapsed)) lapsed = w; + } + return live ?? lapsed; +} + +/** Derive the card's state. Deliberately does NOT check whether the warrant's + * bounds COVER the request — that verdict belongs to the gate (spec §5.1 rule 2), + * and a card that second-guessed it would either contradict the gate or imply an + * authority it does not have. The card reports what was granted; the gate decides + * what that permits. */ +export function approvalState( + request: ApprovalRequest, + warrants: readonly Warrant[], + now: number, + hasBridge: boolean, +): ApprovalState { + if (!hasBridge) return { kind: "unavailable" }; + const w = warrantFor(request.plan_hash, warrants, now); + if (!w) return { kind: "pending" }; + return expiryMs(w) > now ? { kind: "granted", warrant: w } : { kind: "expired", warrant: w }; +} + +/** True when the approve control may be pressed. */ +export function isActionable(state: ApprovalState): boolean { + return state.kind === "pending" || state.kind === "expired"; +} + +/** One-line summary of bounds for the card and the rail. Renders only DECLARED + * bounds — an absent bound is not "unlimited" (the gate refuses a launch needing a + * bound the warrant omits), so inventing a word for absence would mislead. */ +export function boundsText(bounds: WarrantBounds): string { + const parts: string[] = []; + if (bounds.max_solves !== undefined) parts.push(`${bounds.max_solves} solve${bounds.max_solves === 1 ? "" : "s"}`); + if (bounds.tier !== undefined) parts.push(`tier ${bounds.tier}`); + if (bounds.max_size_class !== undefined) parts.push(`up to ${bounds.max_size_class}`); + if (bounds.device !== undefined) parts.push(`device ${bounds.device}`); + return parts.length ? parts.join(" · ") : "no bounds declared"; +} + +/** Rail chip for the ACTIVE warrant (spec §9.6, G-6): remaining budget at a glance, + * so a researcher mid-campaign does not have to open anything to see how much + * authorization is left. + * + * Returns undefined when there is nothing true to say — no live warrant, or a live + * one that declares no bounds. A chip reading "warranted" with no bounds behind it + * would imply an authorization the gate does not actually grant (§5.1 rule 2 + * refuses a launch needing a bound the warrant omits), so silence is correct. + * + * Deliberately shows CONSUMPTION, not permission: "3 of 8 solves" is a fact from + * the ledger. It never says whether the next launch will pass — that is the gate's + * verdict, and the same reason the card carries no coverage claim. */ +export function railWarrantChip(warrants: readonly Warrant[], now: number): string | undefined { + // The live warrant expiring latest — the one a launch would actually be checked + // against, matching liveWarrant() in amico-run's warrant.ts. + let best: Warrant | undefined + for (const w of warrants) { + if (expiryMs(w) <= now) continue + if (!best || expiryMs(w) > expiryMs(best)) best = w + } + if (!best) return undefined + + const parts: string[] = [] + if (best.bounds.max_solves !== undefined) { + const used = best.solves_used + parts.push(used === undefined ? `${best.bounds.max_solves} solves` : `${used} of ${best.bounds.max_solves} solves`) + } + if (best.bounds.tier !== undefined) parts.push(`tier ${best.bounds.tier}`) + if (best.bounds.max_size_class !== undefined) parts.push(`up to ${best.bounds.max_size_class}`) + if (best.bounds.device !== undefined) parts.push(`device ${best.bounds.device}`) + return parts.length ? parts.join(" · ") : undefined +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/ask-bridge.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/ask-bridge.ts new file mode 100644 index 00000000..8e4ee7de --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/ask-bridge.ts @@ -0,0 +1,27 @@ +import { createSignal } from "solid-js" + +// AMICODE: module-level bridge between the app-side send path (registered by +// AmicodeEntityRail, which the app mounts with an onAsk callback wired to +// sdk.client.session.promptAsync) and the AmicodeAskCard instances rendered +// deep inside the message-part dispatch, where no app context is reachable. +// When no bridge is registered (share page, headless), ask buttons render +// disabled — questions never submit from read-only surfaces. + +export interface AskBridge { + send: (text: string) => void + // True when a USER message exists later (ULID order) than the given message + // — i.e. the question has already been answered. Assistant messages after + // the ask must not lock the card. + hasUserReplyAfter: (messageID: string) => boolean +} + +const [current, setCurrent] = createSignal(undefined) + +export const amicodeAskBridge = current + +export function registerAmicodeAskBridge(bridge: AskBridge): () => void { + setCurrent(bridge) + return () => { + if (current() === bridge) setCurrent(undefined) + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/ask-card.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/ask-card.tsx new file mode 100644 index 00000000..d48ac001 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/ask-card.tsx @@ -0,0 +1,113 @@ +import { For, Show, createSignal } from "solid-js" +import { amicodeAskBridge } from "./ask-bridge" +import { answeredOption, hasUserReplyAfter, type AskInput } from "./ask" +import { AmicoMark } from "./spinner" +// The session data context moved to packages/session-ui with the message-part +// stack; the card renders inside its provider, so consume it from there. +// (Package-direction irregularity, but module-level acyclic: data.tsx's only +// ui import is the context helper, never the amicode tree.) +import { useData } from "@opencode-ai/session-ui/context" + +// AMICODE: question card for amicode_ask tool parts — question text + one +// button per option (with optional dim detail line under each label). Click +// submits the option string as the user's next chat message through the ask +// bridge, then locks the card. Buttons are also disabled once the user has +// replied AFTER this card's message (assistant text streamed after the ask +// does NOT lock it — live-demo fix 2026-07-03), or when no bridge is +// registered. Display label is AMICO (the persona); component/data-* +// identifiers stay amicode-*. + +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 = () => !answered() && !clicked() + + const submit = (option: string) => { + const bridge = amicodeAskBridge() + if (!bridge || !active()) return + setClicked(option) + bridge.send(option) + } + + return ( +
+
+ + + + Question +
+
+ {props.ask.question} +
+
+ + {(option, index) => ( + + )} + +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/ask.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/ask.test.ts new file mode 100644 index 00000000..4c9142c1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/ask.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test" +import { hasUserReplyAfter, parseAskInput } from "./ask" + +describe("parseAskInput", () => { + test("parses a well-formed ask input", () => { + expect(parseAskInput({ question: "Which platform?", options: ["transmon", "rydberg"] })).toEqual({ + question: "Which platform?", + options: ["transmon", "rydberg"], + }) + }) + + test("trims and drops empty/non-string options", () => { + expect(parseAskInput({ question: " Pick one ", options: [" a ", "", 3, null, "b"] })).toEqual({ + question: "Pick one", + options: ["a", "b"], + }) + }) + + test("malformed input falls back to undefined (collapsed chip)", () => { + expect(parseAskInput(undefined)).toBeUndefined() + expect(parseAskInput(null)).toBeUndefined() + expect(parseAskInput("question")).toBeUndefined() + expect(parseAskInput({})).toBeUndefined() + expect(parseAskInput({ question: "", options: ["a"] })).toBeUndefined() + expect(parseAskInput({ question: "q" })).toBeUndefined() + expect(parseAskInput({ question: "q", options: [] })).toBeUndefined() + expect(parseAskInput({ question: "q", options: [1, "", " "] })).toBeUndefined() + }) + + test("accepts valid details aligned with options", () => { + expect( + parseAskInput({ + question: "Which integrator?", + options: ["Bilinear", "Spline"], + details: ["PWC exponential — robust default", "Piccolissimo — lower initial violation"], + }), + ).toEqual({ + question: "Which integrator?", + options: ["Bilinear", "Spline"], + details: ["PWC exponential — robust default", "Piccolissimo — lower initial violation"], + }) + }) + + test("details length mismatch is treated as absent, card still parses", () => { + expect(parseAskInput({ question: "q", options: ["a", "b"], details: ["only one"] })).toEqual({ + question: "q", + options: ["a", "b"], + }) + }) + + test("non-string details are treated as absent, card still parses", () => { + expect(parseAskInput({ question: "q", options: ["a", "b"], details: ["ok", 7] })).toEqual({ + question: "q", + options: ["a", "b"], + }) + expect(parseAskInput({ question: "q", options: ["a"], details: "not-an-array" })).toEqual({ + question: "q", + options: ["a"], + }) + }) + + test("details stay aligned with their option through invalid-option filtering", () => { + expect(parseAskInput({ question: "q", options: ["a", "", "b"], details: ["da", "dropped", "db"] })).toEqual({ + question: "q", + options: ["a", "b"], + details: ["da", "db"], + }) + }) +}) + +describe("hasUserReplyAfter", () => { + test("assistant messages after the ask do NOT lock the card", () => { + expect( + hasUserReplyAfter( + [ + { id: "msg_b", role: "assistant" }, // the card's message + { id: "msg_c", role: "assistant" }, // streamed text after the ask + { id: "msg_d", role: "assistant" }, + ], + "msg_b", + ), + ).toBe(false) + }) + + test("a user message later in ULID order locks the card", () => { + expect( + hasUserReplyAfter( + [ + { id: "msg_b", role: "assistant" }, + { id: "msg_c", role: "user" }, + ], + "msg_b", + ), + ).toBe(true) + }) + + test("user messages earlier than the card do not lock it", () => { + expect( + hasUserReplyAfter( + [ + { id: "msg_a", role: "user" }, // the prompt that triggered the ask + { id: "msg_b", role: "assistant" }, + ], + "msg_b", + ), + ).toBe(false) + }) + + test("malformed ids and empty inputs are safe", () => { + expect(hasUserReplyAfter([], "msg_b")).toBe(false) + expect(hasUserReplyAfter([{ id: "", role: "user" }], "msg_b")).toBe(false) + 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/app-bundle/overlay/packages/ui/src/amicode/ask.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/ask.ts new file mode 100644 index 00000000..478a8aee --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/ask.ts @@ -0,0 +1,81 @@ +// AMICODE: pure parse/guard helpers for the amicode_ask question card. +// JSX-free so they are directly testable under `bun test` (repo idiom). + +export interface AskInput { + question: string + options: string[] + // Optional per-option detail lines (same order/length as options). Present + // only when the tool passed a valid, length-matched string[] — otherwise + // omitted (the card renders without details, never rejected for this). + details?: string[] +} + +// Reads question/options (and optional details) from the tool part's INPUT +// args (not the output text). Tolerant: non-object, missing/empty question, +// or no usable string options → undefined (caller falls back to the collapsed +// chip). Invalid details (length mismatch / non-strings) are treated as +// absent — they never reject the whole card. Details stay aligned with their +// option through the invalid-option filter. +export function parseAskInput(input: unknown): AskInput | undefined { + if (typeof input !== "object" || input === null) return undefined + const question = (input as Record).question + const options = (input as Record).options + const details = (input as Record).details + if (typeof question !== "string" || question.trim().length === 0) return undefined + if (!Array.isArray(options)) return undefined + + const detailsValid = + 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 })) + .filter( + (pair): pair is { option: string; detail: string | undefined } => + typeof pair.option === "string" && pair.option.trim().length > 0, + ) + if (pairs.length === 0) return undefined + + return { + question: question.trim(), + options: pairs.map((pair) => pair.option.trim()), + ...(detailsValid ? { details: pairs.map((pair) => pair.detail ?? "") } : {}), + } +} + +// Staleness guard: a question stays actionable until the USER has replied — +// 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 { + if (typeof messageID !== "string" || messageID.length === 0) return false + for (const message of messages) { + if (message.role !== "user") continue + if (typeof message.id !== "string" || message.id.length === 0) continue + if (message.id > messageID) return true + } + 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/app-bundle/overlay/packages/ui/src/amicode/brain-data.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-data.ts new file mode 100644 index 00000000..1ad2d7e3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-data.ts @@ -0,0 +1,14 @@ +// AMICODE: the amico brain's skeleton graph — a sample of the armonissima +// vault (nodes, wikilink/dispatch/uses/produces edges) plus the prototype's +// demo traces. The engine uses trace step-sequences ONLY to pre-wire dashed +// "thought edges" between consecutively-visited nodes the vault has not +// linked yet; the step statuses/titles are never shown in the live embed. +// Extracted verbatim from the retired public/brain.js prototype (itself +// generated from amicode media/brain, now deleted upstream). + +export type BrainDataNode = { id: string; label: string; type: string } +export type BrainDataEdge = { s: string; t: string; kind: string } +export type BrainDataTrace = { id: string; title: string; steps: { node: string; status: string; fanout?: string[] }[] } +export type BrainData = { nodes: BrainDataNode[]; edges: BrainDataEdge[]; traces: BrainDataTrace[] } + +export const BRAIN_DATA: BrainData = {"nodes":[{"id":"strategy","label":"STRATEGY","type":"charter"},{"id":"philosophy","label":"PHILOSOPHY","type":"charter"},{"id":"roadmap","label":"ROADMAP","type":"charter"},{"id":"charter-research-loop","label":"charter: research loop","type":"charter"},{"id":"charter-pulse-catalog","label":"charter: pulse catalog","type":"charter"},{"id":"charter-agents-skills","label":"charter: agents & skills","type":"charter"},{"id":"insight-linear-over-cubic","label":"insight: linear>cubic spline","type":"insight"},{"id":"insight-linear51-fix","label":"insight: linear51 fix","type":"insight"},{"id":"insight-shorter-duration","label":"insight: shorter T0 helps","type":"insight"},{"id":"insight-warmstart-regression","label":"insight: warmstart regress","type":"insight"},{"id":"insight-y-coldstart-variance","label":"insight: Y coldstart var","type":"insight"},{"id":"insight-crosstalk","label":"insight: drive crosstalk","type":"insight"},{"id":"insight-free-phase-2q","label":"insight: free-phase 2q","type":"insight"},{"id":"insight-gn-hessian-fails","label":"insight: GN Hessian fails","type":"insight"},{"id":"insight-stagnation-dominant","label":"insight: stagnation dominant","type":"insight"},{"id":"insight-jit-multistart-thrash","label":"insight: JIT lock thrash","type":"insight"},{"id":"insight-warmstart-taxonomy","label":"insight: warm-start taxonomy","type":"insight"},{"id":"insight-coldstart-dominates","label":"insight: coldstart dominates","type":"insight"},{"id":"insight-free-phase-untried","label":"insight: free-phase untried","type":"insight"},{"id":"insight-mintime-2q","label":"insight: mintime improves 2q","type":"insight"},{"id":"insight-eagle-heron","label":"insight: eagle-heron OC","type":"insight"},{"id":"exp-flux-x-192727","label":"exp: fluxonium X (192727)","type":"experiment"},{"id":"exp-flux-x-193328","label":"exp: fluxonium X (193328)","type":"experiment"},{"id":"exp-flux-x-194147","label":"exp: fluxonium X (194147)","type":"experiment"},{"id":"exp-flux-y-194147","label":"exp: fluxonium Y","type":"experiment"},{"id":"exp-flux-h-194147","label":"exp: fluxonium H","type":"experiment"},{"id":"exp-flux-t-194147","label":"exp: fluxonium T","type":"experiment"},{"id":"exp-flux-y-020343","label":"exp: fluxonium Y (020343)","type":"experiment"},{"id":"exp-flux-sqrtx-020343","label":"exp: fluxonium sqrtX","type":"experiment"},{"id":"exp-flux-x-021602","label":"exp: fluxonium X (021602)","type":"experiment"},{"id":"exp-flux-y-021602","label":"exp: fluxonium Y (021602)","type":"experiment"},{"id":"exp-flux-x-v3","label":"exp: fluxonium X v3","type":"experiment"},{"id":"exp-flux-y-v3","label":"exp: fluxonium Y v3","type":"experiment"},{"id":"exp-flux-t-v3","label":"exp: fluxonium T v3","type":"experiment"},{"id":"exp-flux-y-q200k","label":"exp: fluxonium Y Q200k","type":"experiment"},{"id":"exp-flux-y-retry","label":"exp: fluxonium Y retry","type":"experiment"},{"id":"exp-rydberg-cz","label":"exp: rydberg CZ v1","type":"experiment"},{"id":"exp-flux-x-basis-comp","label":"exp: X basis comparison","type":"experiment"},{"id":"hyp-free-phase-gap","label":"hyp: free-phase flux gap","type":"note"},{"id":"hyp-dressed-goal-kets","label":"hyp: dressed kets unlock 2q","type":"note"},{"id":"hyp-augmented-gn","label":"hyp: augmented controls GN","type":"note"},{"id":"method-warm-start","label":"method: warm-start workflow","type":"note"},{"id":"method-cold-start","label":"method: cold-start 4-phase","type":"note"},{"id":"method-cubic-spline","label":"method: cubic-spline pulses","type":"note"},{"id":"method-free-phase","label":"method: free-phase 2q gates","type":"note"},{"id":"method-crosstalk-gates","label":"method: crosstalk-robust","type":"note"},{"id":"method-basis-comparison","label":"method: eigen vs fock basis","type":"note"},{"id":"method-presolve-diag","label":"method: pre-solve diag","type":"note"},{"id":"spec-analytic-derivatives","label":"spec: analytic derivatives","type":"note"},{"id":"brief-analog-magic","label":"brief: analog magic states","type":"note"},{"id":"pulse-flux-x-v1","label":"pulse: fluxonium-X-v1","type":"catalog"},{"id":"pulse-flux-x-v2","label":"pulse: fluxonium-X-v2","type":"catalog"},{"id":"pulse-flux-x-v3","label":"pulse: fluxonium-X-v3","type":"catalog"},{"id":"pulse-flux-y-v3","label":"pulse: fluxonium-Y-v3","type":"catalog"},{"id":"pulse-flux-t-v3","label":"pulse: fluxonium-T-v3","type":"catalog"},{"id":"pulse-rydberg-cz-v1","label":"pulse: rydberg-CZ-v1","type":"catalog"},{"id":"pulse-transmon-cz-v1","label":"pulse: transmon-CZ-v1","type":"catalog"},{"id":"pulse-transmon-x-v1","label":"pulse: transmon-X-v1","type":"catalog"},{"id":"local-workstation","label":"local-workstation","type":"resource"},{"id":"stanford-fluxonium-chip","label":"stanford fluxonium chip","type":"resource"},{"id":"hermes","label":"hermes","type":"resource"},{"id":"fluxonium-half-flux","label":"fluxonium @ half flux","type":"resource"},{"id":"transmon-two-qubit","label":"transmon two-qubit","type":"resource"},{"id":"rydberg-global","label":"rydberg global drive","type":"resource"},{"id":"using-amico","label":"using-amico","type":"skill"},{"id":"brainstorming","label":"brainstorming","type":"skill"},{"id":"debugging","label":"debugging","type":"skill"},{"id":"verification","label":"verification","type":"skill"},{"id":"tdd","label":"tdd","type":"skill"},{"id":"code-review","label":"code-review","type":"skill"},{"id":"setup","label":"setup","type":"skill"},{"id":"solve","label":"solve","type":"skill"},{"id":"demo","label":"demo","type":"skill"},{"id":"plot","label":"plot","type":"skill"},{"id":"analyze","label":"analyze","type":"skill"},{"id":"benchmark","label":"benchmark","type":"skill"},{"id":"ingest","label":"ingest","type":"skill"},{"id":"multistart","label":"multistart","type":"skill"},{"id":"objectives","label":"objectives","type":"skill"},{"id":"structural-analysis","label":"structural-analysis","type":"skill"},{"id":"hypothesis-review","label":"hypothesis-review","type":"skill"},{"id":"transmon","label":"transmon","type":"skill"},{"id":"fluxonium","label":"fluxonium","type":"skill"},{"id":"atoms","label":"atoms","type":"skill"},{"id":"ions","label":"ions","type":"skill"},{"id":"bosonic","label":"bosonic","type":"skill"},{"id":"amico-vault","label":"amico-vault","type":"skill"},{"id":"amico-catalog","label":"amico-catalog","type":"skill"},{"id":"amico-lab","label":"amico-lab","type":"skill"},{"id":"amico-strategy","label":"amico-strategy","type":"skill"},{"id":"amico-route","label":"amico-route","type":"skill"},{"id":"piccolo-dev","label":"piccolo-dev","type":"skill"},{"id":"piccolissimo-dev","label":"piccolissimo-dev","type":"skill"},{"id":"intonato-dev","label":"intonato-dev","type":"skill"},{"id":"stretto-dev","label":"stretto-dev","type":"skill"},{"id":"pr","label":"pr","type":"skill"},{"id":"test","label":"test","type":"skill"},{"id":"dream","label":"dream","type":"skill"},{"id":"dream-distill","label":"dream-distill","type":"skill"},{"id":"dream-connect","label":"dream-connect","type":"skill"},{"id":"dream-prune","label":"dream-prune","type":"skill"},{"id":"dream-synthesize","label":"dream-synthesize","type":"skill"},{"id":"dream-reflect","label":"dream-reflect","type":"skill"},{"id":"meeting","label":"meeting","type":"skill"},{"id":"hopper","label":"hopper","type":"skill"},{"id":"researcher","label":"researcher","type":"agent"},{"id":"experimenter","label":"experimenter","type":"agent"},{"id":"orchestrator","label":"orchestrator","type":"agent"},{"id":"dispatcher","label":"dispatcher","type":"agent"},{"id":"librarian","label":"librarian","type":"agent"},{"id":"engineer","label":"engineer","type":"agent"},{"id":"dreamer","label":"dreamer","type":"agent"},{"id":"piccolo-jl","label":"Piccolo.jl","type":"package"},{"id":"piccolissimo-jl","label":"Piccolissimo.jl","type":"package"},{"id":"intonato-jl","label":"Intonato.jl","type":"package"},{"id":"stretto-jl","label":"Stretto.jl","type":"package"},{"id":"namedtrajectories-jl","label":"NamedTrajectories.jl","type":"package"},{"id":"directtrajopt-jl","label":"DirectTrajOpt.jl","type":"package"},{"id":"altissimo-jl","label":"Altissimo.jl","type":"package"}],"edges":[{"s":"insight-linear-over-cubic","t":"exp-flux-x-192727","kind":"wikilink"},{"s":"insight-linear-over-cubic","t":"exp-flux-x-193328","kind":"wikilink"},{"s":"insight-linear-over-cubic","t":"exp-flux-x-194147","kind":"wikilink"},{"s":"insight-linear-over-cubic","t":"pulse-flux-x-v1","kind":"wikilink"},{"s":"insight-linear-over-cubic","t":"pulse-flux-x-v2","kind":"wikilink"},{"s":"insight-linear51-fix","t":"exp-flux-x-193328","kind":"wikilink"},{"s":"insight-linear51-fix","t":"exp-flux-x-194147","kind":"wikilink"},{"s":"insight-linear51-fix","t":"exp-flux-y-194147","kind":"wikilink"},{"s":"insight-linear51-fix","t":"exp-flux-y-020343","kind":"wikilink"},{"s":"insight-linear51-fix","t":"exp-flux-sqrtx-020343","kind":"wikilink"},{"s":"insight-linear51-fix","t":"insight-linear-over-cubic","kind":"wikilink"},{"s":"insight-shorter-duration","t":"exp-flux-y-020343","kind":"wikilink"},{"s":"insight-shorter-duration","t":"exp-flux-sqrtx-020343","kind":"wikilink"},{"s":"insight-shorter-duration","t":"exp-flux-y-v3","kind":"wikilink"},{"s":"insight-shorter-duration","t":"insight-linear51-fix","kind":"wikilink"},{"s":"insight-warmstart-regression","t":"exp-flux-x-021602","kind":"wikilink"},{"s":"insight-warmstart-regression","t":"exp-flux-y-021602","kind":"wikilink"},{"s":"insight-y-coldstart-variance","t":"exp-flux-y-q200k","kind":"wikilink"},{"s":"insight-y-coldstart-variance","t":"exp-flux-y-retry","kind":"wikilink"},{"s":"insight-y-coldstart-variance","t":"exp-flux-y-v3","kind":"wikilink"},{"s":"insight-y-coldstart-variance","t":"exp-flux-y-021602","kind":"wikilink"},{"s":"insight-crosstalk","t":"insight-eagle-heron","kind":"wikilink"},{"s":"insight-crosstalk","t":"method-crosstalk-gates","kind":"wikilink"},{"s":"insight-free-phase-2q","t":"method-free-phase","kind":"wikilink"},{"s":"insight-free-phase-2q","t":"transmon-two-qubit","kind":"wikilink"},{"s":"insight-gn-hessian-fails","t":"hyp-augmented-gn","kind":"wikilink"},{"s":"insight-gn-hessian-fails","t":"spec-analytic-derivatives","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-h-194147","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-t-v3","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-x-v3","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-y-q200k","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-y-retry","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"exp-flux-y-v3","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"insight-y-coldstart-variance","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"insight-warmstart-regression","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"insight-coldstart-dominates","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"insight-free-phase-untried","kind":"wikilink"},{"s":"insight-stagnation-dominant","t":"insight-warmstart-taxonomy","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"strategy","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"exp-flux-x-192727","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"exp-flux-x-193328","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"pulse-flux-x-v1","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"pulse-flux-x-v2","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"fluxonium-half-flux","kind":"wikilink"},{"s":"exp-flux-x-194147","t":"local-workstation","kind":"wikilink"},{"s":"exp-flux-x-v3","t":"strategy","kind":"wikilink"},{"s":"exp-flux-x-v3","t":"pulse-flux-x-v2","kind":"wikilink"},{"s":"exp-flux-x-v3","t":"pulse-flux-x-v3","kind":"wikilink"},{"s":"exp-flux-x-v3","t":"fluxonium-half-flux","kind":"wikilink"},{"s":"exp-flux-x-v3","t":"local-workstation","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"strategy","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"exp-flux-y-194147","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"exp-flux-y-020343","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"pulse-flux-y-v3","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"fluxonium-half-flux","kind":"wikilink"},{"s":"exp-flux-y-v3","t":"local-workstation","kind":"wikilink"},{"s":"exp-flux-t-v3","t":"strategy","kind":"wikilink"},{"s":"exp-flux-t-v3","t":"exp-flux-t-194147","kind":"wikilink"},{"s":"exp-flux-t-v3","t":"pulse-flux-t-v3","kind":"wikilink"},{"s":"exp-flux-t-v3","t":"fluxonium-half-flux","kind":"wikilink"},{"s":"exp-flux-t-v3","t":"local-workstation","kind":"wikilink"},{"s":"exp-rydberg-cz","t":"pulse-rydberg-cz-v1","kind":"wikilink"},{"s":"exp-rydberg-cz","t":"rydberg-global","kind":"wikilink"},{"s":"exp-flux-x-basis-comp","t":"fluxonium-half-flux","kind":"wikilink"},{"s":"exp-flux-x-basis-comp","t":"method-basis-comparison","kind":"wikilink"},{"s":"hyp-free-phase-gap","t":"insight-free-phase-2q","kind":"wikilink"},{"s":"researcher","t":"amico-strategy","kind":"dispatch"},{"s":"researcher","t":"hypothesis-review","kind":"dispatch"},{"s":"researcher","t":"structural-analysis","kind":"dispatch"},{"s":"researcher","t":"brainstorming","kind":"dispatch"},{"s":"researcher","t":"objectives","kind":"dispatch"},{"s":"experimenter","t":"setup","kind":"dispatch"},{"s":"experimenter","t":"solve","kind":"dispatch"},{"s":"experimenter","t":"transmon","kind":"dispatch"},{"s":"experimenter","t":"fluxonium","kind":"dispatch"},{"s":"experimenter","t":"atoms","kind":"dispatch"},{"s":"experimenter","t":"ions","kind":"dispatch"},{"s":"experimenter","t":"bosonic","kind":"dispatch"},{"s":"experimenter","t":"multistart","kind":"dispatch"},{"s":"experimenter","t":"benchmark","kind":"dispatch"},{"s":"experimenter","t":"demo","kind":"dispatch"},{"s":"experimenter","t":"plot","kind":"dispatch"},{"s":"librarian","t":"amico-vault","kind":"dispatch"},{"s":"librarian","t":"amico-catalog","kind":"dispatch"},{"s":"librarian","t":"analyze","kind":"dispatch"},{"s":"librarian","t":"ingest","kind":"dispatch"},{"s":"librarian","t":"hopper","kind":"dispatch"},{"s":"dreamer","t":"dream","kind":"dispatch"},{"s":"dreamer","t":"dream-distill","kind":"dispatch"},{"s":"dreamer","t":"dream-connect","kind":"dispatch"},{"s":"dreamer","t":"dream-prune","kind":"dispatch"},{"s":"dreamer","t":"dream-synthesize","kind":"dispatch"},{"s":"dreamer","t":"dream-reflect","kind":"dispatch"},{"s":"engineer","t":"piccolo-dev","kind":"dispatch"},{"s":"engineer","t":"piccolissimo-dev","kind":"dispatch"},{"s":"engineer","t":"intonato-dev","kind":"dispatch"},{"s":"engineer","t":"stretto-dev","kind":"dispatch"},{"s":"engineer","t":"tdd","kind":"dispatch"},{"s":"engineer","t":"test","kind":"dispatch"},{"s":"engineer","t":"pr","kind":"dispatch"},{"s":"engineer","t":"code-review","kind":"dispatch"},{"s":"engineer","t":"debugging","kind":"dispatch"},{"s":"engineer","t":"verification","kind":"dispatch"},{"s":"orchestrator","t":"using-amico","kind":"dispatch"},{"s":"orchestrator","t":"amico-route","kind":"dispatch"},{"s":"orchestrator","t":"meeting","kind":"dispatch"},{"s":"dispatcher","t":"amico-lab","kind":"dispatch"},{"s":"dispatcher","t":"solve","kind":"dispatch"},{"s":"dispatcher","t":"multistart","kind":"dispatch"},{"s":"piccolo-dev","t":"piccolo-jl","kind":"uses"},{"s":"piccolissimo-dev","t":"piccolissimo-jl","kind":"uses"},{"s":"intonato-dev","t":"intonato-jl","kind":"uses"},{"s":"stretto-dev","t":"stretto-jl","kind":"uses"},{"s":"solve","t":"piccolo-jl","kind":"uses"},{"s":"solve","t":"piccolissimo-jl","kind":"uses"},{"s":"setup","t":"piccolo-jl","kind":"uses"},{"s":"benchmark","t":"piccolo-jl","kind":"uses"},{"s":"objectives","t":"piccolo-jl","kind":"uses"},{"s":"plot","t":"piccolo-jl","kind":"uses"},{"s":"piccolo-jl","t":"namedtrajectories-jl","kind":"uses"},{"s":"piccolo-jl","t":"directtrajopt-jl","kind":"uses"},{"s":"directtrajopt-jl","t":"namedtrajectories-jl","kind":"uses"},{"s":"piccolissimo-jl","t":"altissimo-jl","kind":"uses"},{"s":"intonato-jl","t":"piccolo-jl","kind":"uses"},{"s":"amico-strategy","t":"strategy","kind":"uses"},{"s":"amico-strategy","t":"roadmap","kind":"uses"},{"s":"using-amico","t":"philosophy","kind":"uses"},{"s":"amico-route","t":"charter-agents-skills","kind":"uses"},{"s":"amico-catalog","t":"charter-pulse-catalog","kind":"uses"},{"s":"dream","t":"charter-research-loop","kind":"uses"},{"s":"dream","t":"dream-distill","kind":"uses"},{"s":"dream","t":"dream-reflect","kind":"uses"},{"s":"dream","t":"dream-connect","kind":"uses"},{"s":"dream","t":"dream-prune","kind":"uses"},{"s":"dream","t":"dream-synthesize","kind":"uses"},{"s":"amico-lab","t":"local-workstation","kind":"uses"},{"s":"amico-lab","t":"hermes","kind":"uses"},{"s":"amico-lab","t":"stanford-fluxonium-chip","kind":"uses"},{"s":"fluxonium","t":"fluxonium-half-flux","kind":"uses"},{"s":"fluxonium","t":"stanford-fluxonium-chip","kind":"uses"},{"s":"transmon","t":"transmon-two-qubit","kind":"uses"},{"s":"atoms","t":"rydberg-global","kind":"uses"},{"s":"setup","t":"method-cold-start","kind":"uses"},{"s":"setup","t":"method-cubic-spline","kind":"uses"},{"s":"amico-catalog","t":"method-warm-start","kind":"uses"},{"s":"structural-analysis","t":"method-presolve-diag","kind":"uses"},{"s":"structural-analysis","t":"insight-stagnation-dominant","kind":"uses"},{"s":"debugging","t":"method-presolve-diag","kind":"uses"},{"s":"multistart","t":"insight-jit-multistart-thrash","kind":"uses"},{"s":"multistart","t":"method-cold-start","kind":"uses"},{"s":"hypothesis-review","t":"hyp-free-phase-gap","kind":"uses"},{"s":"hypothesis-review","t":"hyp-dressed-goal-kets","kind":"uses"},{"s":"hypothesis-review","t":"hyp-augmented-gn","kind":"uses"},{"s":"amico-catalog","t":"pulse-flux-x-v2","kind":"uses"},{"s":"amico-catalog","t":"pulse-flux-y-v3","kind":"uses"},{"s":"amico-catalog","t":"pulse-transmon-cz-v1","kind":"uses"},{"s":"amico-catalog","t":"pulse-transmon-x-v1","kind":"uses"},{"s":"exp-flux-x-194147","t":"insight-linear-over-cubic","kind":"produces"},{"s":"exp-flux-y-q200k","t":"insight-y-coldstart-variance","kind":"produces"},{"s":"exp-flux-y-retry","t":"insight-y-coldstart-variance","kind":"produces"},{"s":"exp-flux-x-021602","t":"insight-warmstart-regression","kind":"produces"},{"s":"exp-flux-y-021602","t":"insight-warmstart-regression","kind":"produces"},{"s":"exp-flux-y-020343","t":"insight-linear51-fix","kind":"produces"},{"s":"exp-flux-y-v3","t":"insight-shorter-duration","kind":"produces"},{"s":"exp-flux-x-v3","t":"pulse-flux-x-v3","kind":"produces"},{"s":"exp-flux-y-v3","t":"pulse-flux-y-v3","kind":"produces"},{"s":"exp-flux-t-v3","t":"pulse-flux-t-v3","kind":"produces"},{"s":"exp-rydberg-cz","t":"pulse-rydberg-cz-v1","kind":"produces"},{"s":"ingest","t":"exp-rydberg-cz","kind":"produces"},{"s":"ingest","t":"exp-flux-x-basis-comp","kind":"produces"},{"s":"dream-synthesize","t":"insight-stagnation-dominant","kind":"produces"},{"s":"dream-synthesize","t":"insight-warmstart-taxonomy","kind":"produces"},{"s":"dream-synthesize","t":"insight-coldstart-dominates","kind":"produces"},{"s":"dream-synthesize","t":"insight-free-phase-untried","kind":"produces"},{"s":"dream-distill","t":"insight-jit-multistart-thrash","kind":"produces"},{"s":"dream-distill","t":"hyp-dressed-goal-kets","kind":"produces"},{"s":"analyze","t":"insight-y-coldstart-variance","kind":"produces"},{"s":"researcher","t":"brief-analog-magic","kind":"produces"}],"traces":[{"id":"fluxonium-x-gate","title":"optimize a fluxonium X gate","steps":[{"node":"using-amico","status":"loading using-amico: skill map + conventions","fanout":["amico-route"]},{"node":"brainstorming","status":"clarifying target: X gate, 99.99% fidelity goal","fanout":["objectives","demo"]},{"node":"fluxonium","status":"loading fluxonium hamiltonian + drive selection","fanout":["transmon","stanford-fluxonium-chip"]},{"node":"amico-catalog","status":"warm-start lookup: catalog/pulses/fluxonium-X\u2026","fanout":["pulse-flux-x-v1","charter-pulse-catalog"]},{"node":"pulse-flux-x-v2","status":"found fluxonium-X-v2: best prior pulse","fanout":["pulse-flux-x-v1","pulse-flux-x-v3"]},{"node":"insight-linear-over-cubic","status":"reading insight: linear splines beat cubic","fanout":["insight-linear51-fix","method-cubic-spline"]},{"node":"insight-shorter-duration","status":"checking insight: shorter T0 improves fidelity","fanout":["insight-warmstart-regression"]},{"node":"setup","status":"building SplinePulseProblem: linear, 51 knots","fanout":["method-cold-start","objectives"]},{"node":"piccolo-jl","status":"assembling Piccolo problem + GL4 integrator","fanout":["namedtrajectories-jl","directtrajopt-jl"]},{"node":"solve","status":"solving: iter 120, inf_pr 3.2e-9, fid 99.99%","fanout":["local-workstation"]},{"node":"analyze","status":"analyzing run: no stagnation, clean convergence","fanout":["plot","benchmark"]},{"node":"librarian","status":"dispatching librarian to record results","fanout":["amico-vault"]},{"node":"exp-flux-x-v3","status":"writing experiments/exp-\u2026-fluxonium-X-v3","fanout":["strategy"]},{"node":"amico-catalog","status":"ingesting pulse: fluxonium-X-v3 into catalog","fanout":["pulse-flux-x-v3","ingest"]}]},{"id":"debug-stagnation","title":"debug solver stagnation","steps":[{"node":"debugging","status":"reproducing: inf_pr stuck at 0.289 after 200 it","fanout":["verification"]},{"node":"structural-analysis","status":"predicting: free-phase? warm-start? integrator?","fanout":["method-presolve-diag"]},{"node":"insight-stagnation-dominant","status":"reading synthesis: stagnation dominant failure","fanout":["insight-warmstart-taxonomy","insight-coldstart-dominates"]},{"node":"insight-y-coldstart-variance","status":"matching pattern: Y-gate cold-start variance","fanout":["exp-flux-y-q200k","exp-flux-y-retry"]},{"node":"insight-warmstart-regression","status":"ruling out warm-start regression path","fanout":["exp-flux-x-021602"]},{"node":"multistart","status":"dispatching K=8 parallel cold starts","fanout":["dispatcher","insight-jit-multistart-thrash"]},{"node":"insight-jit-multistart-thrash","status":"checking JIT cache-lock thrash mitigation","fanout":["local-workstation"]},{"node":"piccolissimo-jl","status":"inspecting Piccolissimo GL4 jacobian path","fanout":["altissimo-jl"]},{"node":"solve","status":"re-solving best seed: inf_pr 1.1e-8, converged","fanout":["local-workstation"]},{"node":"verification","status":"verifying fidelity claim against rollout","fanout":["analyze"]}]},{"id":"dream-cycle","title":"dream cycle","steps":[{"node":"dreamer","status":"waking dreamer: nightly consolidation","fanout":["dream"]},{"node":"dream","status":"orchestrating distill, connect, prune, synth","fanout":["charter-research-loop"]},{"node":"dream-distill","status":"distilling 14 session transcripts into notes","fanout":["amico-vault"]},{"node":"dream-reflect","status":"writing retrospectives for solver sessions","fanout":["insight-jit-multistart-thrash"]},{"node":"dream-connect","status":"densifying graph: scanning insights for links","fanout":["insight-linear-over-cubic","insight-shorter-duration","insight-crosstalk","insight-free-phase-2q"]},{"node":"dream-connect","status":"linking exp notes to pulse catalog entries","fanout":["exp-flux-y-v3","exp-flux-t-v3","pulse-flux-y-v3","pulse-flux-t-v3"]},{"node":"dream-connect","status":"cross-linking hypotheses to evidence","fanout":["hyp-free-phase-gap","hyp-augmented-gn","insight-gn-hessian-fails","spec-analytic-derivatives"]},{"node":"dream-prune","status":"resolving TBDs, fixing frontmatter drift","fanout":["exp-flux-y-retry","exp-flux-y-q200k"]},{"node":"dream-synthesize","status":"hunting cross-platform patterns in 40+ runs","fanout":["insight-mintime-2q","insight-eagle-heron","exp-rydberg-cz"]},{"node":"insight-coldstart-dominates","status":"new insight: cold start dominates fluxonium","fanout":["insight-free-phase-untried"]},{"node":"insight-warmstart-taxonomy","status":"new insight: warm-start failure taxonomy","fanout":["insight-stagnation-dominant"]},{"node":"amico-vault","status":"committing vault: 3 new notes, 12 new links","fanout":["librarian"]}]},{"id":"morning-briefing","title":"morning research briefing","steps":[{"node":"amico-strategy","status":"loading current research strategy","fanout":["roadmap"]},{"node":"strategy","status":"reading STRATEGY: P2 fluxonium gate suite","fanout":["philosophy","roadmap"]},{"node":"hypothesis-review","status":"ranking open hypotheses by testability","fanout":["researcher"]},{"node":"hyp-free-phase-gap","status":"checking hyp: free-phase fluxonium gap","fanout":["insight-free-phase-2q"]},{"node":"hyp-dressed-goal-kets","status":"checking hyp: dressed kets unlock 2q gates","fanout":["hyp-augmented-gn"]},{"node":"exp-flux-x-v3","status":"scanning recent runs: X v3 hit 99.99%","fanout":["exp-flux-y-v3","exp-flux-t-v3"]},{"node":"insight-stagnation-dominant","status":"surfacing blocker: stagnation on cold starts","fanout":["multistart"]},{"node":"researcher","status":"drafting briefing with researcher agent","fanout":["amico-vault"]},{"node":"brief-analog-magic","status":"writing research-briefs entry for today","fanout":["strategy"]}]},{"id":"benchmark-rydberg-cz","title":"benchmark a rydberg CZ pulse","steps":[{"node":"using-amico","status":"loading skill map + path conventions","fanout":["amico-route"]},{"node":"atoms","status":"loading rydberg physics: blockade, global drive","fanout":["ions","bosonic"]},{"node":"amico-catalog","status":"warm-start lookup: catalog/pulses/rydberg-CZ-v1","fanout":["pulse-rydberg-cz-v1","pulse-transmon-cz-v1"]},{"node":"exp-rydberg-cz","status":"reading exp: rydberg CZ v1 provenance","fanout":["rydberg-global"]},{"node":"benchmark","status":"recomputing fidelity with current Piccolo","fanout":["piccolo-jl"]},{"node":"piccolo-jl","status":"rebuilding system: rydberg global drive","fanout":["namedtrajectories-jl"]},{"node":"solve","status":"rollout: fidelity matches recorded to 1e-4","fanout":["local-workstation"]},{"node":"plot","status":"plotting pulse + population transfer","fanout":["demo"]},{"node":"amico-vault","status":"appending benchmark table to exp note","fanout":["librarian"]}]}]} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.test.ts new file mode 100644 index 00000000..5d639fc9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, test } from "bun:test" +import { createBrainEngine, type BrainEngineOptions } from "./brain-engine" +import { BRAIN_DATA } from "./brain-data" + +/* The engine must run headless: bun test has no DOM, no rAF, no matchMedia. + That is the point — the old /brain.html iframe could only be exercised by + booting a server and a browser, which is why it broke three times before + anyone saw it. A recording 2d-context stub is enough to pin the behavior. */ + +type Call = { method: string; args: unknown[] } +function recordingCtx() { + const calls: Call[] = [] + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, args }) + if (method === "measureText") return { width: 42 } + return undefined + } + const ctx: Record = { calls } + for (const m of [ + "setTransform", + "clearRect", + "fillRect", + "beginPath", + "moveTo", + "lineTo", + "stroke", + "fill", + "arc", + "setLineDash", + "drawImage", + "fillText", + "measureText", + ]) + ctx[m] = record(m) + return ctx as { calls: Call[] } & Record +} +function stubCanvas(ctx: unknown) { + return { + clientWidth: 0, + clientHeight: 0, + width: 0, + height: 0, + getContext: () => ctx, + } as unknown as HTMLCanvasElement +} +function makeEngine(opts: Partial = {}) { + const ctx = recordingCtx() + const engine = createBrainEngine(stubCanvas(ctx), { + scheme: "dark", + reduceMotion: true, + animate: false, + size: { width: 800, height: 224 }, + ...opts, + }) + return { engine, ctx } +} + +describe("boot", () => { + test("builds the full skeleton graph headless, centered on the amico core", () => { + const { engine } = makeEngine() + const s = engine.stats() + // every vault-sample node plus the core; edges include the dispatch spokes + expect(s.nodes).toBe(BRAIN_DATA.nodes.length + 1) + expect(s.edges).toBeGreaterThan(BRAIN_DATA.edges.length) + expect(s.cur).toBe("amico") + expect(s.claimed).toBe(0) + expect(s.atlas).toBe(0) + }) + + test("wears the requested scheme from frame zero — no wrong-theme first frame", () => { + // the iframe read its theme AFTER load (async in the VS Code webview), so + // the first frames painted the wrong palette or opaque white; the native + // engine takes the scheme as a constructor input + const { engine } = makeEngine({ scheme: "light" }) + expect(engine.stats().scheme).toBe("light") + }) + + test("first frame clears to transparent, never paints an opaque ground", () => { + const { engine, ctx } = makeEngine() + engine.tick(16) + engine.tick(32) + const clears = ctx.calls.filter((c) => c.method === "clearRect") + expect(clears.length).toBeGreaterThanOrEqual(2) + expect(clears[0].args).toEqual([0, 0, 800, 224]) + // the unwanted first frame was the old page's own body { background } + // fill — a full-canvas fillRect has no business here (label halos are + // small rects) + const fullFills = ctx.calls.filter( + (c) => c.method === "fillRect" && (c.args[2] as number) >= 800 && (c.args[3] as number) >= 224, + ) + expect(fullFills).toEqual([]) + }) +}) + +describe("live thought", () => { + test("a replay commit claims the node instantly and moves the cursor", () => { + const { engine } = makeEngine() + engine.touch({ label: "solve", replay: true }) + const s = engine.stats() + expect(s.claimed).toBe(1) + expect(s.cur).toBe("solve") + }) + + test("a consider touch is a scout flash — it never claims", () => { + const { engine } = makeEngine() + engine.touch({ label: "debugging", consider: true }) + const s = engine.stats() + expect(s.claimed).toBe(0) + expect(s.cur).toBe("amico") + }) + + test("an unknown label grafts a new node over a thought edge", () => { + const { engine } = makeEngine() + const before = engine.stats() + engine.touch({ label: "scratch/wip-notes.md", replay: true }) + const after = engine.stats() + expect(after.nodes).toBe(before.nodes + 1) + expect(after.edges).toBe(before.edges + 1) + expect(after.claimed).toBe(1) + }) + + test("a live commit claims through the reduced-motion pump", () => { + const { engine } = makeEngine() + engine.touch({ label: "setup" }) + // reduced motion: no traveling pulses — the claim lands synchronously + const s = engine.stats() + expect(s.claimed).toBe(1) + expect(s.cur).toBe("setup") + }) + + test("charting two commits since the last plate yields one constellation", () => { + const { engine } = makeEngine() + engine.touch({ label: "solve", replay: true }) + engine.touch({ label: "piccolo-jl", replay: true }) + engine.chart("optimize a fluxonium X gate", true) + expect(engine.stats().atlas).toBe(1) + }) +}) + +describe("theme", () => { + test("setTheme is lossless: the atlas, claims, and cursor all survive a flip", () => { + // the iframe workaround reloaded the document on every flip and re-flushed + // the whole event history; the native engine just swaps ink + const { engine } = makeEngine() + engine.touch({ label: "solve", replay: true }) + engine.touch({ label: "piccolo-jl", replay: true }) + engine.chart("plate the thought", true) + const before = engine.stats() + engine.setTheme("light") + const after = engine.stats() + expect(after.scheme).toBe("light") + expect(after.claimed).toBe(before.claimed) + expect(after.atlas).toBe(before.atlas) + expect(after.cur).toBe(before.cur) + expect(after.nodes).toBe(before.nodes) + }) + + test("setTheme ignores junk", () => { + const { engine } = makeEngine() + engine.setTheme("hotdog" as never) + expect(engine.stats().scheme).toBe("dark") + }) +}) + +describe("lifecycle", () => { + test("pause stops the frame loop; resume restarts it", () => { + const { engine, ctx } = makeEngine() + engine.tick(16) + const n = ctx.calls.length + engine.pause() + engine.tick(1000) + expect(ctx.calls.length).toBe(n) // folded away — no frames burned + engine.resume() + engine.tick(1016) + expect(ctx.calls.length).toBeGreaterThan(n) + }) + + test("destroy is terminal: later events and ticks are no-ops", () => { + const { engine, ctx } = makeEngine() + engine.destroy() + const n = ctx.calls.length + engine.touch({ label: "solve", replay: true }) + engine.resume() + engine.tick(2000) + expect(ctx.calls.length).toBe(n) + expect(engine.stats().claimed).toBe(0) + }) + + test("highlight tolerates unknown labels", () => { + const { engine } = makeEngine() + expect(() => { + engine.highlight("tdd") + engine.highlight("definitely-not-a-node") + engine.highlight("") + }).not.toThrow() + }) +}) + +describe("hostile input", () => { + test("a NaN timestamp is a no-op and does not poison the clock", () => { + const { engine, ctx } = makeEngine() + engine.tick(Number.NaN) + engine.tick(Number.POSITIVE_INFINITY) + expect(ctx.calls.length).toBe(0) // rejected before any drawing + engine.tick(16) + engine.touch({ label: "solve", replay: true }) + engine.tick(32) // a poisoned beat would NaN every projection from here on + expect(ctx.calls.filter((c) => c.method === "clearRect").length).toBe(2) + expect(engine.stats().claimed).toBe(1) + }) + + test("junk resize falls back instead of zeroing the world scale", () => { + const { engine, ctx } = makeEngine() + engine.resize(Number.NaN, -5) + engine.resize(0, 0) + engine.tick(16) + const clears = ctx.calls.filter((c) => c.method === "clearRect") + expect(clears[0].args).toEqual([0, 0, 800, 224]) // opts.size fallback held + }) + + test("charting fewer than two commits never plates a constellation", () => { + const { engine } = makeEngine() + engine.touch({ label: "solve", replay: true }) + engine.chart("one lonely commit", true) + expect(engine.stats().atlas).toBe(0) + }) + + test("rapid theme flips stay stable and lossless", () => { + const { engine } = makeEngine() + engine.touch({ label: "solve", replay: true }) + for (let i = 0; i < 50; i++) engine.setTheme(i % 2 ? "dark" : "light") + engine.tick(16) + expect(engine.stats().scheme).toBe("dark") // 50 flips from i=0 end on i=49 → dark + expect(engine.stats().claimed).toBe(1) + }) + + test("the graft population is capped — marathon sessions cannot grow the graph unboundedly", () => { + const { engine } = makeEngine() + const boot = engine.stats().nodes + for (let i = 0; i < 340; i++) engine.touch({ label: `scratch/probe-${i}.md`, consider: true }) + // 340 unique search patterns grafted, but eviction holds the line at the cap + expect(engine.stats().nodes).toBeLessThanOrEqual(boot + 300) + expect(engine.stats().edges).toBeLessThanOrEqual(boot + 300 + 200) // skeleton edges + capped thought edges + engine.tick(16) // and the survivors still draw + }) + + test("a render error halts the loop instead of spinning half-rendered", () => { + const ctx = recordingCtx() + let arcs = 0 + ctx.arc = (..._args: unknown[]) => { + if (++arcs > 4) throw new Error("boom") + } + const canvas = stubCanvas(ctx) + const engine = createBrainEngine(canvas, { + scheme: "dark", + reduceMotion: true, + animate: false, + size: { width: 800, height: 224 }, + }) + engine.tick(16) // throws internally → halts, no propagation + const n = ctx.calls.length + engine.tick(32) // halted: no further drawing + expect(ctx.calls.length).toBe(n) + engine.resume() // re-arms after e.g. a session switch + arcs = -1e9 + engine.tick(48) + expect(ctx.calls.length).toBeGreaterThan(n) + }) +}) + +describe("animated pipeline (manual clock)", () => { + // full motion: reduceMotion off, clock driven by hand — a commit is a pulse + // that must physically travel the skeleton before its node claims + const drive = (engine: ReturnType["engine"], fromMs: number, toMs: number) => { + for (let t = fromMs; t <= toMs; t += 16) engine.tick(t) + return toMs + } + + test("a live commit claims only after its pulse arrives", () => { + const { engine } = makeEngine({ reduceMotion: false }) + engine.tick(0) + engine.touch({ label: "setup" }) // amico → experimenter → setup, one beat per hop + expect(engine.stats().claimed).toBe(0) // departure is not arrival + drive(engine, 16, 4000) // allegro q=126: plenty of beats for both hops + const s = engine.stats() + expect(s.claimed).toBeGreaterThanOrEqual(1) + expect(s.cur).toBe("setup") + }) + + test("a chart waits for the pump to drain, then plates every commit", () => { + const { engine } = makeEngine({ reduceMotion: false }) + engine.tick(0) + engine.touch({ label: "setup" }) + engine.touch({ label: "solve" }) + engine.chart("the whole thought", false) // arrives while commits are in flight + expect(engine.stats().atlas).toBe(0) // a plate never misses its own commits + drive(engine, 16, 8000) + const s = engine.stats() + expect(s.atlas).toBe(1) + expect(s.claimed).toBeGreaterThanOrEqual(2) + expect(s.queued).toBe(0) + }) + + test("destroy with pulses in flight is safe", () => { + const { engine } = makeEngine({ reduceMotion: false }) + engine.tick(0) + engine.touch({ label: "setup" }) + drive(engine, 16, 200) // pulse mid-edge + expect(() => engine.destroy()).not.toThrow() + engine.tick(300) // halted — no draw, no arrival callbacks + expect(engine.stats().cur).toBe("amico") + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.ts new file mode 100644 index 00000000..4eee2d67 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-engine.ts @@ -0,0 +1,1183 @@ +/* ================================================================ + amico is thinking — the shape of a thought (native engine) + + A charted fugue over the real vault graph, rendered on a canvas that + lives IN the host document. This is the live-embed half of the retired + /brain.html iframe prototype, ported to a framework-free module so the + session brain-strip can drive it with direct calls instead of a + postMessage bridge. + + Why native (2026-07-20): the iframe boundary broke this animation three + independent ways — document requests can't carry server auth (armed + password ⇒ 401 ⇒ blank strip), a parent/child color-scheme mismatch + makes the browser composite the transparent frame OPAQUE WHITE (async + webview theming ⇒ white box, and theme flips forced a full reload), and + the page painted its own stylesheet ground + prototype chrome for the + first frames before script could hide them. In-document there is no + fetch, no second color-scheme, no boot chrome: the canvas ground is + transparent from frame zero and setTheme() repaints without losing the + atlas. + + Timing law: every event snaps to a musical grid (allegro, q=126). + Mechanics: integrate-and-fire — arrival is not firing; scouts charge + nodes that may leak back to dark; refractory periods gate + re-firing; warm-start edges conduct saltatory. + Brand law: circles only; #fff676 belongs to live thought alone; the + embed is monochrome + brand yellow; glow budget: pulse + + active node only. + ================================================================ */ + +import { BRAIN_DATA } from "./brain-data" + +export type BrainScheme = "dark" | "light" + +export type BrainTouchEvent = { + label: string + type?: string + /** scout flash (search/glob) — may leak back to dark, never claims */ + consider?: boolean + /** a prior turn's step: restore instantly and quietly, no pulses */ + replay?: boolean +} + +export interface BrainEngineOptions { + scheme?: BrainScheme + /** override prefers-reduced-motion (tests) */ + reduceMotion?: boolean + /** drive the render loop via requestAnimationFrame (default true; tests call tick()) */ + animate?: boolean + /** layout fallback when the canvas has no measured size yet */ + size?: { width: number; height: number } +} + +export interface BrainEngineStats { + scheme: BrainScheme + nodes: number + edges: number + claimed: number + atlas: number + queued: number + cur: string +} + +export interface BrainEngine { + touch(ev: BrainTouchEvent): void + /** chart the commits since the last plate as a named constellation */ + chart(title: string, replay?: boolean): void + /** a glance from the log: ring the node and turn the camera to it */ + highlight(label: string): void + /** lossless: swaps the palette and repaints — the atlas persists */ + setTheme(scheme: BrainScheme): void + /** omit both to re-measure from the canvas box (e.g. after mount/layout) */ + resize(width?: number, height?: number): void + /** advance one frame; the rAF loop calls this when animate is on */ + tick(nowMs: number): void + /** folded away — stop burning frames */ + pause(): void + resume(): void + destroy(): void + stats(): BrainEngineStats +} + +/* ---------- category mapping (fixed, never cycled) ---------- */ +const CAT_OF_TYPE: Record = { + note: "knowledge", + insight: "knowledge", + charter: "knowledge", + experiment: "results", + catalog: "results", + skill: "skills", + package: "code", + resource: "code", + agent: "agents", + core: "core", +} + +/* ---------- tokens ---------- + The iframe read these from its own stylesheet with getComputedStyle; the + native engine carries both palettes (ported verbatim from brain.html's + :root blocks) so a theme flip is a repaint, never a reload. */ +type Palette = { + fg: string + edgeRest: string + nodeFill: string + nodeBorder: string + thought: string + ember: string + labelHalo: string + accent: string + cat: Record +} +const PALETTES: Record = { + dark: { + fg: "#d6d6d2", + edgeRest: "rgba(255, 255, 255, 0.13)", + nodeFill: "#242423", + nodeBorder: "rgba(255, 255, 255, 0.24)", + thought: "#fff676", + ember: "rgba(232, 230, 218, 1)", + labelHalo: "rgba(19, 19, 18, 0.72)", + accent: "#fff676", + cat: { + knowledge: "#3794ff", + results: "#c17800", + skills: "#9b6bc4", + code: "#4d9e51", + agents: "#c9c9c4", + }, + }, + light: { + fg: "#22221f", + edgeRest: "rgba(0, 0, 0, 0.15)", + nodeFill: "#eae7dd", + nodeBorder: "rgba(0, 0, 0, 0.26)", + thought: "#8f8000", + ember: "rgba(64, 62, 50, 1)", + labelHalo: "rgba(250, 249, 246, 0.78)", + accent: "#fff676", + cat: { + knowledge: "#1866c9", + results: "#8f5800", + skills: "#7b4fa8", + code: "#33753a", + agents: "#4a4a44", + }, + }, +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "") + const n = parseInt( + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h, + 16, + ) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} +function rgba(color: string, a: number): string { + if (color.startsWith("rgba") || color.startsWith("rgb")) return color + const [r, g, b] = hexToRgb(color) + return `rgba(${r},${g},${b},${a})` +} + +/* ---------- graph shapes ---------- */ +interface BNode { + id: string + label: string + type: string + cat: string + x: number + y: number + fx: number + fy: number + deg: number + half: number + claimed: boolean + flash: number + consider: number + labelA: number + refractUntil: number + ringT: number + touchedAt: number + uses: number + breathe: number + atlasKeep: boolean +} +interface BEdge { + s: BNode + t: BNode + kind: string + passes: number + ember: number + myelin: boolean + liveT: number + ghost: boolean + atlasKeep: boolean +} +interface Pulse { + e: BEdge + rev: boolean + kind: string + t0: number + dur: number + onArrive: (() => void) | null + trail: { x: number; y: number }[] + lastSample: number +} +interface Constellation { + pts: BNode[] + title: string + plate: string + progress: number + alpha: number + born: number +} + +const ROMAN = [ + "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", + "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", +] + +const TEMPI = [ + { bpm: 42, name: "largo" }, + { bpm: 84, name: "andante" }, + { bpm: 126, name: "allegro" }, + { bpm: 168, name: "presto" }, +] + +export function createBrainEngine(canvas: HTMLCanvasElement, opts: BrainEngineOptions = {}): BrainEngine { + let scheme: BrainScheme = opts.scheme === "light" ? "light" : "dark" + let css: Palette = PALETTES[scheme] + // live: honors mid-session OS toggles (the iframe only re-read it on reload) + let reduceMotion = + opts.reduceMotion ?? (typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches) + let motionQuery: MediaQueryList | null = null + const onMotionChange = (e: MediaQueryListEvent) => { + reduceMotion = e.matches + } + if (opts.reduceMotion === undefined && typeof matchMedia !== "undefined") { + motionQuery = matchMedia("(prefers-reduced-motion: reduce)") + motionQuery.addEventListener("change", onMotionChange) + } + const animate = opts.animate ?? true + const ctx = canvas.getContext("2d") + + /* ---------- square-bloom sprites (glow budget: live things only) ---------- */ + let bloom: HTMLCanvasElement | null = null + function buildSprites() { + if (typeof document === "undefined") return // test env: draw guards on null + const S = 64 + const c = document.createElement("canvas") + c.width = c.height = S + const x = c.getContext("2d") + if (!x) return + // stacked circles, not a radial gradient: the bloom keeps the round motif + const steps: [number, number][] = [ + [30, 0.28], + [22, 0.35], + [14, 0.5], + ] + for (const [half, a] of steps) { + x.fillStyle = rgba(css.thought, a * 0.35) + x.beginPath() + x.arc(S / 2, S / 2, half, 0, Math.PI * 2) + x.fill() + } + bloom = c + } + buildSprites() + + /* ---------- graph construction ---------- */ + const nodes: BNode[] = [] + const byId = new Map() + const edges: BEdge[] = [] + const adj = new Map() + const edgeSeen = new Map() + function addNode(n: { id: string; label: string; type: string }): BNode { + const prior = byId.get(n.id) + if (prior) return prior + const node: BNode = { + id: n.id, + label: n.label, + type: n.type, + cat: CAT_OF_TYPE[n.type] || "knowledge", + x: 0, + y: 0, + fx: 0, + fy: 0, + deg: 0, + half: 3, + claimed: false, + flash: 0, + consider: 0, + labelA: 0, + refractUntil: -1, + ringT: -1, + touchedAt: -1, + uses: 0, + breathe: Math.random() * 6.28, + atlasKeep: false, + } + nodes.push(node) + byId.set(n.id, node) + return node + } + function addEdge(s: string, t: string, kind: string): BEdge | null { + if (s === t || !byId.has(s) || !byId.has(t)) return null + const key = s < t ? s + "|" + t : t + "|" + s + const prior = edgeSeen.get(key) + if (prior) return prior + const e: BEdge = { + s: byId.get(s)!, + t: byId.get(t)!, + kind: kind || "wikilink", + passes: 0, + ember: 0, + myelin: false, + liveT: -1, + ghost: kind === "thought", + atlasKeep: false, + } + edgeSeen.set(key, e) + edges.push(e) + byId.get(s)!.deg++ + byId.get(t)!.deg++ + if (!adj.has(s)) adj.set(s, []) + if (!adj.has(t)) adj.set(t, []) + adj.get(s)!.push({ to: t, e }) + adj.get(t)!.push({ to: s, e }) + return e + } + function bfs(a: string, b: string, maxHops?: number): string[] | null { + if (a === b) return [a] + const q: string[][] = [[a]] + const seen = new Set([a]) + while (q.length) { + const path = q.shift()! + if (path.length > (maxHops || 6)) continue + for (const { to } of adj.get(path[path.length - 1]) || []) { + if (seen.has(to)) continue + const np = path.concat(to) + if (to === b) return np + seen.add(to) + q.push(np) + } + } + return null + } + + for (const n of BRAIN_DATA.nodes) addNode(n) + addNode({ id: "amico", label: "amico", type: "core" }) + for (const n of BRAIN_DATA.nodes) if (n.type === "agent") addEdge("amico", n.id, "dispatch") + if (byId.has("using-amico")) addEdge("amico", "using-amico", "dispatch") + for (const e of BRAIN_DATA.edges) addEdge(e.s, e.t, e.kind) + // a thought may connect notes the vault has not linked yet: consecutive trace + // steps without a vault edge get a dashed "thought edge", invisible until used + for (const tr of BRAIN_DATA.traces) { + let prev = "amico" + for (const st of tr.steps) { + if (!byId.has(st.node)) continue + const hasPath = bfs(prev, st.node, 4) + if (!hasPath) addEdge(prev, st.node, "thought") + prev = st.node + } + } + + /* size classes — atlas magnitudes, quantized, never continuous */ + for (const n of nodes) { + n.half = n.type === "core" ? 8 : n.cat === "agents" ? 6.5 : n.deg > 9 ? 6 : n.deg > 5 ? 5 : n.deg > 2 ? 4 : 3 + } + + /* ---------- layout: cluster-anchored force settle, then frozen ---------- + The strip is a wide frame: lay the graph out natively wide (wide anchor + ellipse + anisotropic gravity) so distances stay isotropic — a warped + projection reads as squashed, a wide LAYOUT reads as a landscape. */ + const CLUSTER_ANGLE: Record = { + knowledge: -Math.PI / 2, + skills: Math.PI * 0.05, + results: Math.PI / 2, + code: Math.PI * 0.95, + agents: 0, + core: 0, + } + function repel() { + for (let a = 0; a < nodes.length; a++) { + for (let b = a + 1; b < nodes.length; b++) { + const A = nodes[a], + B = nodes[b] + let dx = A.x - B.x, + dy = A.y - B.y + let d2 = dx * dx + dy * dy + if (d2 < 1) { + d2 = 1 + dx = Math.sin(a * 7 + b) + dy = Math.cos(a - b * 3) + } + const f = 1900 / d2 + const d = Math.sqrt(d2) + A.fx += (dx / d) * f + A.fy += (dy / d) * f + B.fx -= (dx / d) * f + B.fy -= (dy / d) * f + } + } + for (const e of edges) { + const dx = e.t.x - e.s.x, + dy = e.t.y - e.s.y + const d = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01) + const rest = e.kind === "dispatch" ? 95 : 70 + const f = (d - rest) * 0.015 * (e.ghost ? 0.25 : 1) + e.s.fx += (dx / d) * f * d * 0.02 + e.s.fy += (dy / d) * f * d * 0.02 + e.t.fx -= (dx / d) * f * d * 0.02 + e.t.fy -= (dy / d) * f * d * 0.02 + } + } + function settle() { + const AX = 2.3 + const AY = 0.75 + const R = 340 + let i = 0 + for (const n of nodes) { + const ang = CLUSTER_ANGLE[n.cat] + Math.sin(i * 12.9898) * 0.55 + const rad = n.cat === "agents" ? 90 + (i % 5) * 18 : n.type === "core" ? 0 : R * (0.55 + ((i * 0.618) % 0.45)) + n.x = Math.cos(ang) * rad * AX + Math.sin(i * 78.233) * 40 + n.y = Math.sin(ang) * rad * AY + Math.cos(i * 37.719) * 40 + i++ + } + const core = byId.get("amico")! + core.x = 0 + core.y = 0 + for (let it = 0; it < 380; it++) { + for (const n of nodes) { + n.fx = 0 + n.fy = 0 + } + repel() + for (const n of nodes) { + const ang = CLUSTER_ANGLE[n.cat] + const ax = n.type === "core" ? 0 : Math.cos(ang) * (n.cat === "agents" ? 100 : 300) * AX + const ay = n.type === "core" ? 0 : Math.sin(ang) * (n.cat === "agents" ? 100 : 300) * AY + n.fx += (ax - n.x) * 0.004 + n.fy += (ay - n.y) * 0.004 + n.fx += -n.x * 0.0008 + n.fy += -n.y * 0.0034 + if (n.type !== "core") { + n.x += Math.max(-6, Math.min(6, n.fx)) + n.y += Math.max(-6, Math.min(6, n.fy)) + } + } + } + // stretch-then-relax: pull the settled layout wide, then let springs + // restore natural local distances inside a vertically-contained envelope + // — locally isotropic structure, globally wide silhouette + for (const n of nodes) if (n.type !== "core") n.x *= 3.1 + for (let it = 0; it < 150; it++) { + for (const n of nodes) { + n.fx = 0 + n.fy = 0 + } + repel() + for (const n of nodes) { + n.fx += -n.x * 0.0004 // gentle horizontal containment + n.fy += -n.y * 0.009 // firm vertical envelope + if (n.type !== "core") { + n.x += Math.max(-6, Math.min(6, n.fx)) + n.y += Math.max(-6, Math.min(6, n.fy)) + } + } + } + // normalize into a unit box we can fit to any viewport; the amico core IS + // the center of the world — everything references it + let minX = 1e9, + maxX = -1e9, + minY = 1e9, + maxY = -1e9 + for (const n of nodes) { + minX = Math.min(minX, n.x) + maxX = Math.max(maxX, n.x) + minY = Math.min(minY, n.y) + maxY = Math.max(maxY, n.y) + } + const span = Math.max(maxX - minX, maxY - minY) + const cx = core.x, + cy = core.y + for (const n of nodes) { + n.x = (n.x - cx) / span + n.y = (n.y - cy) / span + } + } + settle() + + /* ---------- canvas & camera ---------- */ + let W = 0, + H = 0, + DPR = 1, + worldScale = 1 + function resize(width?: number, height?: number) { + DPR = Math.min((typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1) || 1, 2) + W = width ?? canvas.clientWidth ?? 0 + H = height ?? canvas.clientHeight ?? 0 + // junk-proof: a NaN/negative box would poison every projection this frame + // and a zero worldScale divides the camera fit by 0 — fall back instead + if (!Number.isFinite(W) || !Number.isFinite(H) || W < 1 || H < 1) { + W = opts.size?.width ?? 800 + H = opts.size?.height ?? 224 + } + canvas.width = W * DPR + canvas.height = H * DPR + worldScale = Math.min(W, H) * 0.86 + } + resize() + let ro: ResizeObserver | null = null + if (typeof ResizeObserver !== "undefined") { + // the strip breathes 72px ↔ 224px; track the real box, not a boot snapshot + ro = new ResizeObserver(() => resize()) + ro.observe(canvas) + } + // webview/app zoom changes devicePixelRatio WITHOUT changing the CSS box — + // ResizeObserver stays silent and the backing store goes stale (blurry + // graph until an unrelated layout resize). Watch the dppx itself and re-arm + // after every change, since the query is pinned to one exact ratio. + let dprQuery: MediaQueryList | null = null + const onDprChange = () => { + resize() + armDprWatch() + } + function armDprWatch() { + if (typeof matchMedia === "undefined" || typeof devicePixelRatio === "undefined") return + dprQuery?.removeEventListener("change", onDprChange) + dprQuery = matchMedia(`(resolution: ${devicePixelRatio}dppx)`) + dprQuery.addEventListener("change", onDprChange) + } + armDprWatch() + + const cam = { x: 0, y: 0, k: 1, tx: 0, ty: 0, tk: 1 } + function nx(n: BNode) { + return (n.x * worldScale - cam.x) * cam.k + W / 2 + } + function ny(n: BNode) { + return (n.y * worldScale - cam.y) * cam.k + H / 2 + } + + /* ---------- musical clock ---------- */ + const clock = { beat: 0, tempoIx: 2, lastMs: 0 } // allegro — an embedded moment earns a brisker thought + function bpmNow() { + return TEMPI[clock.tempoIx].bpm + } + const dueQueue: { t: number; fn: () => void }[] = [] + function at(beatsFromNow: number, fn: () => void) { + dueQueue.push({ t: clock.beat + beatsFromNow, fn }) + } + function runDue() { + for (let i = dueQueue.length - 1; i >= 0; i--) { + if (dueQueue[i].t <= clock.beat) { + const q = dueQueue.splice(i, 1)[0] + q.fn() + } + } + } + + /* ---------- pulses ---------- */ + const pulses: Pulse[] = [] + function firePulse(e: BEdge, from: BNode, kind: string, durBeats: number, onArrive: (() => void) | null) { + pulses.push({ e, rev: e.s !== from, kind, t0: clock.beat, dur: durBeats, onArrive, trail: [], lastSample: -1 }) + e.liveT = clock.beat + } + function pulsePos(p: Pulse, tNorm: number) { + let q = tNorm + if (p.e.myelin) { + // saltatory: three staccato leaps, dwell between + const seg = Math.min(2, Math.floor(q * 3)) + const local = q * 3 - seg + const eased = local < 0.55 ? local / 0.55 : 1 // fast leap, brief dwell + q = (seg + eased) / 3 + } else if (p.kind === "commit") { + q = q < 0.5 ? 2 * q * q : 1 - Math.pow(-2 * q + 2, 2) / 2 // on-beat depart/arrive + } // scouts stay linear: constant conduction velocity, feel the geometry + const a = p.rev ? p.e.t : p.e.s, + b = p.rev ? p.e.s : p.e.t + return { x: nx(a) + (nx(b) - nx(a)) * q, y: ny(a) + (ny(b) - ny(a)) * q } + } + + /* ---------- atlas ---------- */ + const atlas: Constellation[] = [] // charted constellations, permanent for the session + let plate = 0 + + function claim(node: BNode) { + node.claimed = true + node.flash = 1 + node.ringT = clock.beat + node.labelA = 1 + node.touchedAt = clock.beat + node.uses++ + node.refractUntil = clock.beat + 2 + } + function conduct(node: BNode) { + // pass-through: signal conducts, node does not claim + node.consider = Math.max(node.consider, 0.55) + } + function potentiate(e: BEdge) { + e.passes++ + e.ember = Math.min(0.15 + 0.175 * e.passes, 0.5) + } + + /* ---------- live thought: the host streams the REAL session ---------- + Reads and skill invocations COMMIT — a pulse travels the skeleton and the + node claims its color. Searches/globs CONSIDER — a scout flash that may + leak back to dark. Labels matching skeleton nodes light the real graph; + unknown files graft new nodes beside the current position over dashed + thought-edges. */ + const live = { + cur: "amico", + queue: [] as BNode[], + pumping: false, + recent: new Map(), + sinceChart: [] as BNode[], + pendingChart: null as { title: string; replay: boolean } | null, + glance: null as { id: string; until: number } | null, + } + function maybeChart() { + // charts wait for the queue to drain so a plate never misses its own + // in-flight commits + if (!live.pendingChart || live.pumping || live.queue.length) return + const { title, replay } = live.pendingChart + live.pendingChart = null + const pts = live.sinceChart.slice() + live.sinceChart = [] + if (pts.length >= 2) liveChart(pts, title, replay) + } + function liveChart(pts: BNode[], title: string, instant: boolean) { + // the quiet ceremony: survey lines draw at rest tone, the cartouche names + // the thought "plate N · " — no camera theatrics + plate++ + const con: Constellation = { + pts, + title: title || "thought", + plate: ROMAN[(plate - 1) % ROMAN.length], + progress: instant || reduceMotion ? 1 : 0, + alpha: 1, + born: clock.beat, + } + atlas.push(con) + for (const n of pts) { + n.atlasKeep = true + n.labelA = Math.max(n.labelA, 0.7) + } + if (con.progress >= 1) return + for (const n of pts) n.consider = Math.max(n.consider, 0.4) // collective acknowledgment + const t0 = clock.beat + const dur = Math.min(pts.length * 0.3, 3) + const tickChart = () => { + con.progress = Math.min((clock.beat - t0) / dur, 1) + if (con.progress < 1) at(0.06, tickChart) + } + tickChart() + } + function spreadActivation(n: BNode) { + // activation ripples: a real touch charges its skeleton neighbors — most + // leak back to dark, some flash a sub-threshold scout. One event reads as + // tissue responding, not a lone blip. + if (reduceMotion) return + let fired = 0 + for (const rec of adj.get(n.id) || []) { + if (fired >= 4) break + if (Math.random() < 0.45) continue + const nb = byId.get(rec.to) + if (!nb || nb.id === live.cur) continue + nb.consider = Math.max(nb.consider, 0.45 + Math.random() * 0.35) + if (Math.random() < 0.7) firePulse(rec.e, n, "scout", 0.4 + Math.random() * 0.4, null) + fired++ + } + } + function findLiveNode(label: string): BNode | undefined { + const norm = label.toLowerCase().replace(/\.(md|jl|json|toml)$/, "") + const id = "live-" + norm.replace(/[^a-z0-9]+/g, "-").slice(0, 48) + return byId.get(norm) || byId.get(id) || nodes.find((x) => x.label.toLowerCase() === norm) + } + // marathon sessions: every unique file AND every unique search pattern + // grafts a node + edge, and the render loop is O(nodes+edges) per frame. + // The iframe used to reset this on every theme-flip reload; the native + // engine persists, so cap the graft population — evict the oldest graft + // that is not charted, not pending a plate, and not where amico stands. + const GRAFT_CAP = 300 + function evictGraft() { + let count = 0 + let oldest: BNode | null = null + for (const n of nodes) { + if (!n.id.startsWith("live-")) continue + count++ + if (n.atlasKeep || n.id === live.cur || live.sinceChart.includes(n) || live.queue.includes(n)) continue + if (!oldest || n.touchedAt < oldest.touchedAt) oldest = n + } + if (count < GRAFT_CAP || !oldest) return + const dead = oldest + byId.delete(dead.id) + nodes.splice(nodes.indexOf(dead), 1) + for (let i = edges.length - 1; i >= 0; i--) { + const e = edges[i] + if (e.s !== dead && e.t !== dead) continue + edges.splice(i, 1) + edgeSeen.delete(e.s.id < e.t.id ? e.s.id + "|" + e.t.id : e.t.id + "|" + e.s.id) + } + adj.delete(dead.id) + for (const [k, list] of adj) { + const filtered = list.filter((r) => r.to !== dead.id) + if (filtered.length !== list.length) adj.set(k, filtered) + } + // an in-flight pulse may still reference the orphan; its arrival mutates + // a detached object and every lookup path tolerates the missing id + } + function liveNode(label: string, type?: string): BNode { + const norm = label.toLowerCase().replace(/\.(md|jl|json|toml)$/, "") + const id = "live-" + norm.replace(/[^a-z0-9]+/g, "-").slice(0, 48) + const found = findLiveNode(label) + if (found) return found + evictGraft() + const src = byId.get(live.cur) || byId.get("amico")! + const n = addNode({ id, label: label.slice(0, 28), type: type || "resource" }) + n.half = 4 + const a = Math.random() * Math.PI * 2, + r = 0.07 + Math.random() * 0.05 + n.x = src.x + Math.cos(a) * r + n.y = src.y + Math.sin(a) * r + addEdge(src.id, n.id, "thought") + return n + } + function liveTouch(msg: BrainTouchEvent) { + const label = String(msg.label || "").trim() + if (!label) return + if (msg.replay) { + // a prior turn's step: restore it to the atlas instantly and quietly — + // the session's whole thought-path persists across turns + const n = liveNode(label, msg.type) + if (!msg.consider) { + let path = bfs(live.cur, n.id, 4) + if (!path) { + addEdge(live.cur, n.id, "thought") + path = [live.cur, n.id] + } + for (let i = 0; i + 1 < path.length; i++) { + const rec = (adj.get(path[i]) || []).find((a) => a.to === path[i + 1]) + if (rec) potentiate(rec.e) + } + if (!n.claimed) { + claim(n) + n.flash = 0.25 // an ember of a past firing, not a live one + } else n.uses++ + if (!live.sinceChart.includes(n)) live.sinceChart.push(n) + live.cur = n.id + } else n.consider = Math.max(n.consider, 0.3) + return + } + const key = label + (msg.consider ? "?" : "!") + if ((live.recent.get(key) ?? -9) > clock.beat - 2) return // debounce repeats + live.recent.set(key, clock.beat) + // marathon sessions: entries past the debounce window are dead weight + if (live.recent.size > 512) { + for (const [k, t] of live.recent) if (t <= clock.beat - 2) live.recent.delete(k) + } + const n = liveNode(label, msg.type) + if (msg.consider) { + n.consider = 1 // the flash itself is a fade — fine under reduced motion + const rec = (adj.get(live.cur) || []).find((a) => a.to === n.id) + if (rec && !reduceMotion) firePulse(rec.e, byId.get(live.cur)!, "scout", 0.5, null) + return + } + live.queue.push(n) + livePump() + } + function livePump() { + if (live.pumping) return + const n = live.queue.shift() + if (!n) return + live.pumping = true + let path = bfs(live.cur, n.id, 4) + if (path && path.length === 1) { + // re-touching where we already are: make the repeat visible — a pulse + // from the core out to the node (amico consulting it again) + path = bfs("amico", n.id, 4) + } + if (!path) { + addEdge(live.cur, n.id, "thought") + path = [live.cur, n.id] + } + if (reduceMotion) { + // no traveling pulses — the path still potentiates, fades only + for (let i = 0; i + 1 < path.length; i++) { + const rec = (adj.get(path[i]) || []).find((a) => a.to === path[i + 1]) + if (rec) potentiate(rec.e) + } + if (n.refractUntil > clock.beat && n.claimed) { + n.ringT = clock.beat + n.uses++ + } else claim(n) + if (!live.sinceChart.includes(n)) live.sinceChart.push(n) + live.cur = n.id + live.pumping = false + at(0.25, () => { + livePump() + maybeChart() + }) + return + } + const done = () => { + if (n.refractUntil > clock.beat && n.claimed) { + n.ringT = clock.beat + n.uses++ + } else claim(n) + if (!live.sinceChart.includes(n)) live.sinceChart.push(n) + spreadActivation(n) + live.cur = n.id + live.pumping = false + at(0.25, () => { + livePump() + maybeChart() + }) + } + const hop = (i: number) => { + if (i + 1 >= path.length) return done() + const rec = (adj.get(path[i]) || []).find((a) => a.to === path[i + 1]) + if (!rec) return done() + firePulse(rec.e, byId.get(path[i])!, "commit", rec.e.myelin ? 0.5 : 1, () => { + potentiate(rec.e) + if (i + 2 >= path.length) done() + else { + conduct(byId.get(path[i + 1])!) + hop(i + 1) + } + }) + } + hop(0) + } + + /* ---------- ambient: tacet, not silence ---------- */ + let nextTwinkle = 2, + nextGhost = 8 + function ambient() { + if (reduceMotion) return + if (clock.beat > nextTwinkle) { + nextTwinkle = clock.beat + 1.5 + Math.random() * 2.5 + const n = nodes[(Math.random() * nodes.length) | 0] + n.consider = Math.max(n.consider, 0.3) // scintillation + } + if (clock.beat > nextGhost) { + nextGhost = clock.beat + 4 + Math.random() * 4 + const e = edges[(Math.random() * edges.length) | 0] + if (!e.ghost) firePulse(e, e.s, "ghost", 1.5, null) + } + } + + /* ---------- render ---------- */ + let halted = false + let destroyed = false + let rafId = 0 + let unfurl = reduceMotion ? 1 : 0 + function tick(nowMs: number) { + if (halted || !ctx) return + if (!Number.isFinite(nowMs)) return // a NaN timestamp would poison clock.beat permanently + try { + drawFrame(nowMs) + } catch (err) { + // a corrupt frame must not spin the rAF chain half-rendered forever — + // halt visibly (resume() re-arms after e.g. a session switch) + halted = true + console.error("[amico-brain] halted on render error:", err) + return + } + // re-check halted: a dueQueue callback may have paused us mid-frame — and + // track the id so pause() can cancel an already-scheduled frame (otherwise + // pause→resume inside one frame breeds parallel rAF chains) + if (!halted && animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + } + function drawFrame(nowMs: number) { + if (!ctx) return + const dt = Math.min(nowMs - (clock.lastMs || nowMs), 50) + clock.lastMs = nowMs + clock.beat += (dt / 60000) * bpmNow() + runDue() + ambient() + if (unfurl < 1) unfurl = Math.min(unfurl + dt / 1400, 1) + const uf = 1 - Math.pow(1 - unfurl, 3) + + // camera + if (H < 120) { + // condensed = a CLOSE-UP on where amico is (never a miniature map); + // glances steer the eye since most nodes live outside this window + const glanced = live.glance && live.glance.until > clock.beat ? byId.get(live.glance.id) : null + const cur = glanced || byId.get(live.cur) + if (cur) { + cam.tx = cur.x * worldScale + cam.ty = cur.y * worldScale + cam.tk = Math.min(8, Math.max(1.05, 320 / worldScale)) + } + } else { + // expanded = the WHOLE network with the AMICO CORE dead center (the + // core is the world origin); zoom fits the farthest node from it so + // nothing clips. Recomputed per frame so grafts never fall outside. + let hw = 0.01, + hh = 0.01 + for (const n of nodes) { + const ax = Math.abs(n.x), + ay = Math.abs(n.y) + if (ax > hw) hw = ax + if (ay > hh) hh = ay + } + cam.tx = 0 + cam.ty = 0 + cam.tk = Math.min((W - 28) / (2 * hw * worldScale), (H - 14) / (2 * hh * worldScale)) + } + // a hover glance deserves a brisk look, not a two-second pan + const camEase = live.glance && live.glance.until > clock.beat ? 0.14 : 0.035 + cam.x += (cam.tx - cam.x) * camEase + cam.y += (cam.ty - cam.y) * camEase + cam.k += (cam.tk - cam.k) * camEase + + ctx.setTransform(DPR, 0, 0, DPR, 0, 0) + ctx.clearRect(0, 0, W, H) // transparent ground — the host surface shows through + + const breathe = reduceMotion ? 0 : Math.sin(nowMs / 4800) * 0.04 // 0.1 Hz field respiration + + // ---- charted constellations (the atlas — survey lines at rest tone) + for (const con of atlas) { + const total = con.pts.length - 1 + const upto = con.progress * total + ctx.lineWidth = 1 + for (let i = 0; i < total; i++) { + const a = con.pts[i], + b = con.pts[i + 1] + const segT = Math.max(Math.min(upto - i, 1), 0) + if (segT <= 0) break + ctx.strokeStyle = rgba(css.fg, 0.16 * con.alpha) + ctx.beginPath() + ctx.moveTo(nx(a), ny(a)) + ctx.lineTo(nx(a) + (nx(b) - nx(a)) * segT, ny(a) + (ny(b) - ny(a)) * segT) + ctx.stroke() + } + } + + // ---- edges + for (const e of edges) { + const x1 = nx(e.s), + y1 = ny(e.s), + x2 = nx(e.t), + y2 = ny(e.t) + if ((x1 < -40 && x2 < -40) || (x1 > W + 40 && x2 > W + 40) || (y1 < -40 && y2 < -40) || (y1 > H + 40 && y2 > H + 40)) + continue + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + let width = 1 + if (e.ember > 0) { + if (e.passes >= 3) width = 2 + ctx.strokeStyle = rgba(css.ember, e.ember * (0.55 + breathe)) + } else { + // skeleton: the embed shows ALL possible pathways clearly, not as a + // whisper — ghosts too, just dimmer + ctx.strokeStyle = rgba(css.fg, 0.2) + if (e.ghost) ctx.globalAlpha = 0.55 + } + ctx.lineWidth = width + if (e.ghost) ctx.setLineDash([3, 4]) + else if (e.myelin) ctx.setLineDash([7, 2]) // segmented sheath: nodes of Ranvier + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 + } + + // ---- pulses (trails are the last six 32nd positions) + for (let i = pulses.length - 1; i >= 0; i--) { + const p = pulses[i] + const tN = (clock.beat - p.t0) / p.dur + if (tN >= 1) { + pulses.splice(i, 1) + if (p.onArrive) p.onArrive() + continue + } + const pos = pulsePos(p, Math.max(tN, 0)) + const dim = p.kind === "scout" ? 0.5 : p.kind === "ghost" ? 0.14 : 1 + const size = p.kind === "replay" ? 5 : p.kind === "scout" ? 3 : 4 + // trail: sample on the 32nd grid + if (p.lastSample < 0 || clock.beat - p.lastSample >= 0.125) { + p.lastSample = clock.beat + p.trail.unshift({ x: pos.x, y: pos.y }) + if (p.trail.length > 6) p.trail.pop() + } + p.trail.forEach((tp, k) => { + const a = dim * 0.4 * (1 - k / 6) + const s = size * (1 - k / 8) + ctx.fillStyle = rgba(css.thought, a) + ctx.beginPath() + ctx.arc(tp.x, tp.y, s / 2, 0, Math.PI * 2) + ctx.fill() + }) + if (p.kind !== "ghost" && bloom) { + ctx.globalAlpha = 0.5 * dim + ctx.drawImage(bloom, pos.x - 16, pos.y - 16, 32, 32) + ctx.globalAlpha = 1 + } + ctx.fillStyle = rgba(css.thought, Math.min(dim + 0.15, 1)) + ctx.beginPath() + ctx.arc(pos.x, pos.y, size / 2, 0, Math.PI * 2) + ctx.fill() + } + + // ---- nodes: the embed is monochrome + brand yellow — every claimed node + // wears the thought color (the categorical palette belonged to the + // standalone demo, where a legend explained it) + ctx.textBaseline = "middle" + for (const n of nodes) { + const x = nx(n), + y = ny(n) + if (x < -60 || x > W + 60 || y < -60 || y > H + 60) continue + const catColor = css.thought + let half = n.half * (0.6 + 0.4 * uf) * (n.uses > 1 ? 1 + Math.min(n.uses, 4) * 0.08 : 1) + if (n.flash > 0) { + half *= 1 + n.flash * 0.35 + n.flash = Math.max(n.flash - dt / 420, 0) + } + if (n.consider > 0) n.consider = Math.max(n.consider - dt / 2600, 0) + const isCore = n.type === "core" + const coreBr = isCore && !reduceMotion ? (Math.sin(nowMs / 2500 + 1) * 0.5 + 0.5) * 0.35 : 0 + + // glow budget: flash only + if (n.flash > 0.02 && bloom) { + ctx.globalAlpha = n.flash + ctx.drawImage(bloom, x - half * 4, y - half * 4, half * 8, half * 8) + ctx.globalAlpha = 1 + } + + ctx.beginPath() + ctx.arc(x, y, half, 0, Math.PI * 2) + + if (n.flash > 0.55) { + ctx.fillStyle = css.thought // all-or-nothing: the attack is pure accent + ctx.fill() + } else if (n.claimed) { + const sustain = n.atlasKeep ? 0.55 : 0.85 + ctx.fillStyle = rgba(catColor, sustain * (n.flash > 0 ? 1 : 0.9) + coreBr) + ctx.fill() + ctx.strokeStyle = rgba(catColor, 0.9) + ctx.lineWidth = 1 + ctx.stroke() + } else { + // untraversed tissue recedes — the traversed path owns the contrast; + // anything live (considered, the core) stays bright + const chargeGlow = Math.max(n.consider, isCore ? 0.5 + coreBr : 0) + const restDim = chargeGlow <= 0.05 ? 0.42 : 1 + ctx.globalAlpha = restDim + ctx.fillStyle = css.nodeFill + ctx.fill() + ctx.strokeStyle = chargeGlow > 0 ? rgba(css.thought, 0.25 + chargeGlow * 0.6) : css.nodeBorder + ctx.lineWidth = 1 + ctx.stroke() + ctx.globalAlpha = 1 + } + if (n.cat === "agents") { + // agents wear a double border — same circle, framed + ctx.beginPath() + ctx.arc(x, y, half + 2.5, 0, Math.PI * 2) + ctx.strokeStyle = rgba(catColor, n.claimed ? 0.8 : 0.16) + ctx.lineWidth = 1 + ctx.stroke() + } + // survey ring: an expanding circle, emitted on fire + if (n.ringT >= 0) { + const rt = (clock.beat - n.ringT) / 0.7 + if (rt < 1) { + const rh = half + rt * half * 2.6 + ctx.beginPath() + ctx.arc(x, y, rh, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, (1 - rt) * 0.8) + ctx.lineWidth = 1 + ctx.stroke() + } else n.ringT = -1 + } + // labels: LOD — touched or considered; halo for legibility + let la = n.labelA + if (n.consider > 0.4) la = Math.max(la, 0.5) + if (n.labelA > 0 && !n.atlasKeep) n.labelA = Math.max(n.labelA - dt / 9000, 0) + if (n.atlasKeep) n.labelA = Math.max(n.labelA - dt / 12000, 0.5) + if (la > 0.03) { + ctx.font = "10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace" + const tw = ctx.measureText(n.label).width + const lx = x + half + 6, + ly = y + ctx.fillStyle = css.labelHalo + ctx.fillRect(lx - 2, ly - 7, tw + 4, 14) + ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * (n.atlasKeep ? 0.8 : 0.65)) + ctx.fillText(n.label, lx, ly) + } + } + + // where-we-are cursor: an always-visible ring on the current node — the + // flare fades and glows are faint on the light surface, so position gets + // its own explicit marker in the thought color (legible in both themes) + const curNode = byId.get(live.cur) + if (curNode) { + const cxp = nx(curNode), + cyp = ny(curNode) + const rr = curNode.half + 5 + (reduceMotion ? 0 : Math.sin(nowMs / 600) * 1.5) + ctx.beginPath() + ctx.arc(cxp, cyp, rr, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, 0.85) + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.beginPath() + ctx.arc(cxp, cyp, rr + 3.5, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, 0.3) + ctx.lineWidth = 1 + ctx.stroke() + curNode.labelA = 1 // the name of where we are stays readable + } + } + + // waking — the thought begins here + const core = byId.get("amico")! + core.flash = 1 + core.ringT = clock.beat + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + + return { + touch: (ev) => { + if (!destroyed) liveTouch(ev) + }, + chart: (title, replay) => { + if (destroyed) return + live.pendingChart = { title: String(title || ""), replay: !!replay } + maybeChart() + }, + highlight: (label) => { + if (destroyed) return + // a glance from the log: ring the node AND turn the camera to look at + // it — in the collapsed close-up most nodes are outside the window, so + // an unsteered ring is invisible + const n = findLiveNode(String(label || "")) + if (n) { + n.ringT = clock.beat + n.consider = Math.max(n.consider, 0.9) + n.labelA = 1 + live.glance = { id: n.id, until: clock.beat + 3.5 } + } + }, + setTheme: (next) => { + if (next !== "dark" && next !== "light") return + scheme = next + css = PALETTES[scheme] + buildSprites() // the atlas, claims, and clock all persist — only ink changes + }, + resize: (width, height) => resize(width, height), + tick, + pause: () => { + halted = true // folded away — stop burning frames + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + }, + resume: () => { + if (destroyed || !halted) return + halted = false + clock.lastMs = 0 // dt is capped, so the gap doesn't lurch the clock + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + }, + destroy: () => { + destroyed = true + halted = true + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + ro?.disconnect() + motionQuery?.removeEventListener("change", onMotionChange) + dprQuery?.removeEventListener("change", onDprChange) + }, + stats: () => ({ + scheme, + nodes: nodes.length, + edges: edges.length, + claimed: nodes.filter((n) => n.claimed).length, + atlas: atlas.length, + queued: live.queue.length, + cur: live.cur, + }), + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/brain-ref.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-ref.ts new file mode 100644 index 00000000..145f098f --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/brain-ref.ts @@ -0,0 +1,84 @@ +// amicode: THE single mapping from a tool call to its brain-graph reference — +// the node label the brain page uses, the brain node type, and whether the +// touch is a commit (pulse travels, node claims) or a consider (scout flash +// that may leak back to dark). Consumed by the chat's hover glances +// (message-part) and the brain strip's touch stream (packages/app +// brain-strip.tsx). Labels must match the brain page's node ids — skills map +// by bare name; unknown files graft new nodes. +export type AmicoBrainRef = { + label: string + type: string + consider: boolean + /** full file path when the touch IS a file — lets the context tree open it */ + path?: string +} + +export function amicoBrainRef(tool: string, input: Record = {}): AmicoBrainRef | undefined { + const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined) + const base = (p: string) => p.split("/").pop() ?? p + switch (tool) { + case "read": + case "edit": + case "write": + case "apply_patch": { + const fp = str(input.filePath) ?? str(input.path) + if (!fp) return undefined + const label = base(fp) + const type = /\.(md|txt)$/i.test(label) ? "note" : /\.jl$/i.test(label) ? "package" : "resource" + return { label, type, consider: false, path: fp } + } + case "grep": + case "glob": { + const pattern = str(input.pattern) + return pattern ? { label: pattern.slice(0, 24), type: "resource", consider: true } : undefined + } + case "list": { + const p = str(input.path) + return { label: p ? base(p) : "files", type: "resource", consider: true } + } + case "websearch": { + const q = str(input.query) + return q ? { label: q.slice(0, 24), type: "resource", consider: true } : undefined + } + case "webfetch": { + const url = str(input.url) + if (!url) return undefined + try { + return { label: new URL(url).hostname, type: "resource", consider: false } + } catch { + return undefined + } + } + case "task": + return { label: str(input.subagent_type) ?? "agent", type: "agent", consider: false } + case "skill": { + const name = str(input.name) + return name ? { label: name, type: "skill", consider: false } : undefined + } + case "bash": { + const cmd = str(input.command) ?? "" + const desc = str(input.description) + if (/amico-run|julia/.test(cmd)) return { label: desc?.slice(0, 28) ?? "solve run", type: "experiment", consider: false } + return desc ? { label: desc.slice(0, 28), type: "resource", consider: false } : undefined + } + case "question": + // the interview asking the user IS the pulse-designer score at work + return { label: "pulse-designer", type: "skill", consider: false } + default: + // amicode plugin tools (amicode_problem, amicode_pick_system, …) are the + // product's own verbs — map each by its most identifying argument + if (tool.startsWith("amicode_")) { + const label = + str(input.name) ?? str(input.system) ?? str(input.family) ?? str(input.id) ?? tool.slice(8).replace(/_/g, " ") + return { label: label.slice(0, 28), type: "resource", consider: false } + } + return undefined // todowrite, MCP — not part of the visible thought + } +} + +/** Hovering a tool row glances at its node on the map — decoupled via a DOM + * event so this package stays ignorant of the brain host. */ +export function emitAmicoBrainHover(ref: AmicoBrainRef | undefined) { + if (!ref || typeof window === "undefined") return + window.dispatchEvent(new CustomEvent("amicode:brain-hover", { detail: { label: ref.label } })) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/bridge.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/bridge.ts new file mode 100644 index 00000000..8528c939 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/bridge.ts @@ -0,0 +1,9 @@ +// AMICODE: imperative side of the iframe→extension bridge for OPENING FILES. +// Anchors handle chat-markdown links declaratively (session-ui markdown.tsx +// posts on click); chips and tool cards outside markdown call this instead. +// No-op outside the framed VS Code webview. The host (extension chat_bridge.ts) +// validates absolute + exists on every message, so a stale path fails quiet. +export function openFileInEditor(absPath: string): void { + if (window.parent === window) return + window.parent.postMessage({ source: "amicode", kind: "open-file", url: "file://" + encodeURI(absPath) }, "*") +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/calibration-view.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/calibration-view.tsx new file mode 100644 index 00000000..c75a7160 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/calibration-view.tsx @@ -0,0 +1,54 @@ +import { Show } from "solid-js" +import type { CalibrationVerdict } from "./problem" + +// AMICODE Calibration hero (ring-2 verdict-first redesign). No calibration +// entity or schema exists in Phase 0 (per amico-catalog: `calibrated` lands in +// a later phase); this renders the PROPOSED freshness verdict when a +// calibration entity with a timestamp/source arrives (design-leads-schema- +// follows). Until then calibrationVerdict() returns null → the dialog shows the +// raw field table / empty state instead. Styling in ./amicode.css. + +export function CalibrationView(props: { verdict: CalibrationVerdict }) { + const v = () => props.verdict + return ( +
+ +
+ + {(f) => ( + + {f()} + + )} + + + {(a) => ( + + calibrated {a()} + + )} + +
+
+ + {(src) => ( + <> +
Source
+
+
+
+ source +
+
{src()}
+
+
+ + )} +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/card.stories.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/card.stories.tsx new file mode 100644 index 00000000..924ff701 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/card.stories.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck +// Visual check for the receipt-run collapse (../receipt-runs.ts): a run of +// consecutive amicode_* receipts sharing (problem, entity, action) renders as +// ONE card with a count instead of N identical-looking ones. Real user report: +// "these repeated amico cards add a lot of clutter can we avoid this?" — see +// message-part.tsx's collapseAmicodeGroups for the wiring; this story exercises +// AmicodeToolCard directly (the count prop it now accepts) rather than the full +// message list, so the collapsed card's rendering is reviewable in isolation. +import { AmicodeToolCard } from "./card" + +export default { + title: "Amicode/ToolCard", + id: "amicode-tool-card", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### Collapsed receipt runs + +Four \`amicode_*\` calls that update the same entity via the same action used to +render four visually-identical cards. A run of consecutive receipts sharing +(problem, entity, action) now collapses into one card carrying a \`×N\` count, +opening the latest (highest-seq) member. A run of one renders exactly as +before — no count shown. A differing action, entity, or problem never +collapses, so a state change is never hidden.`, + }, + }, + }, +} + +const sentinel = (over: Record) => + `AMICODE_DIFF ${JSON.stringify({ problem: "x-gate", entity: "recommend", action: "proposed", seq: 1, diff: {}, ...over })}` + +const Row = (props: { label: string; children: any }) => ( +
+ + {props.label} + +
+ {props.children} +
+
+) + +// A run of 4 identical (problem, entity, action) receipts — what the bug +// report showed as four stacked cards — now collapses to one, count 4, +// opening seq 4 (the latest). +export const CollapsedRunOfFour = () => ( + + + +) + +// A run of one renders exactly as it always has — no count, no markup change. +export const SingleReceipt = () => ( + + + +) + +// Two adjacent runs whose action differs never merge with each other — each +// renders as its own card (here, each itself a collapsed run of 2). +export const AdjacentRunsDifferentActions = () => ( + + + + +) + +// Side-by-side comparison, matching the report's stacked-card shape but with +// the fix applied: the repeated "Recommend" run collapses; the differently- +// actioned "Formulation" card beside it (a run of 1) is untouched. +export const StackedTranscriptExample = () => ( +
+ + +
+) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx new file mode 100644 index 00000000..c8fc6072 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx @@ -0,0 +1,442 @@ +import { For, Match, Show, Switch, createMemo } from "solid-js" +import { openFileInEditor } from "./bridge" +import { amicodeStage } from "./stage" +import { parseAskInput } from "./ask" +import { AmicodeAskCard } from "./ask-card" +import { parseApprovalInput } from "./approval" +import { AmicodeApprovalCard } from "./approval-card" +import { parseDiffSentinel, receiptParts, INLINE_KINDS } from "./receipt" +import { receiptIsCurrent } from "./receipt-currency" +import { systemReceiptPieces, formulationReceiptPieces } from "./facets" +import { compositeChip, chipText } from "./problem" +import { AmicoMark } from "./spinner" +import { + openAmicodeEntity, + amicodeProblemView, + amicodeRunStatus, + amicodeDraftPrompt, + amicodeRefetchProblem, + amicodeEntityLabels, +} from "./ui-bridge" +import { AmicodeEntityView } from "./entity-view" +import { RunWindow } from "./run-window" +import { parseWidgetSentinel } from "./widget-preview" +import { WidgetPreviewCard } from "./widget-preview-card" + +// spec C: when an amicode_solve recorded a run_dir, its sentinel carries the +// path (well under the 120-char truncation cap) — extract lab/run_id from the +// last two segments so the part renders a live run window instead of a chip. +function runRefFromOutput(output: unknown): { run: string; lab?: string } | undefined { + const sentinel = parseDiffSentinel(output) + if (!sentinel || sentinel.entity !== "run") return undefined + for (const [key, entry] of Object.entries(sentinel.diff)) { + if (!/run_dir$/.test(key)) continue + const value = entry.to + if (typeof value !== "string" || value.trim() === "" || value.endsWith("…")) continue + const parts = value.replace(/\/+$/, "").split("/") + const run = parts[parts.length - 1] + const lab = parts[parts.length - 2] + if (run) return { run, lab } + } + return undefined +} + +// AMICODE Layer-2 renderer slot: one-line DIFF RECEIPT for amicode_* tool-call +// parts (parsed from the AMICODE_DIFF sentinel — spec B), falling back to the +// legacy status chip when no sentinel parses (old sessions keep rendering); +// amicode_ask still renders the interactive question card. The raw tool return +// is agent-directed text and is deliberately NOT rendered — durable state +// lives in the problem rail (./entity-rail.tsx). Receipt click opens the +// entity view through the ui-bridge (no-op until the rail registers it). +// +// The card carries four states, differentiated in FORM, not just words: the +// H-mark is the spinner while working (running), settles under a green check +// when done with no diff, becomes a structured diff receipt when there is one, +// and turns its rail red on failure. It is a real + + ) +} + +// AMICODE: a skill activation, wearing the Amico chip. Amicode's skills ARE Amico's — its +// repertoire — so activating one is Amico acting, and it earns the same chip the domain +// receipts wear. Reads " " like every other chip: the label names the kind, +// the detail names the skill. +// +// Inert by default; when the mount site passes the skill's `path` (the skill tool result +// carries its base dir in metadata) the chip becomes a + + ) +} + +// In-transcript entity view (Kate 2026-07-24): the receipt renders the full +// verdict-first entity view inline — no click, no modal. Data comes from the +// rail via the ui bridge (undefined until the rail mounts, exactly like the run +// window). A run with a live run_dir still prefers the richer RunWindow (matched +// earlier in the Switch), so a run only reaches the inline path as its verdict +// when there's no live window. +// +// AMENDED (spec-20260727-164748 §9.4): kind membership is necessary but NO LONGER +// SUFFICIENT. The inline view reads the LIVE problem view, so when every receipt +// of a kind took this path, N updates painted N identical copies of the present — +// and because the transcript fetches the globally-active problem with no ?slug=, +// switching problems mid-chat retroactively rewrote earlier receipts. Only the +// CURRENT receipt for the ACTIVE problem may render live (./receipt-currency.ts); +// the rest fall through to the Chip, which renders from their own captured diff. +function InlineEntityView(props: { kind: string; seq?: number }) { + const labels = createMemo(() => amicodeEntityLabels()) + return ( +
+ amicodeDraftPrompt(text)} + onRetry={() => amicodeRefetchProblem()} + retryLabel={labels().retry} + editLabel={labels().edit} + /> +
+ ) +} + +export function AmicodeToolCard(props: { + tool: string + status?: string + input?: Record + output?: string // passed by message-part.tsx's Dynamic (already wired) + messageID?: string + sessionID?: string + // amicode: set by message-part.tsx when this part is the surviving (latest) + // member of a collapsed run of ≥2 identical (problem, entity, action) + // receipts (../receipt-runs.ts). Only the Chip fallback below reads it — + // every collapse-eligible receipt renders through Chip by construction + // (receipt-runs.ts excludes anything that could route elsewhere), so a + // count reaching ask/approval/run/widget/inline-entity render is not + // expected; those paths simply ignore the prop. + count?: number +}) { + const ask = createMemo(() => (props.tool === "amicode_ask" ? parseAskInput(props.input) : undefined)) + // §9.5: the warrant card, same tool-input pattern as the ask card. + const approval = createMemo(() => + props.tool === "amicode_request_approval" ? parseApprovalInput(props.input) : undefined, + ) + const runRef = createMemo(() => (props.tool === "amicode_solve" ? runRefFromOutput(props.output) : undefined)) + const authored = createMemo(() => + props.tool === "amicode_author_widget" ? parseWidgetSentinel(props.output) : undefined, + ) + // Any entity receipt surfaces its full view inline. We gate only on having a + // problem view (the rail is mounted) — NOT on seq or on entities[kind]: a + // record+update lands as two events but one receipt, and the view can lag a + // beat, so tighter gates hid the view entirely. The entity view renders + // whatever the live view holds for that kind. Runs with a live run_dir never + // reach here (RunWindow matches first); other runs show their verdict. + const inlineEntity = createMemo(() => { + if (props.status !== "completed") return undefined + const sentinel = parseDiffSentinel(props.output) + if (!sentinel || !INLINE_KINDS.has(sentinel.entity)) return undefined + const view = amicodeProblemView() + if (!view) return undefined + // §9.4: superseded receipts, and receipts captured against a different + // problem, render from their captured diff via the Chip instead of the live + // view. Deliberately permissive when currency is ambiguous (a kind with no + // events yet stays live) — the original note here warns that tighter gates + // hid the view entirely. + if (!receiptIsCurrent({ problem: sentinel.problem, entity: sentinel.entity, seq: sentinel.seq }, view)) + return undefined + return { kind: sentinel.entity, seq: sentinel.seq } + }) + + return ( + }> + + {(value) => } + + {(req) => } + {(ref) => } + {(preview) => } + {(e) => } + + ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx new file mode 100644 index 00000000..45c77779 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx @@ -0,0 +1,45 @@ +// AMICODE: ConnectionIcon — 18px logo + 6px state-dot badge (issue #327) +import { Show } from "solid-js" +import { cardModel } from "./connections" +import type { ConnectionView } from "./connections" + +export function ConnectionIcon(props: { conn: ConnectionView }) { + const model = () => cardModel(props.conn) + const tone = () => model().tone + + const dotClass = () => { + switch (tone()) { + case "success": + return "bg-icon-success-base" + case "critical": + return "bg-icon-critical-base" + case "warning": + return "bg-icon-warning-base" + case "pending": + return "bg-icon-warning-base animate-pulse" + default: + return "bg-border-weak-base" + } + } + + const isSvg = () => Boolean(props.conn.icon && props.conn.icon.trim().startsWith(" { + if (props.conn.icon && !isSvg()) return props.conn.icon.charAt(0).toUpperCase() + const name = props.conn.name ?? props.conn.id + return name.charAt(0).toUpperCase() + } + + return ( +
+
+ {letter()}}> + + +
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connection-picker.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-picker.tsx new file mode 100644 index 00000000..94ece627 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-picker.tsx @@ -0,0 +1,216 @@ +// AMICODE: ConnectionPicker — Add-flow picker list + inline forms (issue #327) +import { createSignal, For, Show } from "solid-js" +import { Button } from "../components/button" +import { customConnectionPayload, tokenOnlySubmitPayload } from "./connections" + +export type CatalogEntry = { id: string; name: string; icon: string; authShape: string } + +export function ConnectionPicker(props: { + catalog: CatalogEntry[] + onAddCustom: (payload: { name: string; token: string; url?: string }) => Promise + onSubmitToken: (id: string, token: string) => Promise + onStartBrowser?: (id: string) => void + onClose: () => void +}) { + const [picked, setPicked] = createSignal(undefined) + const [customName, setCustomName] = createSignal("") + const [customToken, setCustomToken] = createSignal("") + const [customUrl, setCustomUrl] = createSignal("") + const [token, setToken] = createSignal("") + + const pickedEntry = () => props.catalog.find((e) => e.id === picked()) + // Google now supports both token and browser — treat it as token in the picker + // so users can paste a token like Claude/Slack/GitHub, with browser as alternative + const isBrowserEntry = () => { + const entry = pickedEntry() + if (!entry) return false + if (entry.id === "google" || entry.id === "google-drive") return false + return entry.authShape === "browser" + } + const isGoogleEntry = () => { + const entry = pickedEntry() + return entry?.id === "google" || entry?.id === "google-drive" + } + + const submitCustom = async (e: Event) => { + e.preventDefault() + const payload = customConnectionPayload(customName(), customToken(), customUrl()) + if (!payload) return + await props.onAddCustom(payload) + setCustomName("") + setCustomToken("") + setCustomUrl("") + setPicked(undefined) + } + + const submitToken = async (e: Event) => { + e.preventDefault() + const id = picked() + if (!id || id === "custom") return + const payload = tokenOnlySubmitPayload(id, token()) + if (!payload) return + await props.onSubmitToken(id, token()) + setToken("") + setPicked(undefined) + } + + const startBrowser = (e: Event) => { + e.preventDefault() + const id = picked() + if (!id) return + if (props.onStartBrowser) props.onStartBrowser(id) + else { + // Fallback: if no browser handler, treat as token flow for backwards compat + } + setPicked(undefined) + } + + const isCustom = () => picked() === "custom" + const isBuiltIn = () => picked() !== undefined && picked() !== "custom" + + return ( +
+
+ Add connection + +
+ + +
+ + + {(entry) => ( + + )} + + + All built-ins already added + +
+
+ + +
+ setCustomName(e.currentTarget.value)} + class="amc-input amc-input--compact" + /> + setCustomToken(e.currentTarget.value)} + class="amc-input amc-input--compact" + /> + setCustomUrl(e.currentTarget.value)} + class="amc-input amc-input--compact" + /> +
+ + +
+
+
+ + + + {picked()} + setToken(e.currentTarget.value)} + class="amc-input amc-input--compact" + /> +
+ + +
+ + }> + {/* Google: token (paste like Claude) + browser alternative */} +
+ {picked()} + setToken(e.currentTarget.value)} + class="amc-input amc-input--compact" + /> +
+ + +
+
— or —
+ +
+
+ }> +
+ {picked()} + Sign in with your browser to connect {pickedEntry()?.name ?? picked()}. +
+ + +
+
+
+ +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections-paths.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-paths.test.ts new file mode 100644 index 00000000..0e8440df --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-paths.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test" +import { + cardModel, + chooseProjectPayload, + connectionAuthMethods, + methodEntryKind, + parseConnectionsResponse, + pasqalTokenSubmitPayload, + startAuthPayload, + stateCopy, + type ConnectionCardState, + type ConnectionStateLabels, + type ConnectionView, +} from "./connections" + +// Auth-path scaffold (amicode#194): the wire MAY advertise auth_methods and +// emit three mid-flow states (waiting-browser / waiting-code / choose-project). +// Contract under test: (1) absent scaffold fields leave today's cards +// byte-identical — the mechanism decision stays open server-side; (2) every +// scaffold affordance is gated on what the wire actually advertised, so a +// stale UI can never start a flow the server no longer offers. + +function parseOne(entry: Record): ConnectionView { + const view = parseConnectionsResponse({ ok: true, connections: [entry] }) + expect(view.ok).toBe(true) + return view.connections[0] +} + +const pasqalWaitingCode = { + id: "pasqal-cloud", + state: "waiting-code", + auth_methods: ["browser", "device-code", "credentials"], + user_code: "HRMQ-4321", + verification_url: "https://pasqal.cloud/activate", + code_expires_at: "2026-07-21T21:00:00.000Z", +} + +describe("scaffold wire parsing", () => { + test("auth_methods keeps known methods in wire order, drops unknowns and dupes", () => { + const conn = parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: ["browser", "quantum-telepathy", "token", "browser"] }) + expect(conn.authMethods).toEqual(["browser", "token"]) + }) + + test("auth_methods off-shape or empty is absent, never a crash", () => { + expect(parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: "browser" }).authMethods).toBeUndefined() + expect(parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: [] }).authMethods).toBeUndefined() + expect(parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: [42, {}] }).authMethods).toBeUndefined() + }) + + test("device-code metadata parses; a garbage expiry is absent, not an Invalid Date", () => { + const conn = parseOne(pasqalWaitingCode) + expect(conn.state).toBe("waiting-code") + expect(conn.userCode).toBe("HRMQ-4321") + expect(conn.verificationUrl).toBe("https://pasqal.cloud/activate") + expect(conn.codeExpiresAt).toContain("2026") + expect(parseOne({ ...pasqalWaitingCode, code_expires_at: "not-a-time" }).codeExpiresAt).toBeUndefined() + }) + + test("projects keep {id, name} with name falling back to id; id-less entries drop", () => { + const conn = parseOne({ + id: "pasqal-cloud", + state: "choose-project", + projects: [{ id: "p1", name: "MIS ladder" }, { id: "p2" }, { name: "orphan" }, "junk"], + }) + expect(conn.projects).toEqual([ + { id: "p1", name: "MIS ladder" }, + { id: "p2", name: "p2" }, + ]) + }) + + test("REGRESSION: an entry without scaffold fields parses exactly as before", () => { + const conn = parseOne({ id: "pasqal-cloud", state: "needs-key", validated_at: "2026-07-19T10:00:00.000Z" }) + expect(conn.authMethods).toBeUndefined() + expect(conn.userCode).toBeUndefined() + expect(conn.verificationUrl).toBeUndefined() + expect(conn.codeExpiresAt).toBeUndefined() + expect(conn.projects).toBeUndefined() + const model = cardModel(conn) + expect(model.showWaiting).toBe(false) + expect(model.showUserCode).toBe(false) + expect(model.showProjectPicker).toBe(false) + expect(model.showForm).toBe(true) + }) +}) + +describe("mid-flow card models", () => { + test("waiting-browser: passive and cancellable — no form, no actions", () => { + const model = cardModel(parseOne({ id: "pasqal-cloud", state: "waiting-browser" })) + expect(model.tone).toBe("pending") + expect(model.showForm).toBe(false) + expect(model.showActions).toBe(false) + expect(model.showWaiting).toBe(true) + expect(model.showUserCode).toBe(false) + }) + + test("waiting-code shows the code block only when the wire sent a code", () => { + expect(cardModel(parseOne(pasqalWaitingCode)).showUserCode).toBe(true) + const codeless = cardModel(parseOne({ id: "pasqal-cloud", state: "waiting-code" })) + expect(codeless.showUserCode).toBe(false) + expect(codeless.showWaiting).toBe(true) + }) + + test("choose-project renders the picker with projects, else keeps the cancel exit open", () => { + const withProjects = cardModel( + parseOne({ id: "pasqal-cloud", state: "choose-project", identity: "kate@harmoniqs.co", projects: [{ id: "p1" }] }), + ) + expect(withProjects.showProjectPicker).toBe(true) + expect(withProjects.showWaiting).toBe(false) + expect(withProjects.showIdentity).toBe(true) + expect(withProjects.showLoading).toBe(true) // spinner in the status slot through the picker, not a pending dot + const empty = cardModel(parseOne({ id: "pasqal-cloud", state: "choose-project" })) + expect(empty.showProjectPicker).toBe(false) + expect(empty.showWaiting).toBe(true) + }) + + test("every mid-flow state has distinct label copy via the states record", () => { + const labels = Object.fromEntries( + ( + [ + "connected", + "needs-key", + "invalid", + "expired", + "unreachable", + "unentitled", + "validating", + "waiting-browser", + "waiting-code", + "choose-project", + "unknown", + ] as ConnectionCardState[] + ).map((state) => [state, `copy:${state}`]), + ) as ConnectionStateLabels + expect(stateCopy(parseOne({ id: "pasqal-cloud", state: "waiting-browser" }), labels)).toBe("copy:waiting-browser") + expect(stateCopy(parseOne({ id: "pasqal-cloud", state: "choose-project" }), labels)).toBe("copy:choose-project") + }) +}) + +describe("method model", () => { + test("no advertisement → the legacy single method per card id, chooser threshold not met", () => { + expect(connectionAuthMethods(parseOne({ id: "pasqal-cloud", state: "needs-key" }))).toEqual(["credentials"]) + expect(connectionAuthMethods(parseOne({ id: "company-compute", state: "needs-key" }))).toEqual(["token"]) + }) + + test("wire-advertised methods win over the legacy default", () => { + const conn = parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: ["browser", "token"] }) + expect(connectionAuthMethods(conn)).toEqual(["browser", "token"]) + }) + + test("methodEntryKind: interactive methods render a start, token stays per-id, credentials keeps #169", () => { + expect(methodEntryKind("pasqal-cloud", "browser")).toBe("none") + expect(methodEntryKind("pasqal-cloud", "device-code")).toBe("none") + expect(methodEntryKind("pasqal-cloud", "credentials")).toBe("pasqal-credentials") + expect(methodEntryKind("pasqal-cloud", "token")).toBe("pasqal-token") + expect(methodEntryKind("company-compute", "token")).toBe("base-url-token") + expect(methodEntryKind("company-compute", "credentials")).toBe("base-url-token") + }) +}) + +describe("scaffold submit gates", () => { + test("startAuthPayload only fires for wire-advertised interactive methods", () => { + const advertised = parseOne({ id: "pasqal-cloud", state: "needs-key", auth_methods: ["browser"] }) + expect(startAuthPayload(advertised, "browser")).toEqual({ id: "pasqal-cloud", method: "browser" }) + expect(startAuthPayload(advertised, "device-code")).toBeUndefined() + expect(startAuthPayload(advertised, "credentials")).toBeUndefined() + const legacy = parseOne({ id: "pasqal-cloud", state: "needs-key" }) + expect(startAuthPayload(legacy, "browser")).toBeUndefined() + }) + + test("chooseProjectPayload only accepts a wire-offered project id", () => { + const conn = parseOne({ id: "pasqal-cloud", state: "choose-project", projects: [{ id: "p1", name: "MIS" }] }) + expect(chooseProjectPayload(conn, " p1 ")).toEqual({ id: "pasqal-cloud", project_id: "p1" }) + expect(chooseProjectPayload(conn, "p2")).toBeUndefined() + expect(chooseProjectPayload(conn, "")).toBeUndefined() + expect(chooseProjectPayload(parseOne({ id: "pasqal-cloud", state: "choose-project" }), "p1")).toBeUndefined() + }) + + test("pasqalTokenSubmitPayload trims both fields and refuses empties", () => { + expect(pasqalTokenSubmitPayload("pasqal-cloud", " tok ", " p1 ")).toEqual({ + id: "pasqal-cloud", + token: "tok", + project_id: "p1", + }) + expect(pasqalTokenSubmitPayload("pasqal-cloud", "", "p1")).toBeUndefined() + expect(pasqalTokenSubmitPayload("pasqal-cloud", "tok", " ")).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx new file mode 100644 index 00000000..5c80e3d7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx @@ -0,0 +1,600 @@ +// AMICODE: Connections tab body for the status popover (amicode#166, Pasqal +// card #169). A thin projection of connections.ts — cardModel/stateCopy carry +// the behavioral contract (and its tests; the repo has no tsx component +// harness), the app layer owns fetching and the one-round-trip overlay, and +// this file only renders. SECURITY: secrets (token, Pasqal password) live in +// password-masked inputs and the submit payload; they are never rendered as +// text and the masked input clears when a submit lands connected. +import { createEffect, createSignal, For, Show } from "solid-js" +import { Button } from "../components/button" +import { Spinner } from "../components/spinner" +import { + cardModel, + catalogForPicker, + chooseProjectPayload, + connectionAuthMethods, + connectionDisplayName, + connectionTitle, + driftCopy, + fillLabelTemplate, + isCustomConnectionId, + methodEntryKind, + offlineCopy, + pasqalSubmitPayload, + pasqalTokenSubmitPayload, + startAuthPayload, + stateCopy, + statusTabConnections, + submitPayload, + tokenOnlySubmitPayload, + type ConnectionActionView, + type ConnectionAuthMethod, + type ConnectionsView, + type ConnectionStateLabels, + type ConnectionView, + type CredentialSubmitPayload, + type ChooseProjectPayload, + type StartAuthPayload, +} from "./connections" +import { ConnectionIcon } from "./connection-icon" +import { ConnectionPicker } from "./connection-picker" + +export type ConnectionsTabLabels = { + empty: string + retry: string + states: ConnectionStateLabels + baseUrlPlaceholder: string + tokenPlaceholder: string + usernamePlaceholder: string + passwordPlaceholder: string + projectIdPlaceholder: string + submit: string + disconnect: string + revalidate: string + staleHint: string + sessionOnlyHint: string + /** {{at}}/{{identity}} template — the offline last-verified line (170 AC3) */ + offlineHint: string + /** {{answered}}/{{stored}} template — the submitter-drift diff (170 AC4) */ + driftHint: string + /** auth-path scaffold (#194) — chooser chips, starts, mid-flow copy */ + methods: Record + startBrowser: string + startDeviceCode: string + cancel: string + /** {{url}} template — where the device code gets entered */ + userCodeHint: string + /** {{at}} template — pending-code expiry */ + codeExpiresHint: string + useProject: string +} + +export function AmicodeConnectionsTab(props: { + view: ConnectionsView | undefined + labels: ConnectionsTabLabels + actionError?: string + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void + onRetry: () => void + onAddCustom?: (payload: { name: string; token: string; url?: string }) => Promise + onRemove?: (id: string) => Promise + /** auth-path scaffold (#194) — optional until the server routes exist; the + * UI that needs them is only reachable when the wire advertises methods */ + onStartAuth?: (payload: StartAuthPayload) => void + onChooseProject?: (payload: ChooseProjectPayload) => void + onCancelAuth?: (id: string) => void +}) { + const [showPicker, setShowPicker] = createSignal(false) + return ( +
+ } + > + {(view) => ( + +
+ {view().error} + +
+ } + > + 0} + fallback={ +
+ No connections yet + + + { + if (props.onAddCustom) await props.onAddCustom(p) + setShowPicker(false) + }} + onSubmitToken={async (id, token) => { + const payload = tokenOnlySubmitPayload(id, token) + if (payload) await props.onSubmit(payload as CredentialSubmitPayload) + setShowPicker(false) + }} + onStartBrowser={(id) => { + // Browser login for google/google-drive: start OAuth in browser + const fakeView = { id, state: "needs-key" as const, rawState: "needs-key", validatedAt: "—", stale: false, authMethods: ["browser" as const] } + const payload = startAuthPayload(fakeView as any, "browser") + if (payload) props.onStartAuth?.(payload) + setShowPicker(false) + }} + onClose={() => setShowPicker(false)} + /> + +
+ } + > + + {(conn) => ( + + )} + + + + + +
+ { + if (props.onAddCustom) await props.onAddCustom(p) + setShowPicker(false) + }} + onSubmitToken={async (id, token) => { + const payload = tokenOnlySubmitPayload(id, token) + if (payload) await props.onSubmit(payload as CredentialSubmitPayload) + setShowPicker(false) + }} + onStartBrowser={(id) => { + const fakeView = { id, state: "needs-key" as const, rawState: "needs-key", validatedAt: "—", stale: false, authMethods: ["browser" as const] } + const payload = startAuthPayload(fakeView as any, "browser") + if (payload) props.onStartAuth?.(payload) + setShowPicker(false) + }} + onClose={() => setShowPicker(false)} + /> +
+
+
+
+ )} +
+
+ ) +} + +const TONE_DOT: Record["tone"], string> = { + success: "bg-icon-success-base", + critical: "bg-icon-critical-base", + warning: "bg-icon-warning-base", + pending: "bg-icon-warning-base animate-pulse", + neutral: "bg-border-weak-base", +} + +/** amicode#200: exported — the solver toggle's popover mounts the SAME card + * for Company Compute (connect / connected-manage / expired states). */ +export function ConnectionCard(props: { + conn: ConnectionView + labels: ConnectionsTabLabels + actionError?: string + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void + onRemove?: (id: string) => Promise + onStartAuth?: (payload: StartAuthPayload) => void + onChooseProject?: (payload: ChooseProjectPayload) => void + onCancelAuth?: (id: string) => void + /** amicode#200 (capsule mount): the accordion header already names and + * dots the connection — hide the card's own title row so the connection + * STATUS is the first and only heading text. */ + hideHeader?: boolean +}) { + const model = () => cardModel(props.conn) + const methods = () => connectionAuthMethods(props.conn) + // chooser selection (#194): defaults to the first advertised method and + // snaps back if a re-fetch stops advertising the chosen one + const [chosen, setChosen] = createSignal(undefined) + const method = () => { + const current = chosen() + return current && methods().includes(current) ? current : methods()[0] + } + const entryKind = () => methodEntryKind(props.conn.id, method()) + const [baseUrl, setBaseUrl] = createSignal("") + const [token, setToken] = createSignal("") + // pasqal credentials (#169): request-scope only — the password signal feeds + // the payload and nothing else, and clears the moment a submit connects + const [username, setUsername] = createSignal("") + const [password, setPassword] = createSignal("") + const [projectId, setProjectId] = createSignal("") + const [pickedProject, setPickedProject] = createSignal("") + + // wire-offered prefill fills an untouched field only, never overwrites input + createEffect(() => { + const wire = props.conn.baseUrl + if (wire && baseUrl() === "") setBaseUrl(wire) + }) + + const submit = async (event: Event) => { + event.preventDefault() + const kind = entryKind() + const payload = + kind === "pasqal-credentials" + ? pasqalSubmitPayload(props.conn.id, username(), password()) // #194: no project_id → server lists projects + : kind === "pasqal-token" + ? pasqalTokenSubmitPayload(props.conn.id, token(), projectId()) + : kind === "token-only" + ? tokenOnlySubmitPayload(props.conn.id, token()) + : submitPayload(props.conn.id, baseUrl(), token()) + if (!payload) return // AC3: empty submission — no request, no state change + const result = await props.onSubmit(payload as CredentialSubmitPayload) + // clear the masked inputs once accepted; secrets are never echoed back + if (result.ok && result.connection?.state === "connected") { + setToken("") + setPassword("") + } + } + + const startAuth = () => { + const payload = startAuthPayload(props.conn, method()) + if (payload) props.onStartAuth?.(payload) + } + + const chooseProject = () => { + const payload = chooseProjectPayload(props.conn, pickedProject() || (props.conn.projects?.[0]?.id ?? "")) + if (payload) props.onChooseProject?.(payload) + } + + return ( +
+ +
+ + {connectionDisplayName(props.conn)} + + + {props.conn.rawState} + + +
+ + + {props.conn.validatedAt} + + +
+ + +
+ + + + {stateCopy(props.conn, props.labels.states)} + + · {props.conn.identity} + +
+ + +
+
+ {driftCopy(props.conn, props.labels.driftHint)} +
+ + + +
+ {props.labels.sessionOnlyHint} +
+
+ + +
+ {offlineCopy(props.conn, props.labels.offlineHint)} +
+
+ + +
{props.labels.staleHint}
+
+ + +
+
+ {props.actionError} +
+ + + +
+
+ {props.conn.userCode} +
+ + + {fillLabelTemplate(props.labels.userCodeHint, { url: props.conn.verificationUrl ?? "—" })} + + + + + {fillLabelTemplate(props.labels.codeExpiresHint, { at: props.conn.codeExpiresAt ?? "—" })} + + +
+
+ + +
+ + {(project) => { + const selected = () => (pickedProject() || props.conn.projects?.[0]?.id) === project.id + return ( + + ) + }} + + +
+
+ + +
+
+
+ +
+ + + +
+ 1}> +
+ + {(entry) => ( + + )} + +
+
+ + {method() === "browser" ? props.labels.startBrowser : props.labels.startDeviceCode} + + } + > +
+ + setUsername(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + setPassword(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + + + setBaseUrl(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + + + setToken(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + + + setToken(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + + + setProjectId(event.currentTarget.value)} + class="amc-input amc-input--compact" + /> + + +
+
+
+
+ + +
+ + props.onDisconnect(props.conn.id)} + > + {props.labels.disconnect} + + }> + + +
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections.stories.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.stories.tsx new file mode 100644 index 00000000..2ca5dfbb --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.stories.tsx @@ -0,0 +1,210 @@ +// @ts-nocheck +// Self-contained preview of the Connections tab auth-path scaffold +// (amicode#194 state atlas). Mock wire data only — NOT wired to the server; +// every state and every entry path is reviewable here without any Pasqal +// client config existing yet. The matrix is the design deliverable: four ways +// in (browser / password / device code / token), one shared lifecycle. +import { createSignal, For } from "solid-js" +import { AmicodeConnectionsTab } from "./connections-tab" +import { parseConnectionsResponse } from "./connections" + +export default { + title: "Amicode/ConnectionsAuthPaths", + id: "amicode-connections-auth-paths", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### Connections tab — auth-path state atlas (#194) + +Every candidate auth mechanism scaffolded behind the wire contract: cards render +exactly as today until the server advertises \`auth_methods\` or emits a mid-flow +state (\`waiting-browser\` / \`waiting-code\` / \`choose-project\`). Mock data; the +server routes are a separate slice, pending the mechanism decision.`, + }, + }, + }, +} + +const LABELS = { + empty: "No connections available", + retry: "Retry", + states: { + connected: "Connected", + "needs-key": "Not connected — enter a key to connect", + invalid: "Key rejected — check it and try again", + expired: "Token expired — reconnect to mint a fresh one", + unreachable: "Service unreachable — check the URL or try again", + unentitled: "Project not authorized — check the project ID", + validating: "Validating key…", + "waiting-browser": "Waiting for your browser — finish signing in there", + "waiting-code": "Waiting for the code — enter it on any device", + "choose-project": "Signed in — pick the project runs should bill to", + unknown: "Status needs attention", + }, + baseUrlPlaceholder: "Service URL", + tokenPlaceholder: "API key", + usernamePlaceholder: "Username", + passwordPlaceholder: "Password", + projectIdPlaceholder: "Project ID", + submit: "Connect", + disconnect: "Disconnect", + revalidate: "Revalidate", + staleHint: "Last check is stale — revalidate to refresh", + sessionOnlyHint: "Session-only — you'll be asked to reconnect after a restart", + offlineHint: "Offline — last verified {{at}} as {{identity}}", + driftHint: "This key answered as {{answered}}, was {{stored}} — historical runs may stop authorizing", + methods: { credentials: "Password", browser: "Browser", "device-code": "Device code", token: "Token" }, + startBrowser: "Connect with browser", + startDeviceCode: "Connect with a code", + cancel: "Cancel", + userCodeHint: "Enter this code at {{url}}", + codeExpiresHint: "Code expires {{at}}", + useProject: "Use this project", +} + +const ALL_METHODS = ["browser", "credentials", "device-code", "token"] +const PROJECTS = [ + { id: "7d21ce09-88a3-4f2e-9c41-3f0b7e5a12aa", name: "MIS ladder" }, + { id: "c4a91b77-2e05-40d1-8f6a-90cf51d2ab10", name: "Hackathon sandbox" }, + { id: "f19e2d40-6b3c-47a8-b5d2-1c8ea0774e55", name: "Calibration scratch" }, +] + +const noop = { onSubmit: async () => ({ ok: true }), onDisconnect: () => {}, onRevalidate: () => {}, onRetry: () => {} } + +function fixture(title, entry) { + return { title, view: parseConnectionsResponse({ ok: true, connections: [entry] }) } +} + +const MATRIX = [ + fixture("legacy today — no methods advertised (unchanged #169 card)", { + id: "pasqal-cloud", + state: "needs-key", + }), + fixture("entry — all four methods advertised (chooser appears)", { + id: "pasqal-cloud", + state: "needs-key", + auth_methods: ALL_METHODS, + }), + fixture("waiting-browser — attempt lives in the user's browser", { + id: "pasqal-cloud", + state: "waiting-browser", + auth_methods: ALL_METHODS, + }), + fixture("waiting-code — short code typed on any device", { + id: "pasqal-cloud", + state: "waiting-code", + auth_methods: ALL_METHODS, + user_code: "HRMQ-4321", + verification_url: "https://pasqal.cloud/activate", + code_expires_at: "2026-07-21T21:00:00.000Z", + }), + fixture("choose-project — authenticated, one choice left", { + id: "pasqal-cloud", + state: "choose-project", + identity: "kate@harmoniqs.co", + projects: PROJECTS, + }), + fixture("choose-project without wire projects — cancel exit stays open", { + id: "pasqal-cloud", + state: "choose-project", + identity: "kate@harmoniqs.co", + }), + fixture("connected", { + id: "pasqal-cloud", + state: "connected", + identity: "kate@harmoniqs.co", + devices: [{ id: "fresnel", name: "Fresnel QPU" }, { id: "emu-free", name: "EMU_FREE" }], + validated_at: "2026-07-21T10:00:00.000Z", + }), + fixture("expired — re-entry cost differs per method (copy carries it)", { + id: "pasqal-cloud", + state: "expired", + auth_methods: ALL_METHODS, + identity: "kate@harmoniqs.co", + }), + fixture("validating — one awaited POST, form frozen", { + id: "pasqal-cloud", + state: "validating", + auth_methods: ALL_METHODS, + }), + fixture("offline — last verified, never a false invalid", { + id: "pasqal-cloud", + state: "connected", + identity: "kate@harmoniqs.co", + offline: true, + validated_at: "2026-07-21T08:00:00.000Z", + }), + fixture("unknown wire state — safe fallback keeps every exit open", { + id: "pasqal-cloud", + state: "quantum-flux", + }), +] + +export const StateAtlas = () => ( +
+ + {(entry) => ( +
+
{entry.title}
+
+ {}} + onChooseProject={() => {}} + onCancelAuth={() => {}} + /> +
+
+ )} +
+
+) + +/** Clickable walk-through: start a method → mid-flow → project → connected. + * The story fakes the server by swapping wire fixtures on each action. */ +export const InteractiveWalkthrough = () => { + const [entry, setEntry] = createSignal({ id: "pasqal-cloud", state: "needs-key", auth_methods: ALL_METHODS }) + const view = () => parseConnectionsResponse({ ok: true, connections: [entry()] }) + const advance = (state, extra = {}) => setEntry({ id: "pasqal-cloud", auth_methods: ALL_METHODS, state, ...extra }) + return ( +
+
+ { + advance("validating") + setTimeout(() => advance("choose-project", { identity: "kate@harmoniqs.co", projects: PROJECTS }), 900) + return { ok: true } + }} + onDisconnect={() => advance("needs-key")} + onRevalidate={() => {}} + onRetry={() => {}} + onStartAuth={(payload) => { + if (payload.method === "browser") { + advance("waiting-browser") + setTimeout(() => advance("choose-project", { identity: "kate@harmoniqs.co", projects: PROJECTS }), 2200) + } else { + advance("waiting-code", { + user_code: "HRMQ-4321", + verification_url: "https://pasqal.cloud/activate", + code_expires_at: "2026-07-21T21:00:00.000Z", + }) + setTimeout(() => advance("choose-project", { identity: "kate@harmoniqs.co", projects: PROJECTS }), 2200) + } + }} + onChooseProject={() => + advance("connected", { identity: "kate@harmoniqs.co", validated_at: new Date().toISOString() }) + } + onCancelAuth={() => advance("needs-key")} + /> +
+
+ Pick a method, hit its action, watch the flow — the story fakes the server. +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.test.ts new file mode 100644 index 00000000..b79709d6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, test } from "bun:test" +import { + applyConnectionOverlay, + cardModel, + connectionFormKind, + connectionTitle, + driftCopy, + fillLabelTemplate, + offlineCopy, + parseConnectionActionResponse, + parseConnectionsResponse, + pasqalSubmitPayload, + stateCopy, + submitPayload, + validatedAtDisplay, + type ConnectionCardState, + type ConnectionStateLabels, + type ConnectionsView, + type ConnectionView, +} from "./connections" + +// Wire fixtures mirror the #165 status contract (packages/opencode +// src/server/amicode/connections.ts): GET → {ok, connections:[…]}, +// POST → {ok, connection, error}. +const connectedEntry = { + id: "company-compute", + state: "connected", + identity: "kate@harmoniqs.co", + entitlements: ["solve"], + expires_at: "2026-12-31T00:00:00.000Z", + devices: [{ id: "d1", name: "fresnel", state: "online" }], + validated_at: "2026-07-19T10:00:00.000Z", + stale: false, +} + +describe("validatedAtDisplay", () => { + test("renders a parseable ISO timestamp as a locale string", () => { + const display = validatedAtDisplay("2026-07-19T10:00:00.000Z") + expect(display).not.toBe("—") + expect(display).toContain("2026") + }) + test("maps absent, non-string, empty, and unparseable values to an em dash", () => { + expect(validatedAtDisplay(undefined)).toBe("—") + expect(validatedAtDisplay(null)).toBe("—") + expect(validatedAtDisplay(42)).toBe("—") + expect(validatedAtDisplay("")).toBe("—") + expect(validatedAtDisplay("not-a-date")).toBe("—") + }) +}) + +describe("parseConnectionsResponse", () => { + test("happy path: a connected entry passes through with renamed fields", () => { + const view = parseConnectionsResponse({ ok: true, connections: [connectedEntry], error: null }) + expect(view.ok).toBe(true) + expect(view.connections).toHaveLength(1) + const conn = view.connections[0] + expect(conn.id).toBe("company-compute") + expect(conn.state).toBe("connected") + expect(conn.rawState).toBe("connected") + expect(conn.identity).toBe("kate@harmoniqs.co") + expect(conn.validatedAt).toContain("2026") + expect(conn.stale).toBe(false) + }) + + test("each contract state survives the parse — incl. expired + unentitled (169)", () => { + for (const state of ["connected", "needs-key", "invalid", "expired", "unreachable", "unentitled", "validating"]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state, validated_at: null, stale: false }], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections[0].state).toBe(state as never) + } + }) + + test("unknown states collapse to the safe fallback, raw word preserved (AC4)", () => { + for (const state of ["some-future-state", "revoked"]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state, validated_at: null, stale: false }], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections[0].state).toBe("unknown") + expect(view.connections[0].rawState).toBe(state) + } + }) + + test("devices parse to display names — objects prefer name, fall back to id; junk is dropped (169 AC2)", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [ + { + id: "pasqal-cloud", + state: "connected", + stale: false, + devices: [{ name: "EMU_FREE" }, { id: "d9", state: "online" }, {}, null, 42, { name: "" }], + }, + ], + error: null, + }) + expect(view.connections[0].devices).toEqual(["EMU_FREE", "d9"]) + }) + + test("absent or empty devices leave the field undefined", () => { + for (const devices of [undefined, [], "three", [null, {}]]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "pasqal-cloud", state: "connected", stale: false, devices }], + error: null, + }) + expect(view.connections[0].devices).toBeUndefined() + } + }) + + test("identity_drift parses to identityDrift; junk shapes leave it undefined (170 AC4)", () => { + const drifted = parseConnectionsResponse({ + ok: true, + connections: [ + { + id: "company-compute", + state: "connected", + identity: "team-alpha", + identity_drift: "team-beta", + stale: false, + }, + ], + error: null, + }) + expect(drifted.connections[0].identity).toBe("team-alpha") + expect(drifted.connections[0].identityDrift).toBe("team-beta") + for (const identity_drift of [undefined, "", 42, { nested: "x" }, ["y"]]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state: "connected", identity_drift, stale: false }], + error: null, + }) + expect(view.connections[0].identityDrift).toBeUndefined() + } + }) + + test("offline:true parses to the offline marker; anything else leaves it undefined (170 AC3)", () => { + const marked = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state: "connected", stale: true, offline: true }], + error: null, + }) + expect(marked.connections[0].offline).toBe(true) + for (const offline of [undefined, false, "yes", 1, null]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state: "connected", stale: true, offline }], + error: null, + }) + expect(view.connections[0].offline).toBeUndefined() + } + }) + + test("session_only:true parses to the sessionOnly marker; anything else leaves it undefined (169 AC4)", () => { + const marked = parseConnectionsResponse({ + ok: true, + connections: [{ id: "pasqal-cloud", state: "connected", stale: false, session_only: true }], + error: null, + }) + expect(marked.connections[0].sessionOnly).toBe(true) + for (const session_only of [undefined, false, "yes", 1]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "pasqal-cloud", state: "connected", stale: false, session_only }], + error: null, + }) + expect(view.connections[0].sessionOnly).toBeUndefined() + } + }) + + test("ok:false carries the server error string", () => { + const view = parseConnectionsResponse({ ok: false, connections: [], error: "bad_output: boom" }) + expect(view.ok).toBe(false) + expect(view.error).toBe("bad_output: boom") + }) + + test("ok:false with no usable error still produces a message", () => { + const view = parseConnectionsResponse({ ok: false, connections: [], error: null }) + expect(view.ok).toBe(false) + expect(view.error).toBeTruthy() + }) + + test("non-object responses are bad_shape, never a throw", () => { + for (const raw of [null, undefined, 42, "nope", []]) { + const view = parseConnectionsResponse(raw) + expect(view.ok).toBe(false) + expect(view.error).toContain("bad_shape") + } + }) + + test("connections absent or non-array is bad_shape", () => { + const missing = parseConnectionsResponse({ ok: true, error: null }) + expect(missing.ok).toBe(false) + expect(missing.error).toContain("bad_shape") + const nonArray = parseConnectionsResponse({ ok: true, connections: "three", error: null }) + expect(nonArray.ok).toBe(false) + }) + + test("tolerant entries: missing/mistyped fields never throw (AC4)", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [ + {}, + null, + { id: "company-compute", state: 7, identity: 3, validated_at: 12, stale: "yes" }, + { id: "", state: "connected", base_url: 9 }, + ], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections).toHaveLength(4) + expect(view.connections[0]).toEqual({ + id: "(unknown)", + state: "unknown", + rawState: "unknown", + identity: undefined, + baseUrl: undefined, + validatedAt: "—", + stale: false, + }) + expect(view.connections[2].state).toBe("unknown") + expect(view.connections[2].stale).toBe(false) + expect(view.connections[3].id).toBe("(unknown)") + expect(view.connections[3].baseUrl).toBeUndefined() + }) + + test("unknown fields are dropped — a poisoned entry can never carry a secret into the view", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ ...connectedEntry, token: "sk-poison", password: "hunter2", junk: { nested: "sk-deep" } }], + error: null, + }) + const serialized = JSON.stringify(view) + expect(serialized).not.toContain("sk-poison") + expect(serialized).not.toContain("hunter2") + expect(serialized).not.toContain("sk-deep") + }) + + test("base_url prefill survives when the server offers one (forward-compatible)", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state: "needs-key", base_url: "https://solve.example", stale: false }], + error: null, + }) + expect(view.connections[0].baseUrl).toBe("https://solve.example") + }) +}) + +describe("parseConnectionActionResponse", () => { + test("happy path: the terminal connection rides the same response (AC2 wire shape)", () => { + const view = parseConnectionActionResponse({ ok: true, connection: connectedEntry, error: null }) + expect(view.ok).toBe(true) + expect(view.connection?.state).toBe("connected") + expect(view.connection?.identity).toBe("kate@harmoniqs.co") + }) + + test("ok:false carries the server error; missing error still produces a message", () => { + const failed = parseConnectionActionResponse({ ok: false, connection: null, error: "invalid_key: rejected" }) + expect(failed.ok).toBe(false) + expect(failed.error).toBe("invalid_key: rejected") + const silent = parseConnectionActionResponse({ ok: false, connection: null, error: null }) + expect(silent.ok).toBe(false) + expect(silent.error).toBeTruthy() + }) + + test("non-object responses and a missing connection are bad_shape, never a throw", () => { + for (const raw of [null, undefined, 42, "nope", []]) { + const view = parseConnectionActionResponse(raw) + expect(view.ok).toBe(false) + expect(view.error).toContain("bad_shape") + } + const missing = parseConnectionActionResponse({ ok: true, connection: null, error: null }) + expect(missing.ok).toBe(false) + expect(missing.error).toContain("bad_shape") + }) + + test("the submitted token has no path into the parsed view", () => { + const view = parseConnectionActionResponse({ + ok: true, + connection: { ...connectedEntry, token: "sk-echoed" }, + error: null, + }) + expect(JSON.stringify(view)).not.toContain("sk-echoed") + }) +}) + +// --- AC1: the state→props mapping the card renders from. With no tsx +// component harness in this repo (see connections-tab.tsx), this mapping IS +// the component contract, so it gets exhaustive coverage here. + +const CARD_STATES: ConnectionCardState[] = [ + "connected", + "needs-key", + "invalid", + "expired", + "unreachable", + "unentitled", + "validating", + "waiting-browser", + "waiting-code", + "choose-project", + "unknown", +] + +const labels: ConnectionStateLabels = { + connected: "Connected", + "needs-key": "Not connected — enter a key", + invalid: "Key rejected", + expired: "Token expired — reconnect to refresh it", + unreachable: "Service unreachable", + unentitled: "Project not authorized — check the project ID", + validating: "Validating key…", + "waiting-browser": "Waiting for your browser — finish signing in there", + "waiting-code": "Waiting for the code — enter it on any device", + "choose-project": "Signed in — pick the project runs should bill to", + unknown: "Status needs attention", +} + +function viewFor(state: ConnectionCardState, extra: Partial = {}): ConnectionView { + return { + id: "company-compute", + state, + rawState: state === "unknown" ? "expired" : state, + validatedAt: "—", + stale: false, + ...extra, + } +} + +describe("stateCopy", () => { + test("every card state picks its own distinct copy (AC1)", () => { + const seen = new Set(CARD_STATES.map((state) => stateCopy(viewFor(state), labels))) + expect(seen.size).toBe(CARD_STATES.length) + for (const state of CARD_STATES) { + expect(stateCopy(viewFor(state), labels)).toBe(labels[state]) + } + }) +}) + +describe("cardModel", () => { + test("connected: actions + validated_at, no key form", () => { + const model = cardModel(viewFor("connected", { validatedAt: "7/19/2026", identity: "kate@harmoniqs.co" })) + expect(model.showForm).toBe(false) + expect(model.showActions).toBe(true) + expect(model.showValidatedAt).toBe(true) + expect(model.showIdentity).toBe(true) + expect(model.tone).toBe("success") + }) + + test("connected without identity hides the identity line; stale surfaces the hint", () => { + const bare = cardModel(viewFor("connected")) + expect(bare.showIdentity).toBe(false) + expect(bare.showStale).toBe(false) + const stale = cardModel(viewFor("connected", { stale: true })) + expect(stale.showStale).toBe(true) + }) + + test("needs-key / invalid / expired / unreachable / unentitled: credential form enabled, no actions", () => { + for (const [state, tone] of [ + ["needs-key", "neutral"], + ["invalid", "critical"], + ["expired", "warning"], + ["unreachable", "warning"], + ["unentitled", "critical"], + ] as const) { + const model = cardModel(viewFor(state)) + expect(model.showForm).toBe(true) + expect(model.formDisabled).toBe(false) + expect(model.showActions).toBe(false) + expect(model.showValidatedAt).toBe(false) + expect(model.tone).toBe(tone) + } + }) + + test("session-only connected surfaces the note; a durable connect does not (169 AC4)", () => { + expect(cardModel(viewFor("connected", { sessionOnly: true })).showSessionOnly).toBe(true) + expect(cardModel(viewFor("connected")).showSessionOnly).toBe(false) + expect(cardModel(viewFor("invalid", { sessionOnly: true })).showSessionOnly).toBe(false) + }) + + test("offline connected surfaces the last-verified line — and ONLY on connected (170 AC3)", () => { + expect(cardModel(viewFor("connected", { offline: true, stale: true })).showOffline).toBe(true) + expect(cardModel(viewFor("connected")).showOffline).toBe(false) + for (const state of ["needs-key", "invalid", "expired", "unreachable", "unknown"] as const) { + expect(cardModel(viewFor(state, { offline: true })).showOffline).toBe(false) + } + }) + + test("a drifted identity surfaces the diff on a connected card — never without a drift (170 AC4)", () => { + const drifted = cardModel(viewFor("connected", { identity: "team-alpha", identityDrift: "team-beta" })) + expect(drifted.showDrift).toBe(true) + expect(drifted.showIdentity).toBe(true) // the record still renders beside the diff + expect(cardModel(viewFor("connected", { identity: "team-alpha" })).showDrift).toBe(false) + for (const state of ["needs-key", "invalid", "expired", "validating"] as const) { + expect(cardModel(viewFor(state, { identityDrift: "team-beta" })).showDrift).toBe(false) + } + }) + + test("validating: a loading spinner, not a frozen form — the in-flight render (#194)", () => { + const model = cardModel(viewFor("validating")) + expect(model.showLoading).toBe(true) + expect(model.showForm).toBe(false) + expect(model.showActions).toBe(false) + expect(model.showProjectPicker).toBe(false) + expect(model.tone).toBe("pending") + }) + + test("unknown: safe fallback keeps every exit open and shows the raw wire word (AC4)", () => { + const model = cardModel(viewFor("unknown")) + expect(model.showForm).toBe(true) + expect(model.formDisabled).toBe(false) + expect(model.showActions).toBe(true) + expect(model.showRawState).toBe(true) + expect(model.tone).toBe("neutral") + }) + + test("only unknown renders the raw state word", () => { + for (const state of CARD_STATES.filter((s) => s !== "unknown")) { + expect(cardModel(viewFor(state)).showRawState).toBe(false) + } + }) +}) + +// --- 170 AC3/AC4: label templates whose values (identities, timestamps) only +// exist at render time. The app hands the raw {{slot}} template through; the +// card fills it with display values — never secrets, never wire-raw objects. + +describe("fillLabelTemplate", () => { + test("fills named slots; unknown slots stay verbatim (forward-compatible copy)", () => { + expect(fillLabelTemplate("last verified {{at}} as {{identity}}", { at: "7/19/2026", identity: "team-alpha" })).toBe( + "last verified 7/19/2026 as team-alpha", + ) + expect(fillLabelTemplate("no slots here", {})).toBe("no slots here") + expect(fillLabelTemplate("keep {{future}} intact", { at: "x" })).toBe("keep {{future}} intact") + }) +}) + +describe("offlineCopy (170 AC3)", () => { + test("renders the last-verified line from the view's display values", () => { + const view = viewFor("connected", { + offline: true, + stale: true, + validatedAt: "7/19/2026, 10:00", + identity: "team-alpha", + }) + expect(offlineCopy(view, "Offline — last verified {{at}} as {{identity}}")).toBe( + "Offline — last verified 7/19/2026, 10:00 as team-alpha", + ) + }) + + test("a view without an identity renders an em dash, never a hole", () => { + const view = viewFor("connected", { offline: true, stale: true, validatedAt: "7/19/2026, 10:00" }) + expect(offlineCopy(view, "Offline — last verified {{at}} as {{identity}}")).toBe( + "Offline — last verified 7/19/2026, 10:00 as —", + ) + }) +}) + +describe("driftCopy (170 AC4)", () => { + test("renders the explicit diff — answered as X, was Y", () => { + const view = viewFor("connected", { identity: "team-alpha", identityDrift: "team-beta" }) + expect( + driftCopy(view, "This key answered as {{answered}}, was {{stored}} — historical runs may stop authorizing"), + ).toBe("This key answered as team-beta, was team-alpha — historical runs may stop authorizing") + }) + + test("holes render em dashes rather than template residue", () => { + const view = viewFor("connected", { identityDrift: "team-beta" }) + expect(driftCopy(view, "answered as {{answered}}, was {{stored}}")).toBe("answered as team-beta, was —") + }) +}) + +describe("connectionTitle", () => { + test("known products get names; unknown ids render verbatim", () => { + expect(connectionTitle("company-compute")).toBe("Harmoniqs Cloud") + expect(connectionTitle("pasqal-cloud")).toBe("Pasqal Cloud") + expect(connectionTitle("(unknown)")).toBe("(unknown)") + }) +}) + +describe("connectionFormKind", () => { + test("pasqal-cloud takes the credentials form; everything else keeps base_url + token", () => { + expect(connectionFormKind("pasqal-cloud")).toBe("pasqal-credentials") + expect(connectionFormKind("company-compute")).toBe("base-url-token") + expect(connectionFormKind("some-future-id")).toBe("base-url-token") + }) +}) + +// --- AC3: the submit gate. An empty submission produces NO payload — the +// component fires no request and touches no state when this returns undefined. + +describe("submitPayload", () => { + test("empty or whitespace-only fields yield no payload at all (AC3)", () => { + expect(submitPayload("company-compute", "", "")).toBeUndefined() + expect(submitPayload("company-compute", "https://solve.example", "")).toBeUndefined() + expect(submitPayload("company-compute", "", "sk-key")).toBeUndefined() + expect(submitPayload("company-compute", " ", "sk-key")).toBeUndefined() + expect(submitPayload("company-compute", "https://solve.example", " \t ")).toBeUndefined() + }) + + test("both fields present yields the trimmed wire body", () => { + expect(submitPayload("company-compute", " https://solve.example ", " sk-key ")).toEqual({ + id: "company-compute", + base_url: "https://solve.example", + token: "sk-key", + }) + }) +}) + +describe("pasqalSubmitPayload (#194 two-step: username+password only, no project_id)", () => { + test("an empty or whitespace-only username or password yields no payload — no request fires", () => { + expect(pasqalSubmitPayload("pasqal-cloud", "", "")).toBeUndefined() + expect(pasqalSubmitPayload("pasqal-cloud", "kate@example.com", "")).toBeUndefined() + expect(pasqalSubmitPayload("pasqal-cloud", "", "hunter2")).toBeUndefined() + expect(pasqalSubmitPayload("pasqal-cloud", "kate@example.com", " \t ")).toBeUndefined() + }) + + test("username trimmed, password VERBATIM, and NO project_id (the server lists projects)", () => { + const payload = pasqalSubmitPayload("pasqal-cloud", " kate@example.com ", " p4ss word ") + expect(payload).toEqual({ + id: "pasqal-cloud", + username: "kate@example.com", + password: " p4ss word ", // passwords may legitimately carry spaces — never trimmed + }) + expect(payload && "project_id" in payload).toBe(false) + }) +}) + +// --- AC2: the overlay the app layer applies around one round trip — the +// submit renders "validating" while the POST is in flight, then the terminal +// connection from the SAME response replaces it. No polling loop anywhere. + +describe("applyConnectionOverlay", () => { + const baseView: ConnectionsView = { + ok: true, + connections: [viewFor("needs-key")], + } + + test("no overlay passes the base view through untouched", () => { + expect(applyConnectionOverlay(baseView, {})).toBe(baseView) + expect(applyConnectionOverlay(undefined, {})).toBeUndefined() + }) + + test("validating overlay flips the matching card to the in-flight state (AC2)", () => { + const view = applyConnectionOverlay(baseView, { validating: "company-compute" }) + expect(view?.ok).toBe(true) + expect(view?.connections[0].state).toBe("validating") + expect(view?.connections[0].id).toBe("company-compute") + // the base view is not mutated + expect(baseView.connections[0].state).toBe("needs-key") + }) + + test("terminal overlay replaces the card with the connection from the same response (AC2)", () => { + const terminal = viewFor("connected", { validatedAt: "7/19/2026", identity: "kate@harmoniqs.co" }) + const view = applyConnectionOverlay(baseView, { terminal }) + expect(view?.connections).toHaveLength(1) + expect(view?.connections[0]).toEqual(terminal) + }) + + test("validating wins over a stale terminal while a new request is in flight", () => { + const terminal = viewFor("connected") + const view = applyConnectionOverlay(baseView, { terminal, validating: "company-compute" }) + expect(view?.connections[0].state).toBe("validating") + }) + + test("overlay still renders when the base GET is absent or failed", () => { + const inflight = applyConnectionOverlay(undefined, { validating: "company-compute" }) + expect(inflight?.ok).toBe(true) + expect(inflight?.connections[0].state).toBe("validating") + const failed = applyConnectionOverlay( + { ok: false, connections: [], error: "boom" }, + { terminal: viewFor("connected") }, + ) + expect(failed?.ok).toBe(true) + expect(failed?.connections[0].state).toBe("connected") + }) + + test("an overlay for one id leaves sibling cards untouched (Pasqal-ready)", () => { + const twoCards: ConnectionsView = { + ok: true, + connections: [viewFor("needs-key"), viewFor("connected", { id: "pasqal-cloud" })], + } + const view = applyConnectionOverlay(twoCards, { validating: "company-compute" }) + expect(view?.connections[1]).toEqual(twoCards.connections[1]) + expect(view?.connections[0].state).toBe("validating") + }) +}) + +// ── Harmoniqs Cloud is connectable in the Connections tab (reverses #200 AC5) ─ +import { statusTabConnections, COMPANY_COMPUTE_ID as COMPUTE_ID } from "./connections" + +describe("status-tab connection list", () => { + const mk = (id: string) => ({ id, state: "connected" as const, rawState: "connected", validatedAt: "—", stale: false }) + + // The regression this pins: #200 filtered company-compute out of this list, so + // Pasqal Cloud was connectable here and OUR cloud was not — users looked where + // Pasqal is, found nothing, and had nowhere to enter an API key. + test("includes company-compute, in wire order alongside the others", () => { + const list = [mk(COMPUTE_ID), mk("pasqal-cloud"), mk("future-target")] + expect(statusTabConnections(list).map((c) => c.id)).toEqual([COMPUTE_ID, "pasqal-cloud", "future-target"]) + }) + + test("a compute-only list still renders a card (the empty state must not swallow it)", () => { + expect(statusTabConnections([mk(COMPUTE_ID)]).map((c) => c.id)).toEqual([COMPUTE_ID]) + }) + + test("an empty list stays empty", () => { + expect(statusTabConnections([])).toEqual([]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.ts new file mode 100644 index 00000000..5c0626c6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections.ts @@ -0,0 +1,582 @@ +// AMICODE: pure, tolerant parsing + card logic for the Connections panel tab +// (amicode#166 / parent #159). The wire shape is the #165 status contract +// served by GET /amicode/connections ({ok, connections:[…]}) and the three +// POST mutations ({ok, connection, error}) — packages/opencode +// src/server/amicode/connections.ts is the producer. This module is the +// SINGLE consumer of the wire shape (vaults.ts idiom: one schema, one place +// to update). It must never throw on malformed input; unknown fields are +// dropped and unknown states collapse to a safe "unknown" fallback, so a +// poisoned or future-shaped response can neither crash the card nor carry a +// secret into the DOM. + +export const CONNECTION_WIRE_STATES = [ + "connected", + "needs-key", + "invalid", + "expired", + "unreachable", + "unentitled", + "validating", + // auth-path scaffold (amicode#194 atlas): mid-flow states the server emits + // once an interactive method is live. Today's producer never sends them — + // every card renders exactly as before until it does. + "waiting-browser", + "waiting-code", + "choose-project", +] as const +export type ConnectionWireState = (typeof CONNECTION_WIRE_STATES)[number] +/** Everything the wire might say beyond the contract states renders via the + * "unknown" fallback. */ +export type ConnectionCardState = ConnectionWireState | "unknown" + +/** Auth methods a connection MAY advertise (amicode#194): how the token gets + * minted. Absent on the wire → legacy behavior (the per-id credential form), + * chooser hidden — the mechanism decision stays open server-side. */ +export const CONNECTION_AUTH_METHODS = ["credentials", "browser", "device-code", "token"] as const +export type ConnectionAuthMethod = (typeof CONNECTION_AUTH_METHODS)[number] + +export type ConnectionProject = { id: string; name: string } + +export type ConnectionView = { + id: string + state: ConnectionCardState + /** what the wire actually said — shown verbatim when state is "unknown" */ + rawState: string + identity?: string + /** the submitter this key NOW answers as when it differs from the stored + * identity record (170 AC4) — presence IS the drift signal */ + identityDrift?: string + /** server-offered prefill for the key form (forward-compatible) */ + baseUrl?: string + /** device display names (Pasqal, 169 AC2) — non-secret status metadata */ + devices?: string[] + /** connected in memory only (Pasqal minted no token, 169 AC4) — the card + * notes that a restart will re-prompt */ + sessionOnly?: boolean + /** the last revalidation could not reach the service (170 AC3) — the card + * is showing the last verified status, never a verdict on the key */ + offline?: boolean + /** display string: locale-formatted timestamp or an em dash */ + validatedAt: string + stale: boolean + /** auth methods the server advertises (#194); unknown strings are dropped */ + authMethods?: ConnectionAuthMethod[] + /** device-code flow: the short human code — non-secret BY DESIGN (it is + * typed on another machine) and useless without that signed-in session */ + userCode?: string + /** device-code flow: where the code gets entered */ + verificationUrl?: string + /** display string: when the pending code stops working */ + codeExpiresAt?: string + /** choose-project: the authenticated account's projects (name falls back + * to id; entries without an id are dropped) */ + projects?: ConnectionProject[] + /** #327: optional icon svg or letter for logo rendering */ + icon?: string + /** #327: display name from registry */ + name?: string +} + +export type ConnectionsView = { ok: boolean; connections: ConnectionView[]; error?: string } +export type ConnectionActionView = { ok: boolean; connection?: ConnectionView; error?: string } + +export const COMPANY_COMPUTE_ID = "company-compute" +export const PASQAL_ID = "pasqal-cloud" +export const SLACK_ID = "slack" +export const GITHUB_ID = "github" +export const LINEAR_ID = "linear" +export const GOOGLE_ID = "google" +export const GOOGLE_DRIVE_ID = "google-drive" + +export const BUILT_IN_IDS = [COMPANY_COMPUTE_ID, PASQAL_ID, SLACK_ID, GITHUB_ID, LINEAR_ID, GOOGLE_ID, GOOGLE_DRIVE_ID] as const + +export const CONNECTION_ICONS: Record = { + "company-compute": + '', + "pasqal-cloud": + '', + slack: + '', + github: + '', + linear: + '', + google: + '', + "google-drive": + '', +} + +export function isCustomConnectionId(id: string): boolean { + return id.startsWith("custom-") +} + +export function catalogForPicker(connections: ConnectionView[]): { id: string; name: string; icon: string; authShape: string }[] { + const configured = new Set(connections.filter((c) => c.state !== "needs-key").map((c) => c.id)) + return BUILT_IN_IDS.filter((id) => !configured.has(id)).map((id) => ({ + id, + name: connectionTitle(id), + icon: CONNECTION_ICONS[id] ?? "", + authShape: connectionFormKind(id), + })) +} + +/** #327: Only configured connections visible. The panel shows only connections + * that have been configured (connected or previously attempted). Unconfigured + * built-ins (needs-key) are hidden and surface via the Add picker. */ +export function statusTabConnections(connections: ConnectionView[]): ConnectionView[] { + return connections.filter((c) => c.state !== "needs-key") +} +export function unconfiguredBuiltIns(connections: ConnectionView[]): string[] { + const configured = new Set(connections.filter((c) => c.state !== "needs-key").map((c) => c.id)) + return BUILT_IN_IDS.filter((id) => !configured.has(id)) as unknown as string[] +} + +/** Product names are not translated; ids without one render verbatim. */ +export function connectionTitle(id: string): string { + // Named as the product, alongside Pasqal Cloud. #200 called this "Solver API + // key" to avoid implying a second product, but in a list whose other entry is + // "Pasqal Cloud" that reads as a settings field rather than our service — and + // it is the name every downstream refusal uses (amico-run, the hpc gate). + // The wire id stays "company-compute": server contract, not presentation. + if (id === COMPANY_COMPUTE_ID) return "Harmoniqs Cloud" + if (id === PASQAL_ID) return "Pasqal Cloud" + if (id === SLACK_ID) return "Slack" + if (id === GITHUB_ID) return "GitHub" + if (id === LINEAR_ID) return "Linear" + if (id === GOOGLE_ID) return "Google" + if (id === GOOGLE_DRIVE_ID) return "Google Drive" + if (isCustomConnectionId(id)) return id // caller should use view.name when available + return id +} + +export function connectionDisplayName(view: ConnectionView): string { + if (view.name) return view.name + return connectionTitle(view.id) +} + +const WIRE_STATES: ReadonlySet = new Set(CONNECTION_WIRE_STATES) + +function str(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined +} + +export function validatedAtDisplay(value: unknown): string { + const raw = str(value) + if (!raw) return "—" + const at = Date.parse(raw) + if (!Number.isFinite(at)) return "—" + return new Date(at).toLocaleString() +} + +/** Wire devices ({id?,name?,state?} objects) → display names; anything + * off-shape is dropped, an empty result is absent. */ +function parseDevices(raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined + const names: string[] = [] + for (const device of raw) { + if (typeof device !== "object" || device === null || Array.isArray(device)) continue + const d = device as Record + const name = str(d.name) ?? str(d.id) + if (name) names.push(name) + } + return names.length > 0 ? names : undefined +} + +const AUTH_METHODS: ReadonlySet = new Set(CONNECTION_AUTH_METHODS) + +/** Wire auth_methods → known methods; unknown strings and dupes are dropped, + * an empty result is absent (→ legacy per-id form, no chooser). */ +function parseAuthMethods(raw: unknown): ConnectionAuthMethod[] | undefined { + if (!Array.isArray(raw)) return undefined + const methods = raw.filter( + (method, index): method is ConnectionAuthMethod => + typeof method === "string" && AUTH_METHODS.has(method) && raw.indexOf(method) === index, + ) + return methods.length > 0 ? methods : undefined +} + +/** Wire projects ({id, name?} objects) → picker entries; entries without an + * id are dropped, an empty result is absent. */ +function parseProjects(raw: unknown): ConnectionProject[] | undefined { + if (!Array.isArray(raw)) return undefined + const projects = raw.flatMap((project): ConnectionProject[] => { + if (typeof project !== "object" || project === null || Array.isArray(project)) return [] + const p = project as Record + const id = str(p.id) + if (!id) return [] + return [{ id, name: str(p.name) ?? id }] + }) + return projects.length > 0 ? projects : undefined +} + +function parseConnectionEntry(raw: unknown): ConnectionView { + const entry = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record + const rawState = str(entry.state) ?? "unknown" + const codeExpires = str(entry.code_expires_at) ? validatedAtDisplay(entry.code_expires_at) : "—" + return { + id: str(entry.id) ?? "(unknown)", + state: WIRE_STATES.has(rawState) ? (rawState as ConnectionWireState) : "unknown", + rawState, + identity: str(entry.identity), + identityDrift: str(entry.identity_drift), + baseUrl: str(entry.base_url), + devices: parseDevices(entry.devices), + sessionOnly: entry.session_only === true ? true : undefined, + offline: entry.offline === true ? true : undefined, + validatedAt: validatedAtDisplay(entry.validated_at), + stale: entry.stale === true, + authMethods: parseAuthMethods(entry.auth_methods), + userCode: str(entry.user_code), + verificationUrl: str(entry.verification_url), + codeExpiresAt: codeExpires === "—" ? undefined : codeExpires, + projects: parseProjects(entry.projects), + icon: str(entry.icon), + name: str(entry.name), + } +} + +export function parseConnectionsResponse(raw: unknown): ConnectionsView { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, connections: [], error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) { + const error = str(data.error) ?? "connection status reported a failure" + return { ok: false, connections: [], error } + } + if (!Array.isArray(data.connections)) + return { ok: false, connections: [], error: "bad_shape: connections missing or not a list" } + return { ok: true, connections: data.connections.map(parseConnectionEntry) } +} + +// --- card model (AC1): the pure state→props mapping the tab renders from. +// The tsx component is a thin projection of this — with no component harness +// in the repo, this mapping carries the behavioral contract and its tests. + +export type ConnectionStateLabels = Record + +/** Distinct copy per state; anything unrecognized reads the "unknown" line. */ +export function stateCopy(view: ConnectionView, labels: ConnectionStateLabels): string { + return labels[view.state] +} + +export type ConnectionCardModel = { + state: ConnectionCardState + tone: "success" | "critical" | "warning" | "pending" | "neutral" + /** credential-entry form (fields per connectionFormKind) */ + showForm: boolean + /** true only while validating — the in-flight render keeps the form frozen */ + formDisabled: boolean + /** disconnect + revalidate */ + showActions: boolean + showValidatedAt: boolean + showIdentity: boolean + showStale: boolean + /** device names listed on a connected card (Pasqal, 169 AC2) */ + /** session-only note on a connected card (Pasqal, 169 AC4) */ + showSessionOnly: boolean + /** offline last-verified line on a connected card (170 AC3) */ + showOffline: boolean + /** submitter-drift diff on a connected card (170 AC4) */ + showDrift: boolean + /** unknown states show the wire's raw word beside the fallback copy */ + showRawState: boolean + /** mid-flow waiting row (browser handoff / device code) with a cancel exit */ + showWaiting: boolean + // NOTE: the connected card intentionally does NOT list devices — the + // validator's get_device_specs_dict() is Pasqal's device CATALOG, not the + // project's entitled set, so it can mislead. Device selection happens fresh + // at submit time (#160). The wire `devices` field is kept for that path. + /** the short human code block + where to enter it (waiting-code) */ + showUserCode: boolean + /** the authenticated account's project picker (choose-project) */ + showProjectPicker: boolean + /** in-flight indicator (validating): a spinner + status copy, no frozen form — + * shown for both connect and choose-project round trips */ + showLoading: boolean +} + +export function cardModel(view: ConnectionView): ConnectionCardModel { + const base = { + state: view.state, + formDisabled: false, + showValidatedAt: false, + showIdentity: false, + showStale: false, + showSessionOnly: false, + showOffline: false, + showDrift: false, + showRawState: false, + showWaiting: false, + showUserCode: false, + showProjectPicker: false, + showLoading: false, + } + switch (view.state) { + case "connected": + return { + ...base, + tone: "success", + showForm: false, + showActions: true, + showValidatedAt: true, + showIdentity: view.identity !== undefined, + showStale: view.stale, + showSessionOnly: view.sessionOnly === true, + showOffline: view.offline === true, + showDrift: view.identityDrift !== undefined, + } + case "needs-key": + return { ...base, tone: "neutral", showForm: true, showActions: false } + case "invalid": + return { ...base, tone: "critical", showForm: true, showActions: false } + case "expired": + // the stored token aged out — re-entering credentials mints a fresh one + return { ...base, tone: "warning", showForm: true, showActions: false } + case "unreachable": + return { ...base, tone: "warning", showForm: true, showActions: false } + case "unentitled": + // project-authorization refusal: the fix is a corrected project id + return { ...base, tone: "critical", showForm: true, showActions: false } + case "validating": + // in-flight (connect or choose-project): a spinner + "validating" copy, + // not a frozen form — clean and identical for both round trips + return { ...base, tone: "pending", showForm: false, showActions: false, showLoading: true } + case "waiting-browser": + // the attempt lives in the user's browser; the card is passive + cancellable + return { ...base, tone: "pending", showForm: false, showActions: false, showWaiting: true } + case "waiting-code": + // code typed on another device; the code itself is non-secret by design + return { + ...base, + tone: "pending", + showForm: false, + showActions: false, + showWaiting: true, + showUserCode: view.userCode !== undefined, + } + case "choose-project": + // authenticated, one choice left; no wire projects → waiting row keeps + // the cancel exit open instead of rendering an empty picker. The connect + // flow isn't done until a project lands, so the status slot keeps the + // loading spinner (not a static pending dot) all the way through the picker. + return { + ...base, + tone: "pending", + showForm: false, + showActions: false, + showLoading: true, + showIdentity: view.identity !== undefined, + showProjectPicker: (view.projects?.length ?? 0) > 0, + showWaiting: (view.projects?.length ?? 0) === 0, + } + default: + // safe fallback (AC4): keep every exit open — re-key, disconnect, or + // revalidate — and surface whatever the wire said as a raw badge. + return { + ...base, + tone: "neutral", + showForm: true, + showActions: true, + showRawState: true, + showValidatedAt: view.validatedAt !== "—", + showIdentity: view.identity !== undefined, + } + } +} + +// --- label templates (170 AC3/AC4): copy whose values (identities, +// timestamps) only exist at render time. The app passes the raw {{slot}} +// template from its i18n dict; the card fills it with DISPLAY values already +// scrubbed by the parser above — never wire-raw objects, never secrets. + +/** Fill {{name}} slots in a label template; unknown slots stay verbatim so + * future copy keeps rendering something honest. */ +export function fillLabelTemplate(template: string, values: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (slot, key: string) => values[key] ?? slot) +} + +/** 170 AC3: the offline last-verified line — "last verified as + * " semantics; a missing identity renders an em dash. */ +export function offlineCopy(view: ConnectionView, template: string): string { + return fillLabelTemplate(template, { at: view.validatedAt, identity: view.identity ?? "—" }) +} + +/** 170 AC4: the drift diff — "answered as , was "; + * the stored record renders unaltered beside what the key answers as now. */ +export function driftCopy(view: ConnectionView, template: string): string { + return fillLabelTemplate(template, { answered: view.identityDrift ?? "—", stored: view.identity ?? "—" }) +} + +export function parseConnectionActionResponse(raw: unknown): ConnectionActionView { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) { + const error = str(data.error) ?? "connection update failed" + return { ok: false, error } + } + if (typeof data.connection !== "object" || data.connection === null || Array.isArray(data.connection)) + return { ok: false, error: "bad_shape: connection missing" } + return { ok: true, connection: parseConnectionEntry(data.connection) } +} + +// --- submit gate (AC3): an empty submission yields NO payload — the card +// fires no request and changes no state when this returns undefined. + +export type BaseUrlTokenPayload = { id: string; base_url: string; token: string } +/** #194 two-step: the credentials form collects ONLY username+password — the + * project is chosen from the picker the server returns, not typed here. */ +export type PasqalCredentialsPayload = { id: string; username: string; password: string } +/** paste-a-token method (#194): a portal-minted token + explicit project — + * the only path where project id stays a typed field (no authenticated + * listing exists before connect) */ +export type PasqalTokenPayload = { id: string; token: string; project_id: string } +export type TokenOnlyPayload = { id: string; token: string } +export type CustomConnectionPayload = { id?: string; name: string; token: string; url?: string } +export type CredentialSubmitPayload = BaseUrlTokenPayload | PasqalCredentialsPayload | PasqalTokenPayload | TokenOnlyPayload + +/** Which credential fields a card's form collects (169): pasqal-cloud takes + * username/password/project_id; every other id keeps base_url + token. + * #327: slack/github/linear take token-only; custom takes name+token+url. */ +export type ConnectionFormKind = "base-url-token" | "pasqal-credentials" | "token-only" | "custom" | "browser" + +export function connectionFormKind(id: string): ConnectionFormKind { + if (id === PASQAL_ID) return "pasqal-credentials" + if (id === GOOGLE_ID || id === GOOGLE_DRIVE_ID) return "token-only" + if (id === SLACK_ID || id === GITHUB_ID || id === LINEAR_ID) return "token-only" + if (isCustomConnectionId(id)) return "custom" + return "base-url-token" +} + +export function tokenOnlySubmitPayload(id: string, token: string): TokenOnlyPayload | undefined { + const key = token.trim() + if (key === "") return undefined + return { id, token: key } +} + +export function customConnectionPayload(name: string, token: string, url?: string): CustomConnectionPayload | undefined { + const n = name.trim() + const key = token.trim() + if (n === "" || key === "") return undefined + const trimmedUrl = url?.trim() + return { name: n, token: key, ...(trimmedUrl ? { url: trimmedUrl } : {}) } +} + +// --- auth-path scaffold (#194): method model + start/choose payload gates. +// The chooser exists only when the wire advertises ≥2 methods; an un-advertised +// method can never produce a payload, so a stale UI cannot start a flow the +// server no longer offers. + +/** Methods this card offers. Wire-advertised when present; otherwise the + * legacy single method implied by the card's form kind. */ +export function connectionAuthMethods(view: ConnectionView): ConnectionAuthMethod[] { + if (view.authMethods && view.authMethods.length > 0) return view.authMethods + if (view.id === GOOGLE_ID || view.id === GOOGLE_DRIVE_ID) return ["token", "browser"] + return connectionFormKind(view.id) === "pasqal-credentials" ? ["credentials"] : ["token"] +} + +/** What the entry area renders for a chosen method: a field set or a start + * button ("none" — browser/device-code hand the work elsewhere). */ +export type MethodEntryKind = ConnectionFormKind | "pasqal-token" | "none" + +export function methodEntryKind(id: string, method: ConnectionAuthMethod): MethodEntryKind { + if (method === "browser" || method === "device-code") return "none" + if (method === "credentials") return connectionFormKind(id) + if (method === "token") { + // Google now supports token like Slack/GitHub — return token-only even though + // connectionFormKind previously returned browser for backwards compat + if (id === GOOGLE_ID || id === GOOGLE_DRIVE_ID) return "token-only" + const kind = connectionFormKind(id) + if (kind === "token-only" || kind === "custom" || kind === "browser") return kind + return id === PASQAL_ID ? "pasqal-token" : "base-url-token" + } + return connectionFormKind(id) +} + +export type StartAuthPayload = { id: string; method: ConnectionAuthMethod } + +/** Start gate: only wire-advertised interactive methods may start a flow. */ +export function startAuthPayload(view: ConnectionView, method: ConnectionAuthMethod): StartAuthPayload | undefined { + if (method !== "browser" && method !== "device-code") return undefined + if (!connectionAuthMethods(view).includes(method)) return undefined + return { id: view.id, method } +} + +export type ChooseProjectPayload = { id: string; project_id: string } + +/** Choose-project gate: the id must be one of the wire-offered projects. */ +export function chooseProjectPayload(view: ConnectionView, projectId: string): ChooseProjectPayload | undefined { + const project = projectId.trim() + if (project === "" || !view.projects?.some((entry) => entry.id === project)) return undefined + return { id: view.id, project_id: project } +} + +/** Pasqal paste-a-token gate (#194): both fields required, both trimmed — + * tokens and project ids have no legitimate edge whitespace. */ +export function pasqalTokenSubmitPayload(id: string, token: string, projectId: string): PasqalTokenPayload | undefined { + const key = token.trim() + const project = projectId.trim() + if (key === "" || project === "") return undefined + return { id, token: key, project_id: project } +} + +export function submitPayload(id: string, baseUrl: string, token: string): BaseUrlTokenPayload | undefined { + const base = baseUrl.trim() + const key = token.trim() + if (base === "" || key === "") return undefined + return { id, base_url: base, token: key } +} + +/** Pasqal submit gate (#194 two-step): username+password required; the + * username is trimmed, the password rides VERBATIM (spaces can be legitimate). + * No project_id — an empty project_id tells the server to list projects for + * the picker. The secret lives only in this payload and the POST body. */ +export function pasqalSubmitPayload( + id: string, + username: string, + password: string, +): PasqalCredentialsPayload | undefined { + const user = username.trim() + if (user === "" || password.trim() === "") return undefined + return { id, username: user, password } +} + +// --- action overlay (AC2): the app layer wraps ONE round trip with this — +// {validating: id} while the POST is in flight, then {terminal: connection} +// parsed from the SAME response. Pure and non-mutating; no polling loop. + +export type ConnectionOverlay = { + /** connection id whose card renders "validating" while a request runs */ + validating?: string + /** terminal connection from the mutation response; replaces the card */ + terminal?: ConnectionView +} + +function replaceConnection(base: ConnectionsView | undefined, entry: ConnectionView): ConnectionsView { + if (!base || !base.ok) return { ok: true, connections: [entry] } + const found = base.connections.some((conn) => conn.id === entry.id) + return { + ok: true, + connections: found + ? base.connections.map((conn) => (conn.id === entry.id ? entry : conn)) + : [...base.connections, entry], + } +} + +function validatingEntry(base: ConnectionsView | undefined, id: string): ConnectionView { + const existing = base?.ok ? base.connections.find((conn) => conn.id === id) : undefined + if (existing) return { ...existing, state: "validating", rawState: "validating" } + return { id, state: "validating", rawState: "validating", validatedAt: "—", stale: false } +} + +export function applyConnectionOverlay( + base: ConnectionsView | undefined, + overlay: ConnectionOverlay, +): ConnectionsView | undefined { + let view = base + if (overlay.terminal) view = replaceConnection(view, overlay.terminal) + // applied last: a request in flight outranks any previously stored terminal + if (overlay.validating !== undefined) view = replaceConnection(view, validatingEntry(view, overlay.validating)) + return view +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.test.ts new file mode 100644 index 00000000..5e342488 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import { buildContextTree, contextKind, vaultRefFromPath, type ContextTurn } from "./context-tree-data" + +const turn = (id: string, refs: ContextTurn["refs"], busy = false): ContextTurn => ({ + id, + refs, + busy, +}) + +describe("contextKind", () => { + test("maps types and extensions", () => { + expect(contextKind({ label: "STRATEGY.md", type: "note", path: "/v/STRATEGY.md" })).toBe("note") + expect(contextKind({ label: "solve.jl", type: "package", path: "/p/solve.jl" })).toBe("source") + expect(contextKind({ label: "transmon", type: "skill" })).toBe("skill") + expect(contextKind({ label: "Explore", type: "agent" })).toBe("agent") + expect(contextKind({ label: "run solve", type: "experiment" })).toBe("action") + expect(contextKind({ label: "arxiv.org", type: "resource" })).toBe("web") + }) +}) + +describe("vaultRefFromPath", () => { + test("splits mount and relative path", () => { + expect(vaultRefFromPath("/Users/k/.amico/vaults/armonissima/insights/x.md")).toEqual({ + mount: "armonissima", + rel: "insights/x.md", + }) + expect(vaultRefFromPath("/Users/k/project/src/x.ts")).toBeUndefined() + }) +}) + +describe("buildContextTree", () => { + test("root → turns → leaves; roman numerals only — never prompt text", () => { + const tree = buildContextTree([ + turn("m1", [ + { label: "setup.jl", type: "package", path: "/p/setup.jl" }, + { label: "transmon", type: "skill" }, + ]), + turn("m2", [{ label: "Explore", type: "agent" }]), + ]) + expect(tree.kind).toBe("root") + expect(tree.children!.map((t) => t.label)).toEqual(["I", "II"]) + expect(tree.children![0].children!.length).toBe(2) + }) + test("consider refs (searches) never enter the tree", () => { + const tree = buildContextTree([turn("m1", [{ label: "saveat", type: "resource", consider: true }])]) + expect(tree.children![0].children!.length).toBe(0) + }) + test("a re-touched file dedups into a recall link, not a duplicate", () => { + const tree = buildContextTree([ + turn("m1", [{ label: "solve.jl", type: "package", path: "/p/solve.jl" }]), + turn("m2", [{ label: "solve.jl", type: "package", path: "/p/solve.jl" }]), + ]) + const [t1, t2] = tree.children! + expect(t1.children!.length).toBe(1) + expect(t2.children!.length).toBe(0) + expect(t2.recalls).toEqual([t1.children![0].id]) + }) + test("busy turn puts the live cursor on its newest leaf", () => { + const tree = buildContextTree([ + turn( + "m1", + [ + { label: "a.md", type: "note", path: "/p/a.md" }, + { label: "b.md", type: "note", path: "/p/b.md" }, + ], + true, + ), + ]) + const leaves = tree.children![0].children! + expect(leaves[1].active).toBe(true) + expect(leaves[0].active).toBeUndefined() + }) + test("vault paths are flagged for the Vault panel", () => { + const tree = buildContextTree([ + turn("m1", [{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" }]), + ]) + expect(tree.children![0].children![0].vault).toBe(true) + }) + test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => { + const tree = buildContextTree( + [ + turn("m1", [ + { label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" }, + { label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" }, + { label: "solve.jl", type: "package", path: "/p/solve.jl" }, + ]), + ], + { vaultLocked: (mount) => mount === "armonissima" }, + ) + const [team, personal, project] = tree.children![0].children! + expect(team.locked).toBe(true) + expect(personal.locked).toBe(false) + expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted + }) + test("marathon sessions fold old turns into one earlier branch", () => { + const turns = Array.from({ length: 30 }, (_, i) => + turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]), + ) + const tree = buildContextTree(turns) + expect(tree.children![0].id).toBe("turn-earlier") + expect(tree.children!.length).toBe(25) // fold + last 24 + expect(tree.children![0].children!.length).toBe(6) // 30 - 24 folded refs + // a later re-touch of a folded file recalls into the fold, not a dup + const again = buildContextTree([...turns, turn("m30", [{ label: "f0.md", type: "note", path: "/p/f0.md" }])]) + const last = again.children![again.children!.length - 1] + expect(last.children!.length).toBe(0) + expect(last.recalls!.length).toBe(1) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.ts new file mode 100644 index 00000000..25ee6739 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-data.ts @@ -0,0 +1,172 @@ +// amicode: THE single mapping from a session's turns to the context tree the +// top panel renders — pure and headless so the shape is testable without the +// sync store. Turns come in message order; each ref is one tool touch already +// mapped by brain-ref. Commits only: searches/globs are transient scouting, +// not context the agent holds, so `consider` refs never enter the tree. +// +// Dedup law: a file/skill/agent enters the tree ONCE, under the turn that +// first pulled it in. A later turn touching the same thing draws a recall +// link back to the existing node instead of duplicating it — the tree stays +// a map of distinct context, not a log. +import type { ContextTreeKind, ContextTreeNodeInput } from "./context-tree-engine" + +export type ContextRef = { + label: string + type?: string + path?: string + consider?: boolean +} + +export type ContextTurn = { + id: string + refs: ContextRef[] + /** the turn is still running — its newest touch wears the live cursor */ + busy?: boolean +} + +const ROMAN = [ + "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", + "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", +] +const roman = (i: number) => ROMAN[i % ROMAN.length] + +/** Beyond this many turns the oldest fold into one "· earlier ·" branch so a + * marathon session stays a readable tree, not a wall. */ +const TURN_CAP = 24 + +const SOURCE_EXT = /\.(jl|py|ts|tsx|js|jsx|json|toml|yaml|yml|sh|css|html|rs|go|c|h|cpp)$/i +const NOTE_EXT = /\.(md|txt|tex)$/i + +export function contextKind(ref: ContextRef): ContextTreeKind { + if (ref.type === "skill") return "skill" + if (ref.type === "agent") return "agent" + if (ref.type === "experiment") return "action" + if (ref.type === "note") return "note" + if (ref.path) { + if (NOTE_EXT.test(ref.path)) return "note" + if (SOURCE_EXT.test(ref.path)) return "source" + return "resource" + } + if (ref.type === "package") return "source" + // webfetch hostnames and bash verbs arrive as pathless resources + if (/^[\w.-]+\.[a-z]{2,}$/i.test(ref.label)) return "web" + return "resource" +} + +/** A path inside an attached vault mount → {mount, rel} so the host can open + * it through the Vault panel instead of a project file tab. */ +export function vaultRefFromPath(path: string): { mount: string; rel: string } | undefined { + const m = path.match(/\/\.amico\/vaults\/([^/]+)\/(.+)$/) + if (m) return { mount: m[1], rel: m[2] } + return undefined +} + +const dedupKey = (ref: ContextRef, kind: ContextTreeKind) => + ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}` + +export type ContextTreeOpts = { + rootLabel?: string + /** true when the mount refuses browsing (proprietary data/software) — its + * leaves render locked: dimmed, padlocked, not openable */ + vaultLocked?: (mount: string) => boolean +} + +export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput { + const root: ContextTreeNodeInput = { + id: "root", + label: opts.rootLabel ?? "amico", + kind: "root", + children: [], + } + let visible = turns + let offset = 0 + if (turns.length > TURN_CAP) { + // fold the overflow into one quiet branch; its refs still claim dedup + // keys so later recalls point somewhere real + const folded = turns.slice(0, turns.length - TURN_CAP) + visible = turns.slice(turns.length - TURN_CAP) + offset = folded.length + const earlier: ContextTreeNodeInput = { + id: "turn-earlier", + label: `· ${folded.length} earlier turns ·`, + kind: "turn", + children: [], + recalls: [], + } + root.children!.push(earlier) + const seen = seenOf(root) + for (const t of folded) + for (const ref of t.refs) { + if (ref.consider) continue + const kind = contextKind(ref) + const key = dedupKey(ref, kind) + if (seen.has(key)) continue + seen.set(key, `ctx-${seen.size}`) + earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts)) + } + } + const seen = seenOf(root) + let lastLeaf: ContextTreeNodeInput | undefined + visible.forEach((turn, i) => { + const n = offset + i + // turn branches are anonymous roman numerals BY DESIGN (Kate, 2026-07-28): + // the tree surfaces only the context amico checks and the scripts it + // writes — user prompt text must never enter it. Don't re-add excerpts. + const node: ContextTreeNodeInput = { + id: `turn-${turn.id}`, + label: roman(n), + kind: "turn", + children: [], + recalls: [], + } + for (const ref of turn.refs) { + if (ref.consider) continue + const kind = contextKind(ref) + const key = dedupKey(ref, kind) + const known = seen.get(key) + if (known) { + if (!node.recalls!.includes(known)) node.recalls!.push(known) + continue + } + const id = `ctx-${seen.size}` + seen.set(key, id) + const leaf = leafOf(ref, kind, id, opts) + node.children!.push(leaf) + lastLeaf = leaf + } + // a turn that only recalled things it already holds still charts — the + // recall links ARE its story + root.children!.push(node) + if (turn.busy) { + const cursor = lastLeaf && node.children!.includes(lastLeaf) ? lastLeaf : node + cursor.active = true + } + }) + return root +} + +function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput { + const vault = ref.path ? vaultRefFromPath(ref.path) : undefined + return { + id, + label: ref.label.slice(0, 32), + kind, + path: ref.path, + vault: !!vault, + locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined, + } +} + +/** Rebuild the dedup index from a partially-built tree (used by the fold). */ +function seenOf(root: ContextTreeNodeInput): Map { + const seen = new Map() + const walk = (n: ContextTreeNodeInput) => { + for (const c of n.children ?? []) { + if (c.kind !== "turn" && c.kind !== "root") + seen.set(c.path ? `p:${c.path}` : `${c.kind}:${c.label.toLowerCase()}`, c.id) + walk(c) + } + } + walk(root) + return seen +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.test.ts new file mode 100644 index 00000000..702d7d54 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test" +import { createContextTreeEngine, type ContextTreeEngineOptions, type ContextTreeNodeInput } from "./context-tree-engine" + +/* Headless like brain-engine.test.ts: no DOM, no rAF, no matchMedia. The stub + canvas has no addEventListener, so pointer wiring is skipped — interaction + logic is exercised through pick()/locate() directly. */ + +type Call = { method: string; args: unknown[] } +function recordingCtx() { + const calls: Call[] = [] + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, args }) + if (method === "measureText") return { width: 42 } + return undefined + } + const ctx: Record = { calls } + for (const m of [ + "setTransform", + "clearRect", + "fillRect", + "beginPath", + "moveTo", + "lineTo", + "bezierCurveTo", + "stroke", + "fill", + "arc", + "setLineDash", + "fillText", + "measureText", + ]) + ctx[m] = record(m) + return ctx as { calls: Call[] } & Record +} +function stubCanvas(ctx: unknown) { + return { + clientWidth: 0, + clientHeight: 0, + width: 0, + height: 0, + getContext: () => ctx, + } as unknown as HTMLCanvasElement +} +function makeEngine(opts: Partial = {}) { + const ctx = recordingCtx() + const engine = createContextTreeEngine(stubCanvas(ctx), { + scheme: "dark", + reduceMotion: true, + animate: false, + size: { width: 800, height: 208 }, + ...opts, + }) + return { engine, ctx } +} + +const TREE: ContextTreeNodeInput = { + id: "root", + label: "amico", + kind: "root", + children: [ + { + id: "turn-1", + label: "I", + kind: "turn", + children: [ + { id: "f-strategy", label: "STRATEGY.md", kind: "note", path: "STRATEGY.md", vault: true }, + { id: "f-solve", label: "solve.jl", kind: "source", path: "scripts/solve.jl" }, + { id: "s-transmon", label: "transmon", kind: "skill" }, + ], + }, + { + id: "turn-2", + label: "II", + kind: "turn", + recalls: ["f-solve"], + children: [{ id: "a-explore", label: "Explore", kind: "agent", active: true }], + }, + ], +} + +describe("setTree", () => { + test("counts nodes, tree edges, recall links, and depth", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + const s = engine.stats() + expect(s.nodes).toBe(7) // root + 2 turns + 4 leaves + expect(s.edges).toBe(6) // tree edges: every non-root has exactly one + expect(s.recalls).toBe(1) + expect(s.depth).toBe(2) + }) + test("re-setting keeps known ids and absorbs new ones", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + const before = engine.locate("f-solve") + const grown = structuredClone(TREE) + grown.children![1].children!.push({ id: "f-new", label: "new.md", kind: "note", path: "new.md" }) + engine.setTree(grown) + expect(engine.stats().nodes).toBe(8) + // the force settle re-equilibrates around the newcomer — known ids + // persist (locate resolves), exact positions legitimately shift + expect(before).toBeDefined() + expect(engine.locate("f-solve")).toBeDefined() + expect(engine.locate("f-new")).toBeDefined() + }) + test("recall to an unknown id is dropped, not fatal", () => { + const { engine } = makeEngine() + const t = structuredClone(TREE) + t.children![1].recalls = ["ghost-id"] + engine.setTree(t) + expect(engine.stats().recalls).toBe(0) + }) +}) + +describe("pick / locate", () => { + test("pick at a node's location returns its id; empty space returns undefined", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const p = engine.locate("f-strategy")! + expect(engine.pick(p.x, p.y)).toBe("f-strategy") + expect(engine.pick(-9999, -9999)).toBeUndefined() + }) + test("locate of an unknown id is undefined", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + expect(engine.locate("nope")).toBeUndefined() + }) +}) + +describe("render", () => { + test("a tick paints the frame: transform, clear, edges, circles, labels", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const methods = new Set(ctx.calls.map((c) => c.method)) + expect(methods.has("setTransform")).toBe(true) + expect(methods.has("clearRect")).toBe(true) + expect(methods.has("lineTo")).toBe(true) // straight Obsidian-style edges + expect(methods.has("arc")).toBe(true) + expect(methods.has("fillText")).toBe(true) + }) + test("a NaN timestamp is refused (would poison the clock)", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(Number.NaN) + expect(ctx.calls.length).toBe(0) + }) + test("recall edges render dashed", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const dashes = ctx.calls.filter((c) => c.method === "setLineDash" && (c.args[0] as number[]).length > 0) + expect(dashes.length).toBeGreaterThan(0) + }) +}) + +describe("theme / lifecycle", () => { + test("setTheme swaps the scheme losslessly", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.setTheme("light") + expect(engine.stats().scheme).toBe("light") + expect(engine.stats().nodes).toBe(7) + }) + test("highlight of unknown label is a no-op; known label survives a tick", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.highlight("does-not-exist") + engine.highlight("solve.jl") + engine.tick(16) + expect(engine.stats().nodes).toBe(7) + }) + test("resize with explicit dims scales the backing store by DPR", () => { + const { engine } = makeEngine() + const canvas = stubCanvas(recordingCtx()) + const e2 = createContextTreeEngine(canvas, { reduceMotion: true, animate: false }) + e2.resize(400, 100) + expect((canvas as unknown as { width: number }).width).toBeGreaterThanOrEqual(400) + e2.destroy() + engine.destroy() + }) + test("destroy is idempotent and setTree after destroy is ignored", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.destroy() + engine.destroy() + engine.setTree(structuredClone(TREE)) + expect(engine.stats().nodes).toBe(7) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.ts new file mode 100644 index 00000000..0717d3c5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/context-tree-engine.ts @@ -0,0 +1,803 @@ +/* ================================================================ + the context tree — what amico is holding in mind (native engine) + + The knowledge graph, moved to the header panel and redesigned around + the session instead of the vault sample — laid out as an ORGANIC, + Obsidian-like graph (Kate 2026-07-27): root = amico pinned at center, + the session's turns orbiting it, and each turn's context — markdown, + source, skills, agents, web references — clustering around its turn + under a deterministic force settle. A leaf touched again by a later + turn is not duplicated — the later turn draws a recall link back to + it, and the springs pull shared context between its turns, so the + cloud's SHAPE shows what the session keeps returning to. + + Unlike the ambient brain (brain-engine.ts), this surface is + INTERACTIVE: nodes hit-test, hover raises their label, and clicking a + file node hands its path to the host (onSelect) to open the real + markdown/source. Camera: auto-fit until the user pans/zooms; wheel + zooms about the cursor, drag pans, double-click refits. + + Shared laws with the brain: circles only; #fff676 belongs to the live + position alone; both palettes carried in-module so a theme flip is a + repaint, never a reload; DPR re-checked when the device-pixel-ratio + itself changes (webview zoom), not only on box resize. + ================================================================ */ + +export type ContextTreeScheme = "dark" | "light" + +export type ContextTreeKind = + | "root" + | "turn" + | "note" // markdown / prose + | "source" // code + | "skill" + | "agent" + | "web" + | "action" // solves, bash verbs + | "resource" + +export type ContextTreeNodeInput = { + id: string + label: string + kind: ContextTreeKind + /** full or repo-relative file path — presence makes the node openable */ + path?: string + /** the file lives in a vault mount (open via the Vault panel, not a tab) */ + vault?: boolean + /** the vault refuses browsing (proprietary data/software) — the node renders + * dimmed with a padlock and is NOT openable, path or not */ + locked?: boolean + /** where the agent currently works — wears the thought-color cursor */ + active?: boolean + children?: ContextTreeNodeInput[] + /** ids of earlier nodes this node re-touched — drawn as recall links */ + recalls?: string[] +} + +export type ContextTreeSelection = { + id: string + label: string + kind: ContextTreeKind + path?: string + vault?: boolean + locked?: boolean +} + +export interface ContextTreeEngineOptions { + scheme?: ContextTreeScheme + /** override prefers-reduced-motion (tests) */ + reduceMotion?: boolean + /** drive the render loop via requestAnimationFrame (default true; tests call tick()) */ + animate?: boolean + /** layout fallback when the canvas has no measured size yet */ + size?: { width: number; height: number } + onSelect?: (node: ContextTreeSelection) => void + onHover?: (node: ContextTreeSelection | null) => void +} + +export interface ContextTreeStats { + scheme: ContextTreeScheme + nodes: number + edges: number + recalls: number + depth: number +} + +export interface ContextTreeEngine { + /** declaratively replace the tree; known ids keep their motion state */ + setTree(root: ContextTreeNodeInput): void + /** lossless palette swap + repaint */ + setTheme(scheme: ContextTreeScheme): void + /** a glance from the log: ring the node whose label matches */ + highlight(label: string): void + /** keyboard focus: ring + camera-glance a node by ID (host-driven nav) */ + focus(id: string): void + /** re-fit the camera to the whole tree (also clears manual pan/zoom) */ + fit(): void + /** omit both to re-measure from the canvas box */ + resize(width?: number, height?: number): void + tick(nowMs: number): void + pause(): void + resume(): void + destroy(): void + /** screen-space hit test (CSS px) — the id under the point, if any */ + pick(x: number, y: number): string | undefined + /** screen-space position of a node (CSS px) — tests + host tooltips */ + locate(id: string): { x: number; y: number } | undefined + stats(): ContextTreeStats +} + +/* ---------- tokens: both palettes in-module (same idiom as brain-engine) ---------- */ +type Palette = { + fg: string + edge: string + recall: string + nodeFill: string + nodeBorder: string + thought: string + labelHalo: string + kind: Record +} +const PALETTES: Record = { + dark: { + fg: "#d6d6d2", + edge: "rgba(255, 255, 255, 0.16)", + recall: "rgba(255, 255, 255, 0.10)", + nodeFill: "#242423", + nodeBorder: "rgba(255, 255, 255, 0.24)", + thought: "#fff676", + labelHalo: "rgba(19, 19, 18, 0.72)", + kind: { + root: "#fff676", + turn: "#d6d6d2", + note: "#3794ff", + source: "#4d9e51", + skill: "#9b6bc4", + agent: "#c9c9c4", + web: "#c17800", + action: "#c17800", + resource: "#8a8a86", + }, + }, + light: { + fg: "#22221f", + edge: "rgba(0, 0, 0, 0.18)", + recall: "rgba(0, 0, 0, 0.10)", + nodeFill: "#eae7dd", + nodeBorder: "rgba(0, 0, 0, 0.26)", + thought: "#8f8000", + labelHalo: "rgba(250, 249, 246, 0.78)", + kind: { + root: "#8f8000", + turn: "#22221f", + note: "#1866c9", + source: "#33753a", + skill: "#7b4fa8", + agent: "#4a4a44", + web: "#8f5800", + action: "#8f5800", + resource: "#77776f", + }, + }, +} + +/** The kind→color mapping, for host chrome (the panel legend) — single source + * with the render palette so the dots can never drift from the canvas. */ +export function contextTreeKindColor(scheme: ContextTreeScheme, kind: ContextTreeKind): string { + return PALETTES[scheme].kind[kind] ?? PALETTES[scheme].fg +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "") + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} +function rgba(color: string, a: number): string { + if (color.startsWith("rgba") || color.startsWith("rgb")) return color + const [r, g, b] = hexToRgb(color) + return `rgba(${r},${g},${b},${a})` +} + +/* ---------- internal shapes ---------- */ +interface TNode { + id: string + label: string + kind: ContextTreeKind + path?: string + vault?: boolean + locked?: boolean + active: boolean + depth: number + // world targets (tidy layout) and animated positions + tx: number + ty: number + x: number + y: number + alpha: number + flash: number + ringT: number + half: number + parent?: TNode +} +interface TEdge { + s: TNode + t: TNode + recall: boolean +} + +const TURN_RING = 150 // base rest distance root -> turn (grows with turn count) +const LEAF_REST = 70 // rest distance turn -> leaf +const RECALL_REST = 200 // weak spring across recall links +const SETTLE_STEPS = 240 +const HALF: Record = { root: 8, turn: 5.5, agent: 5 } +// deterministic jitter — no Math.random (identical trees settle identically) +const jitter = (i: number) => Math.sin(i * 12.9898) * 0.5 + +export function createContextTreeEngine( + canvas: HTMLCanvasElement, + opts: ContextTreeEngineOptions = {}, +): ContextTreeEngine { + let scheme: ContextTreeScheme = opts.scheme === "light" ? "light" : "dark" + let css: Palette = PALETTES[scheme] + let reduceMotion = + opts.reduceMotion ?? (typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches) + let motionQuery: MediaQueryList | null = null + const onMotionChange = (e: MediaQueryListEvent) => { + reduceMotion = e.matches + } + if (opts.reduceMotion === undefined && typeof matchMedia !== "undefined") { + motionQuery = matchMedia("(prefers-reduced-motion: reduce)") + motionQuery.addEventListener("change", onMotionChange) + } + const animate = opts.animate ?? true + const ctx = canvas.getContext("2d") + + let nodes: TNode[] = [] + let byId = new Map() + let edges: TEdge[] = [] + let recallCount = 0 + let maxDepth = 0 + + /* ---------- canvas, DPR, camera ---------- */ + let W = 0, + H = 0, + DPR = 1 + function resize(width?: number, height?: number) { + DPR = Math.min((typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1) || 1, 2) + W = width ?? canvas.clientWidth ?? 0 + H = height ?? canvas.clientHeight ?? 0 + if (!Number.isFinite(W) || !Number.isFinite(H) || W < 1 || H < 1) { + W = opts.size?.width ?? 800 + H = opts.size?.height ?? 208 + } + canvas.width = W * DPR + canvas.height = H * DPR + } + resize() + let ro: ResizeObserver | null = null + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => resize()) + ro.observe(canvas) + } + // webview/app zoom changes devicePixelRatio WITHOUT changing the CSS box — + // ResizeObserver stays silent and the backing store goes stale (blurry). + // Watch the dppx itself and re-arm after every change. + let dprQuery: MediaQueryList | null = null + const onDprChange = () => { + resize() + armDprWatch() + } + function armDprWatch() { + if (typeof matchMedia === "undefined" || typeof devicePixelRatio === "undefined") return + dprQuery?.removeEventListener("change", onDprChange) + dprQuery = matchMedia(`(resolution: ${devicePixelRatio}dppx)`) + dprQuery.addEventListener("change", onDprChange) + } + armDprWatch() + + // fit-derived camera with manual override; k is world→screen scale + const cam = { x: 0, y: 0, k: 1, tx: 0, ty: 0, tk: 1 } + let userCam = false + function fitTarget() { + if (!nodes.length) return + let minX = 1e9, + maxX = -1e9, + minY = 1e9, + maxY = -1e9 + for (const n of nodes) { + minX = Math.min(minX, n.tx) + maxX = Math.max(maxX, n.tx) + minY = Math.min(minY, n.ty) + maxY = Math.max(maxY, n.ty) + } + const padX = 56, // room for centered labels at the cloud's edges + padY = 32 + const spanX = Math.max(maxX - minX, 1) + const spanY = Math.max(maxY - minY, 1) + cam.tk = Math.min((W - padX * 2) / spanX, (H - padY * 2) / spanY, 1.6) + cam.tk = Math.max(cam.tk, 0.04) + cam.tx = (minX + maxX) / 2 + cam.ty = (minY + maxY) / 2 + } + const sx = (wx: number) => (wx - cam.x) * cam.k + W / 2 + const sy = (wy: number) => (wy - cam.y) * cam.k + H / 2 + + /* ---------- organic layout: deterministic force settle ---------- + Root pinned at center, turns seeded on a ring in session order, leaves + fanned outward from their turn — then a fixed number of repel+spring + iterations (no randomness, so the same tree always settles the same + way). The settle runs synchronously here; the render loop only LERPS + nodes toward their new equilibrium, which is what gives the cloud its + Obsidian-like drift when context arrives. */ + function layout(root: ContextTreeNodeInput) { + const nextNodes: TNode[] = [] + const nextById = new Map() + const nextEdges: TEdge[] = [] + const recallPairs: [string, string][] = [] + maxDepth = 0 + const visit = (input: ContextTreeNodeInput, depth: number, parent?: TNode): TNode => { + maxDepth = Math.max(maxDepth, depth) + const prior = byId.get(input.id) + const n: TNode = prior ?? { + id: input.id, + label: input.label, + kind: input.kind, + active: false, + depth, + tx: 0, + ty: 0, + // newborns condense out of their parent's position, not (0,0) + x: parent ? parent.x : 0, + y: parent ? parent.y : 0, + alpha: 0, + flash: reduceMotion ? 0 : 1, + ringT: -1, + half: 4, + } + n.label = input.label + n.kind = input.kind + n.path = input.path + n.vault = input.vault + n.locked = input.locked + n.active = !!input.active + n.depth = depth + n.half = HALF[input.kind] ?? 4 + n.parent = parent + nextNodes.push(n) + nextById.set(n.id, n) + for (const c of input.children ?? []) { + const cn = visit(c, depth + 1, n) + nextEdges.push({ s: n, t: cn, recall: false }) + } + for (const r of input.recalls ?? []) recallPairs.push([input.id, r]) + return n + } + const rootNode = visit(root, 0, undefined) + recallCount = 0 + for (const [from, to] of recallPairs) { + const s = nextById.get(from) + const t = nextById.get(to) + if (!s || !t || s === t) continue + nextEdges.push({ s, t, recall: true }) + recallCount++ + } + + // --- seed (deterministic): root center; turns on the ring; leaves fanned + const turns = nextNodes.filter((n) => n.depth === 1) + // busy sessions need a wider orbit or the clusters shove each other apart + const ring = Math.min(TURN_RING + turns.length * 12, 320) + rootNode.tx = 0 + rootNode.ty = 0 + turns.forEach((t, i) => { + const ang = -Math.PI / 2 + (i * Math.PI * 2) / Math.max(turns.length, 3) + t.tx = Math.cos(ang) * ring + t.ty = Math.sin(ang) * ring + const leaves = nextNodes.filter((n) => n.parent === t) + leaves.forEach((l, j) => { + const spread = Math.min(Math.PI * 0.9, 0.5 * Math.max(leaves.length - 1, 1)) + const la = ang - spread / 2 + (leaves.length > 1 ? (j * spread) / (leaves.length - 1) : 0) + l.tx = t.tx + Math.cos(la) * (LEAF_REST + jitter(i * 31 + j) * 14) + l.ty = t.ty + Math.sin(la) * (LEAF_REST + jitter(i * 17 + j * 3) * 14) + }) + }) + + // --- settle: pairwise repulsion + edge springs + weak center gravity + const index = new Map() + nextNodes.forEach((n, i) => index.set(n, i)) + for (let it = 0; it < SETTLE_STEPS; it++) { + const fx = new Array(nextNodes.length).fill(0) + const fy = new Array(nextNodes.length).fill(0) + for (let a = 0; a < nextNodes.length; a++) { + for (let b = a + 1; b < nextNodes.length; b++) { + const A = nextNodes[a] + const B = nextNodes[b] + let dx = A.tx - B.tx + let dy = A.ty - B.ty + let d2 = dx * dx + dy * dy + if (d2 < 1) { + d2 = 1 + dx = jitter(a * 7 + b) || 0.5 + dy = jitter(a - b * 3) || -0.5 + } + const f = Math.min(1400 / d2, 6) + const d = Math.sqrt(d2) + fx[a] += (dx / d) * f + fy[a] += (dy / d) * f + fx[b] -= (dx / d) * f + fy[b] -= (dy / d) * f + } + } + for (const e of nextEdges) { + const ai = index.get(e.s)! + const bi = index.get(e.t)! + const dx = e.t.tx - e.s.tx + const dy = e.t.ty - e.s.ty + const d = Math.max(Math.sqrt(dx * dx + dy * dy), 0.01) + const rest = e.recall ? RECALL_REST : e.s.depth === 0 ? ring : LEAF_REST + const k = e.recall ? 0.004 : 0.02 + const f = (d - rest) * k + fx[ai] += (dx / d) * f + fy[ai] += (dy / d) * f + fx[bi] -= (dx / d) * f + fy[bi] -= (dy / d) * f + } + for (let i = 0; i < nextNodes.length; i++) { + const n = nextNodes[i] + if (n.depth === 0) continue // the root IS the world origin + fx[i] += -n.tx * 0.004 + fy[i] += -n.ty * 0.004 + n.tx += Math.max(-7, Math.min(7, fx[i])) + n.ty += Math.max(-7, Math.min(7, fy[i])) + } + } + + nodes = nextNodes + byId = nextById + edges = nextEdges + if (reduceMotion) + for (const n of nodes) { + n.x = n.tx + n.y = n.ty + n.alpha = 1 + } + if (!userCam) fitTarget() + } + + /* ---------- interaction ---------- */ + let hovered: TNode | null = null + function pick(px: number, py: number): string | undefined { + // topmost-last: later nodes (deeper/younger) win overlapping hits + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i] + const dx = px - sx(n.x) + const dy = py - sy(n.y) + const r = Math.max(n.half * cam.k, 5) + 4 + if (dx * dx + dy * dy <= r * r) return n.id + } + return undefined + } + const selection = (n: TNode): ContextTreeSelection => ({ + id: n.id, + label: n.label, + kind: n.kind, + path: n.path, + vault: n.vault, + locked: n.locked, + }) + // openable = the click actually goes somewhere; everything else (turns, + // skills, agents, actions, locked vault files) must not wear the pointer + const openable = (n: TNode) => !!n.path && !n.locked + let dragging = false + let dragMoved = false + let lastPX = 0, + lastPY = 0 + const local = (e: MouseEvent) => { + const rect = typeof canvas.getBoundingClientRect === "function" ? canvas.getBoundingClientRect() : undefined + if (!rect) return { x: e.clientX, y: e.clientY } + // the rect is post-zoom/post-transform (CSS zoom on the root scales it) + // while W/H are the engine's pre-zoom CSS px — normalize so hit-testing + // stays true at any app zoom level + const kx = rect.width > 0 && W > 0 ? rect.width / W : 1 + const ky = rect.height > 0 && H > 0 ? rect.height / H : 1 + return { x: (e.clientX - rect.left) / kx, y: (e.clientY - rect.top) / ky } + } + const onPointerDown = (e: PointerEvent) => { + dragging = true + dragMoved = false + const p = local(e) + lastPX = p.x + lastPY = p.y + } + const onPointerMove = (e: PointerEvent) => { + const p = local(e) + if (dragging) { + const dx = p.x - lastPX + const dy = p.y - lastPY + if (Math.abs(dx) + Math.abs(dy) > 2) dragMoved = true + if (dragMoved) { + userCam = true + cam.tx -= dx / cam.k + cam.ty -= dy / cam.k + cam.x = cam.tx + cam.y = cam.ty + lastPX = p.x + lastPY = p.y + } + return + } + const id = pick(p.x, p.y) + const n = id ? (byId.get(id) ?? null) : null + if (n !== hovered) { + hovered = n + // pointer only where a click opens something; a locked node says so + if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab" + opts.onHover?.(n ? selection(n) : null) + } + } + const onPointerUp = (e: PointerEvent) => { + const wasDrag = dragging && dragMoved + dragging = false + if (wasDrag) return + const p = local(e) + const id = pick(p.x, p.y) + const n = id ? byId.get(id) : undefined + // only openable nodes acknowledge the click — a ring on a dead-end node + // would promise an action that never comes + if (n && openable(n)) { + n.ringT = beatNow + opts.onSelect?.(selection(n)) + } + } + const onPointerLeave = () => { + dragging = false + if (hovered) { + hovered = null + opts.onHover?.(null) + } + } + const onWheel = (e: WheelEvent) => { + e.preventDefault() + userCam = true + const p = local(e) + const factor = Math.exp(-e.deltaY * 0.0015) + const k0 = cam.k + const k1 = Math.min(Math.max(k0 * factor, 0.15), 4) + // zoom about the cursor: keep the world point under it fixed + const wx = (p.x - W / 2) / k0 + cam.x + const wy = (p.y - H / 2) / k0 + cam.y + cam.k = k1 + cam.tk = k1 + cam.x = wx - (p.x - W / 2) / k1 + cam.y = wy - (p.y - H / 2) / k1 + cam.tx = cam.x + cam.ty = cam.y + } + const onDblClick = () => { + userCam = false + fitTarget() + } + const listeners: [string, EventListener][] = [ + ["pointerdown", onPointerDown as EventListener], + ["pointermove", onPointerMove as EventListener], + ["pointerup", onPointerUp as EventListener], + ["pointerleave", onPointerLeave as EventListener], + ["wheel", onWheel as EventListener], + ["dblclick", onDblClick as EventListener], + ] + if (typeof canvas.addEventListener === "function") + for (const [ev, fn] of listeners) canvas.addEventListener(ev, fn, ev === "wheel" ? { passive: false } : undefined) + + /* ---------- render ---------- */ + let halted = false + let destroyed = false + let rafId = 0 + let lastMs = 0 + let beatNow = 0 // seconds-ish clock for rings + let glance: { id: string; until: number } | null = null + + function tick(nowMs: number) { + if (halted || !ctx) return + if (!Number.isFinite(nowMs)) return + try { + drawFrame(nowMs) + } catch (err) { + halted = true + console.error("[amico-context-tree] halted on render error:", err) + return + } + if (!halted && animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + } + + function drawFrame(nowMs: number) { + if (!ctx) return + const dt = Math.min(nowMs - (lastMs || nowMs), 50) + lastMs = nowMs + beatNow += dt / 1000 + + const ease = reduceMotion ? 1 : 1 - Math.pow(0.0015, dt / 1000) + cam.x += (cam.tx - cam.x) * ease + cam.y += (cam.ty - cam.y) * ease + cam.k += (cam.tk - cam.k) * ease + for (const n of nodes) { + n.x += (n.tx - n.x) * ease + n.y += (n.ty - n.y) * ease + n.alpha = Math.min(n.alpha + dt / 320, 1) + if (n.flash > 0) n.flash = Math.max(n.flash - dt / 600, 0) + } + + ctx.setTransform(DPR, 0, 0, DPR, 0, 0) + ctx.clearRect(0, 0, W, H) // transparent ground — the panel surface shows through + + // ---- edges: straight lines (the Obsidian idiom); recall links dashed; + // the hovered node's edges brighten so its neighborhood reads at a glance + for (const e of edges) { + const x1 = sx(e.s.x), + y1 = sy(e.s.y), + x2 = sx(e.t.x), + y2 = sy(e.t.y) + if ((x1 < -60 && x2 < -60) || (x1 > W + 60 && x2 > W + 60) || (y1 < -60 && y2 < -60) || (y1 > H + 60 && y2 > H + 60)) + continue + const a = Math.min(e.s.alpha, e.t.alpha) + if (a <= 0.02) continue + const near = hovered !== null && (e.s === hovered || e.t === hovered) + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + if (e.recall) { + ctx.strokeStyle = rgba(css.fg, (near ? 0.3 : 0.1) * a) + ctx.setLineDash([3, 4]) + } else { + ctx.strokeStyle = rgba(css.fg, (near ? 0.42 : 0.16) * a) + } + ctx.lineWidth = near ? 1.5 : 1 + ctx.stroke() + ctx.setLineDash([]) + } + + // ---- nodes + ctx.textBaseline = "middle" + for (const n of nodes) { + const x = sx(n.x), + y = sy(n.y) + if (x < -80 || x > W + 80 || y < -40 || y > H + 40) continue + const color = css.kind[n.kind] ?? css.fg + const isRoot = n.kind === "root" + let half = Math.max(n.half * Math.min(cam.k, 1.4), isRoot ? 5 : 3) + if (n.flash > 0) half *= 1 + n.flash * 0.35 + const hoveredNow = hovered === n + + ctx.globalAlpha = n.alpha + ctx.beginPath() + ctx.arc(x, y, half, 0, Math.PI * 2) + if (isRoot) { + // the root wears the thought color — the one yellow on the surface + ctx.fillStyle = css.thought + ctx.fill() + } else if (n.kind === "turn") { + ctx.fillStyle = css.nodeFill + ctx.fill() + ctx.strokeStyle = rgba(color, 0.85) + ctx.lineWidth = 1 + ctx.stroke() + } else { + // locked (non-browsable vault) leaves read clearly non-interactive: + // reduced emphasis, plus the padlock by the label (shape, not color) + ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75) + ctx.fill() + ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9) + ctx.lineWidth = 1 + ctx.stroke() + } + if (n.kind === "agent") { + // agents wear the double border, same as on the brain + ctx.beginPath() + ctx.arc(x, y, half + 2.5, 0, Math.PI * 2) + ctx.strokeStyle = rgba(color, 0.5) + ctx.lineWidth = 1 + ctx.stroke() + } + // active cursor: where the agent currently works + if (n.active) { + const rr = half + 4 + (reduceMotion ? 0 : Math.sin(nowMs / 600) * 1.5) + ctx.beginPath() + ctx.arc(x, y, rr, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, 0.85) + ctx.lineWidth = 1.5 + ctx.stroke() + } + // click/glance ring + if (n.ringT >= 0) { + const rt = (beatNow - n.ringT) / 0.7 + if (rt >= 0 && rt < 1) { + ctx.beginPath() + ctx.arc(x, y, half + rt * half * 2.6, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, (1 - rt) * 0.8) + ctx.lineWidth = 1 + ctx.stroke() + } else if (rt >= 1) n.ringT = -1 + } + // labels: centered under the node (the Obsidian idiom); turns + root + // always readable, leaves at rest tone — raised on hover, keyboard + // focus, a log glance, or being the hovered node's neighbor + const glancedNow = glance !== null && glance.id === n.id + const nearHover = hovered !== null && (n.parent === hovered || hovered.parent === n) + const la = + isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6 + ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace` + const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label + const lockW = n.locked ? 10 : 0 + const tw = ctx.measureText(text).width + const lx = x - (tw + lockW) / 2, + ly = y + half + 10 + ctx.fillStyle = css.labelHalo + ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14) + const inkA = Math.min(la, 1) * n.alpha + if (n.locked) { + // padlock: shackle arc over a body — the non-color "cannot open" signal + ctx.strokeStyle = rgba(css.fg, inkA) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0) + ctx.stroke() + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillRect(lx, ly - 1.5, 6, 5) + } + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillText(text, lx + lockW, ly) + ctx.globalAlpha = 1 + } + + // a glance from the log fades on its own clock + if (glance && glance.until < beatNow) glance = null + } + + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + + return { + setTree: (root) => { + if (!destroyed) layout(root) + }, + setTheme: (next) => { + if (next !== "dark" && next !== "light") return + scheme = next + css = PALETTES[scheme] + }, + highlight: (label) => { + if (destroyed) return + const norm = String(label || "") + .toLowerCase() + .replace(/\.(md|jl|json|toml)$/, "") + const n = nodes.find((x) => x.label.toLowerCase().replace(/\.(md|jl|json|toml)$/, "") === norm) + if (n) { + n.ringT = beatNow + glance = { id: n.id, until: beatNow + 3 } + } + }, + focus: (id) => { + if (destroyed) return + const n = byId.get(id) + if (!n) return + n.ringT = beatNow + glance = { id: n.id, until: beatNow + 3 } + }, + fit: () => { + userCam = false + fitTarget() + }, + resize: (width, height) => resize(width, height), + tick, + pause: () => { + halted = true + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + }, + resume: () => { + if (destroyed || !halted) return + halted = false + lastMs = 0 + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + }, + destroy: () => { + destroyed = true + halted = true + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + ro?.disconnect() + motionQuery?.removeEventListener("change", onMotionChange) + dprQuery?.removeEventListener("change", onDprChange) + if (typeof canvas.removeEventListener === "function") + for (const [ev, fn] of listeners) canvas.removeEventListener(ev, fn) + }, + pick, + locate: (id) => { + const n = byId.get(id) + if (!n) return undefined + return { x: sx(n.x), y: sy(n.y) } + }, + stats: () => ({ + scheme, + nodes: nodes.length, + edges: edges.filter((e) => !e.recall).length, + recalls: recallCount, + depth: maxDepth, + }), + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/device-view.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/device-view.tsx new file mode 100644 index 00000000..7036cf8f --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/device-view.tsx @@ -0,0 +1,68 @@ +import { For, Show } from "solid-js" +import type { DeviceVerdict } from "./problem" + +// AMICODE Device hero (ring-2 verdict-first redesign). device_session has no +// dedicated schema yet — today it carries only pulse_ref/run_dir/note. This +// renders the PROPOSED verdict (connection + provider + ready + queue) when +// those fields land (design-leads-schema-follows), degrading gracefully to the +// pulse/run linkage that exists now. Verdict model is the pure deviceVerdict() +// in ./problem.ts; styling in ./amicode.css. The raw field table stays one +// "Show all fields" disclosure below. + +const basename = (p: string): string => p.split("/").filter(Boolean).pop() ?? p + +export function DeviceView(props: { verdict: DeviceVerdict }) { + const v = () => props.verdict + const hasPills = () => + v().connection !== null || v().ready !== null || Boolean(v().provider) || Boolean(v().queue) + const links = () => { + const out: { label: string; value: string }[] = [] + const p = v().pulseRef + if (p) out.push({ label: "pulse", value: basename(p) }) + const r = v().runDir + if (r) out.push({ label: "run", value: basename(r) }) + return out + } + return ( +
+ +
+ + {(c) => ( + + {c()} + + )} + + + + {v().ready ? "ready to run" : "not ready"} + + + {(p) => {p()}} + {(q) => {q()}} +
+
+ 0}> +
Links
+ +
+ {(n) =>
{n()}
}
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.test.ts new file mode 100644 index 00000000..12694e7f --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { editRowDiff, editRowFilePath, editRowLabel, EDIT_ROW_MAX } from "./edit-row" + +// The edit row has the shell row's bug class (see shell-row.test.ts): a PENDING +// edit part has no `input.filePath` yet, so the label chain must not fall +// through to unclamped prose in the filename slot. + +describe("edit row label", () => { + test("the file's basename always wins over the title", () => { + expect( + editRowLabel({ + state: { title: "Fix the context wiring", input: { filePath: "/repo/src/ui/ChatApp.svelte" } }, + }), + ).toBe("ChatApp.svelte") + }) + + test("patch-family input.path and the filediff's file are honoured", () => { + expect(editRowFilePath({ state: { input: { path: "/repo/a.ts" } } })).toBe("/repo/a.ts") + expect( + editRowFilePath({ state: { metadata: { filediff: { file: "/repo/b.ts", additions: 1, deletions: 0 } } } }), + ).toBe("/repo/b.ts") + }) + + test("a pending part falls back to a clamped title, never raw prose", () => { + const prose = `"${"Rephrasing the whole module so the wiring reaches the transcript and the view ".repeat(3)}"` + const out = editRowLabel({ state: { title: prose } }) + expect(out.length).toBeLessThanOrEqual(EDIT_ROW_MAX) + expect(out.endsWith("…")).toBe(true) + }) + + test("blank filePath does not beat a usable title", () => { + expect(editRowLabel({ state: { title: "Write svelte-shims.d.ts", input: { filePath: " " } } })).toBe( + "Write svelte-shims.d.ts", + ) + }) + + test("nothing usable → the neutral placeholder, never a throw", () => { + expect(editRowLabel({})).toBe("file") + expect(editRowLabel({ state: {} })).toBe("file") + expect(editRowLabel({ state: { input: {} } })).toBe("file") + }) +}) + +describe("edit row diff", () => { + test("reads additions/deletions off the filediff", () => { + expect(editRowDiff({ state: { metadata: { filediff: { file: "a.ts", additions: 14, deletions: 1 } } } })).toEqual({ + additions: 14, + deletions: 1, + }) + }) + + test("no (or partial) filediff → undefined, never a fabricated count", () => { + expect(editRowDiff({})).toBeUndefined() + expect(editRowDiff({ state: { metadata: {} } })).toBeUndefined() + expect(editRowDiff({ state: { metadata: { filediff: { file: "a.ts" } } } })).toBeUndefined() + expect(editRowDiff({ state: { metadata: { filediff: { additions: 3 } } } })).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.ts new file mode 100644 index 00000000..c8d05dc3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/edit-row.ts @@ -0,0 +1,60 @@ +// The one-line label + diff stats for an edit/write part's row inside the +// "Edited files" group (spec B shape — see message-part-groups.ts). +// +// Extracted from message-part.tsx so the fallback chain is testable — the same +// bug class that bit the shell row (see ./shell-row.ts's module docs): while a +// part is PENDING its `input.filePath` may not be populated yet, so a naive +// chain falls through to the model's free-text title and renders prose in the +// slot where users read a filename. Whatever wins the chain is clamped, so a +// pending part can never fill the row with a sentence. + +import { clampShellLabel, SHELL_ROW_MAX } from "./shell-row" + +/** Longest label we render before eliding. Matches the shell row's budget. */ +export const EDIT_ROW_MAX = SHELL_ROW_MAX + +export interface EditRowPartLike { + state?: { + input?: Record + title?: unknown + metadata?: Record + } +} + +function recordOf(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) return undefined + return value as Record +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined +} + +/** The path this part mutates: input.filePath (edit/write), input.path + * (patch-family), or the filediff's recorded file. undefined while pending. */ +export function editRowFilePath(part: EditRowPartLike): string | undefined { + const input = part.state?.input ?? {} + const candidates = [input.filePath, input.path, recordOf(part.state?.metadata?.filediff)?.file] + for (const candidate of candidates) { + const path = nonEmpty(candidate) + if (path) return path + } + return undefined +} + +/** Prefer the file's basename; fall back to the (clamped) title, then the + * neutral placeholder. A prose title can never fill the row unclamped. */ +export function editRowLabel(part: EditRowPartLike): string { + const path = editRowFilePath(part) + if (path) return clampShellLabel(path.split("/").pop() ?? path, EDIT_ROW_MAX) + return clampShellLabel(nonEmpty(part.state?.title) ?? "file", EDIT_ROW_MAX) +} + +/** The {additions, deletions} this part recorded, when it recorded them. */ +export function editRowDiff(part: EditRowPartLike): { additions: number; deletions: number } | undefined { + const filediff = recordOf(part.state?.metadata?.filediff) + if (!filediff) return undefined + const { additions, deletions } = filediff + if (typeof additions !== "number" || typeof deletions !== "number") return undefined + return { additions, deletions } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/entity-rail.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/entity-rail.tsx new file mode 100644 index 00000000..fc6e61c8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/entity-rail.tsx @@ -0,0 +1,358 @@ +import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { hasUserReplyAfter } from "./ask" +import { registerAmicodeAskBridge } from "./ask-bridge" +import { registerAmicodeApprovalBridge } from "./approval-bridge" +import { railWarrantChip, type ApprovalRequest, type Warrant } from "./approval" +import { Icon } from "../components/icon" +import { registerAmicodeUiBridge, type AmicodeWidgetHost } from "./ui-bridge" +import { + type ProblemView, + type RunStatusView, + mergeChips, + parseProblemResponse, + parseRunStatusResponse, + railState, +} from "./problem" + +// AMICODE problem-header rail (spec B). One compact sticky row per session +// view, bound to the ACTIVE problem workspace via GET /amicode/problem — +// AMICO · ▾ │ . +// Session gate: renders only when the session contains ≥ 1 amicode_* tool +// part (fresh + non-amicode sessions stay stock; the active pointer is global +// but the rail is session-scoped — no empty chrome). The app supplies +// transport (fetchProblem/fetchRunStatus, per-active-server auth) + ring-2 +// actions; this component owns parsing, refetch triggers, run polling, and +// failure states. It also hosts the ask bridge (existing) and the ui bridge +// (receipt clicks → entity view). Refetch triggers, scoped per spec: the +// completed-part counter below; "section open" has no analog for an +// always-visible rail (dialogs fetch on open, app side); server switch is +// handled per-call because fetchProblem resolves the active connection on +// every invocation. + +// The session gate + refetch key now live in rail-gate.ts (pure, tested): the +// gate also recognises a SHELL-driven amicode session, which used to show no +// chips at all despite having real entities to describe. +export { countAmicodeParts, sessionHasAmicodeParts } from "./rail-gate" +import { countAmicodeParts as countParts, type GatePartLike } from "./rail-gate" + +type RailPart = GatePartLike + +const RUN_POLL_MS = 2500 + +// Pending chips are placeholders, not buttons — say WHY they aren't clickable +// (Kate 2026-07-27: an unexplained dead chip reads as a bug). +function pendingHint(kind: string): string { + switch (kind) { + case "pulse": + return "No pulse banked yet — a completed run produces one" + case "device_session": + return "No device session yet — appears when a pulse targets hardware" + case "run": + return "No run yet — solve the formulation to create one" + case "formulation": + return "No formulation yet — amico writes one from the problem" + case "system": + return "No system picked yet" + default: + return "Not created yet" + } +} + +// One line-icon per entity kind (glyphs live in the shared family — icon.tsx). +function chipIcon(kind: string) { + switch (kind) { + case "system": + return "target" + case "formulation": + return "sliders" + case "run": + return "archive" + case "pulse": + return "activity" + case "device_session": + return "status" + default: + return "dot-grid" + } +} + +export function AmicodeEntityRail(props: { + messages: readonly { id: string; role?: string }[] + partsFor: (messageID: string) => readonly RailPart[] | undefined + fetchProblem: () => Promise + fetchRunStatus: () => Promise + fetchRunSeries?: (run: string, lab?: string) => Promise + // Stage 2: transport for the in-chat widget preview (frame src + host + // callbacks + pin). Optional so hosts that can't render widgets omit it. + widgetHost?: AmicodeWidgetHost + onOpenEntity: (kind: string, seq?: number) => void + onAsk?: (text: string) => void + // Warrant transport (spec-20260727-164748 §9.5). DELIBERATELY NOT onAsk: routing an + // approval through the chat would leave the ledger's only provenance reading "the + // agent says the user approved". The app wires these to GET /amicode/warrants and + // POST /amicode/approve; omitting them leaves the approval card non-actionable, + // which is the correct read-only-surface behaviour. + warrants?: () => readonly Warrant[] + onApprove?: (request: ApprovalRequest) => void + // Bridge-agnostic: fired when the user clicks the pulse chip (the rail's one + // inspector entry). The app wires it to the host (postAmicode → + // amicode.openInspector) and passes it only when framed in Amicode, so the + // chip falls back to dialog/inert behavior everywhere else. + onInspectRun?: () => void + retryLabel: string + unavailableLabel: string + // Inline entity views: the composer draft target for the ✎ affordance and the + // edit label — the rail forwards both to the ui bridge for the in-chat view. + onDraftPrompt?: (text: string) => void + editLabel?: string + // When true, the rail is completely hidden. Used to prevent the rail from + // showing in unrelated chat sessions (issue #272). + disabled?: boolean +}) { + if (props.onAsk) { + const dispose = registerAmicodeAskBridge({ + send: (text) => props.onAsk?.(text), + hasUserReplyAfter: (messageID) => hasUserReplyAfter(props.messages, messageID), + }) + onCleanup(dispose) + } + // Registered only when BOTH halves are present: a card that could approve but not + // read back its own warrant would show "pending" forever after a successful press. + if (props.onApprove && props.warrants) { + const disposeApproval = registerAmicodeApprovalBridge({ + approve: (request) => props.onApprove?.(request), + warrants: () => props.warrants?.() ?? [], + }) + onCleanup(disposeApproval) + } + // The ui bridge (openEntity + the in-transcript entity-view transport) is + // registered below, once the live problem view, run statuses, and refetch it + // exposes are all in scope. + + // Session gate + refetch key: completed amicode parts bump the counter → refetch. + const amicodeParts = createMemo(() => countParts(props.messages, props.partsFor)) + + const [problemRaw, { refetch }] = createResource( + () => (amicodeParts().any > 0 ? amicodeParts().completed + 1 : undefined), + () => props.fetchProblem(), + ) + const [lastGood, setLastGood] = createSignal(undefined) + const current = createMemo(() => { + 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) + }) + createEffect(() => { + const view = current() + if (view?.ok) setLastGood(view) + }) + const state = createMemo(() => railState(current(), lastGood())) + + // Run chip live state: poll run-status while any referenced run is unfinished. + // Feeds ONLY the run chip — never other chips, never a problem refetch. + const [runStatuses, setRunStatuses] = createSignal([]) + let timer: ReturnType | undefined + const stopPolling = () => { + if (timer !== undefined) clearInterval(timer) + timer = undefined + } + createEffect(() => { + 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") + if (hasRuns && unfinished && timer === undefined) { + timer = setInterval(async () => { + const statuses = parseRunStatusResponse(await props.fetchRunStatus().catch(() => undefined)) + setRunStatuses(statuses) + if (statuses.length > 0 && statuses.every((status) => status.status !== "solving")) stopPolling() + }, RUN_POLL_MS) + } + if (!hasRuns) stopPolling() + }) + onCleanup(stopPolling) + + // Register the ui bridge now that current()/runStatuses()/refetch exist. The + // rail chips still open the current-version modal via openEntity; the added + // transport lets the in-transcript receipt cards render the full entity view + // inline (Kate 2026-07-24) without a second fetch path. + const disposeUiBridge = registerAmicodeUiBridge({ + openEntity: (kind, seq) => props.onOpenEntity(kind, seq), + fetchRunSeries: props.fetchRunSeries, + widgetHost: props.widgetHost, + problemView: () => current(), + runStatus: () => runStatuses(), + draftPrompt: props.onDraftPrompt, + refetchProblem: () => void refetch(), + retryLabel: props.retryLabel, + editLabel: props.editLabel, + }) + onCleanup(disposeUiBridge) + + // Recomputed on any warrant change; no ticker, so an expiry crossing resolves on + // the next refetch rather than needing a timer per rail. + const warrantChip = createMemo(() => railWarrantChip(props.warrants?.() ?? [], Date.now())) + // Whether there is a run to inspect — gates the "Inspect Run" button so it + // appears alongside the live run chip, not before any solve has started. + const hasRun = createMemo(() => { + const snapshot = state() + return snapshot.kind === "ready" && snapshot.view.runs.length > 0 + }) + const chips = createMemo(() => { + const snapshot = state() + if (snapshot.kind !== "ready") return [] + return mergeChips(snapshot.view.entities, snapshot.view.scoreStages) + }) + // Which chips hand off to the Pulse Inspector instead of the entity dialog. + // Only the pulse chip, and only when the host actually wired an inspector — + // standalone opencode has none, so there the chip keeps its dialog behavior. + // The pulse chip is the ONLY inspector entry on the rail — the separate + // "Inspect Run" button was redundant chrome next to it (Kate 2026-07-28). + const opensInspector = (kind: string) => kind === "pulse" && props.onInspectRun !== undefined + + // Don't render if disabled (issue #272: prevents rail in unrelated sessions) + if (props.disabled) return null + + return ( + 0}> +
+ + {props.unavailableLabel} + +
+ } + > + {/* Static entity chips (Kate 2026-07-24): label ONLY — no value, no + chevron / + icon. A recorded (clickable) chip carries the soft-yellow + fill as its affordance and opens its current version; a not-yet- + recorded chip is inert with a dotted border. The problem name sits in + the bottom-right corner as a thin, low-contrast watermark. */} +
+ + {(chip) => ( + + + {chip.label} + + } + > + + + )} + + {/* Warrant status (spec §9.6 / G-6): the ACTIVE warrant's consumption, so a + researcher mid-campaign sees remaining authorization without opening + anything. Inert by design — it reports a ledger fact, and whether the + next launch passes is the gate's verdict, not this chip's. Absent when + there is no live warrant or it declares no bounds, so it never implies + an authorization the gate would refuse. */} + + {(text) => ( + + + {text()} + + )} + + + + +
+
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/entity-view.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/entity-view.tsx new file mode 100644 index 00000000..fe8fd703 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/entity-view.tsx @@ -0,0 +1,261 @@ +import { For, Show, createMemo } from "solid-js" +import { + type ProblemView, + type RunStatusView, + calibrationVerdict, + deviceVerdict, + editPromptText, + entityRows, + fieldGroup, + formulationProjection, + historyRows, + humanizeKey, + runVerdict, + systemProjection, +} from "./problem" +import { SystemComposite } from "./system-view" +import { FormulationView } from "./formulation-view" +import { RunVerdictView } from "./run-view" +import { DeviceView } from "./device-view" +import { CalibrationView } from "./calibration-view" + +// AMICODE ring-2 entity view (spec B): dialog BODY opened from a rail chip or +// diff receipt — current fields (pretty table + per-field "Edit in chat" +// draft handoff), event history for this entity ("which turn changed this"), +// and tier treatment on Run entities. READ-ONLY + edit-in-chat only: all +// writes route through the agent's tools (ring 0), so entity state and +// conversation never fork. Presentation-only over ./problem.ts helpers; the +// app owns fetching and wraps this in its Dialog. +// +// Redesign: raw wire keys lead with a human label (params.drive_max → "Drive +// max") and keep the technical name underneath; nested groups get a subhead; +// the field you arrived from (anchorSeq's diff) is marked; the ✎ affordance is +// always faintly present, not hover-only; and a footer states the read-only / +// edit-in-chat contract so the absence of Save buttons reads as intent. Styling +// is in ./amicode.css; data-slot hooks are preserved for the e2e suite. + +// (System Hamiltonian LaTeX now lives in ./system-view.tsx's SystemComposite.) +// (Event history removed 2026-07-23 — the change-log was meta-noise, not the +// decision-relevant physics; entity views surface current state only.) + +// Per-kind raw-field-table suppressions: linkage/metadata that's noise in the +// dialog (the Run's system_ref/formulation_ref/tier/note — Kate 2026-07-24; the +// Run modal leads with the verdict + derived problem size instead). +const HIDDEN_FIELDS: Record> = { + run: new Set(["system_ref", "formulation_ref", "tier", "note"]), +} + +export function AmicodeEntityView(props: { + view: ProblemView | undefined // undefined → loading skeleton + kind: string + runStatus?: RunStatusView[] // live run-status (for the Run verdict); ignored otherwise + anchorSeq?: number + onDraftPrompt: (text: string) => void + onRetry: () => void + retryLabel: string + editLabel: string +}) { + const entity = createMemo(() => props.view?.entities[props.kind] ?? {}) + const rows = createMemo(() => { + const hidden = HIDDEN_FIELDS[props.kind] + const all = entityRows(entity()) + if (!hidden) return all + return all.filter((r) => !hidden.has(r.key) && !hidden.has(r.key.split(".").pop() ?? r.key)) + }) + const history = createMemo(() => (props.view ? historyRows(props.view.events, props.kind) : [])) + const anchored = createMemo(() => + props.anchorSeq !== undefined && history().some((event) => event.seq === props.anchorSeq) + ? props.anchorSeq + : undefined, + ) + const changedKeys = createMemo(() => { + const seq = anchored() + const set = new Set() + if (seq === undefined) return set + const event = history().find((candidate) => candidate.seq === seq) + if (event?.diff) + for (const key of Object.keys(event.diff)) { + set.add(key) + const bare = key.split(".").pop() + if (bare) set.add(bare) + } + return set + }) + const isChanged = (key: string) => { + const set = changedKeys() + return set.has(key) || set.has(key.split(".").pop() ?? key) + } + // Rows with a group-header flag when a nested group first appears (entityRows + // flattens each object's children contiguously, so a group is one run). + const fieldRows = createMemo(() => { + let prevGroup: string | undefined + return rows().map((row) => { + const group = fieldGroup(row.key) + const showGroupHeader = group !== undefined && group !== prevGroup + prevGroup = group + return { + key: row.key, + value: row.value, + name: humanizeKey(row.key), + groupLabel: group ? humanizeKey(group) : undefined, + showGroupHeader, + } + }) + }) + const verdict = createMemo(() => + props.kind === "run" && props.view ? runVerdict(props.runStatus ?? [], props.view.runs) : null, + ) + const deviceV = createMemo(() => (props.kind === "device_session" ? deviceVerdict(entity()) : null)) + const calibV = createMemo(() => (props.kind === "calibration" ? calibrationVerdict(entity(), Date.now()) : null)) + // Run modal only: derived problem-size metrics — Hilbert dimension (∏ levels + // from the system), knot points N (from the formulation solve), and the state- + // trajectory variable count (N × 2d² for a gate, N × 2d for a ket). All exact, + // derived from the sibling system/formulation entities. + const runSize = createMemo(() => { + if (props.kind !== "run") return undefined + const sysE = props.view?.entities.system + const fmlE = props.view?.entities.formulation + if (!sysE && !fmlE) return undefined + const sp = sysE ? systemProjection(sysE) : undefined + const fp = fmlE ? formulationProjection(fmlE) : undefined + const comps = sp?.components ?? [] + const dim = comps.length + ? comps.reduce((acc, c) => acc * (typeof c.levels === "number" && c.levels > 0 ? c.levels : 1), 1) + : undefined + const solveN = fp?.solve?.N + const N = typeof solveN === "number" ? solveN : undefined + const ket = fp?.trajectory_type === "ket" || fp?.trajectory_type === "multiket" + const stateDim = dim !== undefined ? (ket ? 2 * dim : 2 * dim * dim) : undefined + const vars = N !== undefined && stateDim !== undefined ? N * stateDim : undefined + if (dim === undefined && N === undefined) return undefined + return { dim, N, vars } + }) + // A hero renders only when the kind has one AND it has content to show + // (run/device/calibration return null when there's nothing worth surfacing, + // so the raw field table takes over instead of showing an empty hero). + const hasHero = createMemo(() => { + switch (props.kind) { + case "system": + case "formulation": + return true + case "run": + return verdict() !== null + case "device_session": + return deviceV() !== null + case "calibration": + return calibV() !== null + default: + return false + } + }) + return ( +
+ +
+
+
+
+
+ } + > + {(view) => ( + + + {view().error} + +
+ } + > + + + + + + + {(v) => } + + {(s) => ( + <> +
Problem size
+ +
+
+ hilbert dimension +
+
{s().dim}
+
+
+ +
+
+ knot points +
+
{s().N}
+
+
+ +
+
+ state variables +
+
{s().vars}
+
+
+ + )} +
+ {(v) => } + {(v) => } + + {/* Raw field table only for kinds WITHOUT a hero (so their dialog + isn't empty). Hero kinds surface their content directly — the + "Show all fields" disclosure was removed (Kate 2026-07-23). */} + 0}> +
Details
+
+ + {(row) => ( + <> + +
{row.groupLabel}
+
+
+ + {row.name} + {row.key} + + + {row.value} + + +
+ + )} +
+
+
+ + + )} + +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/facets.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/facets.test.ts new file mode 100644 index 00000000..2587beaa --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/facets.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "bun:test" +import { compactValue, setDiff, modeBadges, systemReceiptPieces, formulationReceiptPieces, formatSci } from "./facets" + +const byKind = (t: { kind: string; label?: string }, i: number) => `${t.kind}:${t.label ?? i}` + +describe("formatSci", () => { + it("renders integer π-multiples as Nπ (drive/detuning bounds)", () => { + expect(formatSci(125.66370614359172)).toBe("40π") // 40π + expect(formatSci(62.83185307179586)).toBe("20π") // 20π + expect(formatSci(Math.PI)).toBe("π") + expect(formatSci(-Math.PI)).toBe("-π") + }) + it("trims non-π values to ~4 significant figures", () => { + expect(formatSci(0.2)).toBe("0.2") + expect(formatSci(28.9)).toBe("28.9") + expect(formatSci(0.005)).toBe("0.005") + expect(formatSci(4.83729)).toBe("4.837") + }) + it("handles 0 and non-finite defensively", () => { + expect(formatSci(0)).toBe("0") + expect(formatSci(NaN)).toBe("NaN") + }) +}) + +describe("compactValue", () => { + it("passes scalars through unchanged", () => { + expect(compactValue("gate")).toBe("gate") + expect(compactValue(3)).toBe("3") + expect(compactValue(true)).toBe("true") + }) + it("renders null/undefined as em-dash", () => { + expect(compactValue(null)).toBe("—") + expect(compactValue(undefined)).toBe("—") + }) + it("summarizes arrays by count + head, never raw JSON", () => { + const out = compactValue([{ id: "q1" }, { id: "q2" }, { id: "q3" }, { id: "q4" }]) + expect(out).not.toContain("{") // no JSON blob + expect(out).toContain("4") // count present + expect(out).toContain("q1") // head label present + }) + it("summarizes objects compactly, never raw JSON", () => { + const out = compactValue({ arch: "global", n: 2 }) + expect(out).not.toContain('"') // no JSON quotes + expect(out).toContain("arch") + }) +}) + +describe("setDiff", () => { + it("detects added / removed by unique key", () => { + const d = setDiff([{ kind: "reg_u" }], [{ kind: "reg_u" }, { kind: "reg_du" }], byKind) + expect(d.added.map((x) => x.kind)).toEqual(["reg_du"]) + expect(d.removed).toEqual([]) + }) + it("does NOT collide two same-kind terms (label disambiguates)", () => { + const from = [{ kind: "custom", label: "a" }, { kind: "custom", label: "b" }] + const to = [{ kind: "custom", label: "a" }] + const d = setDiff(from, to, byKind) + expect(d.removed).toEqual([{ kind: "custom", label: "b" }]) + expect(d.added).toEqual([]) + }) + it("reports per-field changes for matched keys", () => { + const d = setDiff([{ kind: "reg_u", params: { R: 1e-4 } }], [{ kind: "reg_u", params: { R: 1e-5 } }], byKind) + expect(d.changed).toHaveLength(1) + expect(d.changed[0].changes[0]).toMatchObject({ field: "params.R", from: 1e-4, to: 1e-5 }) + }) +}) + +describe("modeBadges", () => { + const full = { + trajectory_type: "gate", + time_mode: "min_time", + parameterization: "cubic_spline", + robustness: { kind: "ensemble" }, + free_phase: true, + leakage: false, + } + it("emits type / time / param / robustness / flag badges", () => { + const b = modeBadges(full) + const has = (pred: (x: { label: string; value: string }) => boolean) => b.some(pred) + expect(has((x) => x.label === "type" && x.value === "gate")).toBe(true) + expect(has((x) => /min.?time/.test(x.value + x.label))).toBe(true) + expect(has((x) => x.value === "ensemble")).toBe(true) + expect(has((x) => /free.?phase/.test(x.label))).toBe(true) + }) + it("omits false flags, none-robustness, and fixed time", () => { + const b = modeBadges({ trajectory_type: "ket", time_mode: "fixed", robustness: { kind: "none" }, free_phase: false, leakage: false }) + expect(b.some((x) => /leakage/.test(x.label))).toBe(false) + expect(b.some((x) => /robust/.test(x.label))).toBe(false) + expect(b.some((x) => /min.?time/.test(x.value + x.label))).toBe(false) + }) +}) + +describe("systemReceiptPieces", () => { + it("creation (components.from empty) → chip mode with a reconstructed entity", () => { + const r = systemReceiptPieces({ + platform: { from: null, to: "transmon" }, + components: { from: null, to: [{ id: "q1", role: "qubit", levels: 3 }, { id: "q2", role: "qubit", levels: 3 }] }, + "drive.arch": { from: null, to: "per-component" }, + }) + expect(r.kind).toBe("chip") + if (r.kind === "chip") { + expect(Array.isArray(r.entity.components)).toBe(true) + expect((r.entity.drive as { arch: string }).arch).toBe("per-component") + } + }) + it("add-component → a '+ q2' piece", () => { + const r = systemReceiptPieces({ + components: { from: [{ id: "q1", role: "qubit", levels: 3 }], to: [{ id: "q1", role: "qubit", levels: 3 }, { id: "q2", role: "qubit", levels: 3 }] }, + }) + expect(r.kind).toBe("pieces") + if (r.kind === "pieces") expect(r.pieces.some((p) => p.text.includes("+ q2") && p.tone === "add")).toBe(true) + }) + it("param-change → a 'q1 delta …→…' change piece", () => { + const r = systemReceiptPieces({ + components: { from: [{ id: "q1", role: "qubit", params: { delta: -0.2 } }], to: [{ id: "q1", role: "qubit", params: { delta: -0.25 } }] }, + }) + expect(r.kind).toBe("pieces") + if (r.kind === "pieces") expect(r.pieces.some((p) => p.text.includes("q1") && p.text.includes("delta") && p.tone === "change")).toBe(true) + }) + it("elided set key (…) → chip mode", () => { + const r = systemReceiptPieces({ "…": { from: null, to: "2 more fields" }, "drive.arch": { from: "global", to: "zoned" } }) + expect(r.kind).toBe("chip") + }) +}) + +describe("formulationReceiptPieces", () => { + it("creation (trajectory_type.from null) → chip mode", () => { + const r = formulationReceiptPieces({ + trajectory_type: { from: null, to: "gate" }, + time_mode: { from: null, to: "min_time" }, + target: { from: null, to: "CZ" }, + }) + expect(r.kind).toBe("chip") + }) + it("mode transition → 'type: ket → gate'", () => { + const r = formulationReceiptPieces({ trajectory_type: { from: "ket", to: "gate" } }) + expect(r.kind).toBe("pieces") + if (r.kind === "pieces") expect(r.pieces.some((p) => /type: ket → gate/.test(p.text))).toBe(true) + }) + it("flag add → '+ free-phase'", () => { + const r = formulationReceiptPieces({ free_phase: { from: false, to: true } }) + expect(r.kind).toBe("pieces") + if (r.kind === "pieces") expect(r.pieces.some((p) => p.text === "+ free-phase" && p.tone === "add")).toBe(true) + }) + it("added constraint → '+ …' via setDiff", () => { + const r = formulationReceiptPieces({ + constraints: { from: [], to: [{ kind: "leakage_c", label: "leakage ≤1e-3" }] }, + }) + expect(r.kind).toBe("pieces") + if (r.kind === "pieces") expect(r.pieces.some((p) => p.text.includes("+ leakage ≤1e-3") && p.tone === "add")).toBe(true) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/facets.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/facets.ts new file mode 100644 index 00000000..4b97e875 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/facets.ts @@ -0,0 +1,244 @@ +// facets.ts — pure, dependency-free rendering helpers for the amicode entity +// visualization language (spec-20260709 §4). No SolidJS, no imports. + +/** Physicists' number formatter for display. Drive/detuning bounds are integer + * multiples of π, so 125.66370614359172 → "40π", 62.83… → "20π"; other values + * trim to ~4 significant figures with trailing zeros dropped (0.2 → "0.2", + * 28.9 → "28.9"). Keeps the full float out of chips, tables, and history. */ +export function formatSci(value: number): string { + if (!Number.isFinite(value)) return String(value) + if (value === 0) return "0" + const ratio = value / Math.PI + const n = Math.round(ratio) + if (n !== 0 && Math.abs(ratio - n) < 1e-6) return n === 1 ? "π" : n === -1 ? "-π" : `${n}π` + return String(Number(value.toPrecision(4))) +} + +/** Array/object-aware compact renderer. Replaces the JSON.stringify branch that + * the old short()/shortValue() used, so a components[]/constraints[] value never + * becomes a raw blob. Scalars pass through; arrays → "N items" (+ head id/kind if + * present); objects → compact "k:v · k:v" (first few keys). Never throws. */ +export function compactValue(value: unknown): string { + if (value === null || value === undefined) return "—" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (Array.isArray(value)) { + if (value.length === 0) return "none" + const head = value + .slice(0, 3) + .map((v) => (v && typeof v === "object" ? headLabel(v as Record) : String(v))) + .filter(Boolean) + .join(", ") + return value.length <= 3 ? head : `${value.length} items (${head}…)` + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .slice(0, 3) + .map(([k, v]) => `${k}:${typeof v === "object" ? "…" : String(v)}`) + return entries.join(" · ") || "{}" + } + return "—" +} + +/** Best label for one object in an array: id → kind → between → first string field. */ +function headLabel(o: Record): string { + for (const k of ["id", "kind", "between", "name"]) { + const v = o[k] + if (typeof v === "string" && v) return v + if (Array.isArray(v) && v.length) return v.map(String).join("↔") + } + return "item" +} + +export type FieldChange = { field: string; from: unknown; to: unknown } +export type SetDiff = { added: T[]; removed: T[]; changed: { key: string; item: T; changes: FieldChange[] }[] } + +/** Set delta by a UNIQUE key (keyFn MUST be unique within the set — for + * objectives/constraints use `kind:(label??index)`; kind alone collides). */ +export function setDiff(from: T[], to: T[], keyFn: (t: T, i: number) => string): SetDiff { + const fromMap = new Map(from.map((t, i) => [keyFn(t, i), t])) + const toMap = new Map(to.map((t, i) => [keyFn(t, i), t])) + const added = [...toMap].filter(([k]) => !fromMap.has(k)).map(([, t]) => t) + const removed = [...fromMap].filter(([k]) => !toMap.has(k)).map(([, t]) => t) + const changed: SetDiff["changed"] = [] + for (const [k, toItem] of toMap) { + const fromItem = fromMap.get(k) + if (fromItem === undefined) continue + const changes = flatFieldChanges(fromItem, toItem) + if (changes.length) changed.push({ key: k, item: toItem, changes }) + } + return { added, removed, changed } +} + +/** One-level-deep field comparison (dotted keys for nested params). */ +function flatFieldChanges(a: unknown, b: unknown, prefix = ""): FieldChange[] { + const out: FieldChange[] = [] + const ao = (a ?? {}) as Record + const bo = (b ?? {}) as Record + for (const k of new Set([...Object.keys(ao), ...Object.keys(bo)])) { + const av = ao[k], + bv = bo[k] + const field = prefix ? `${prefix}.${k}` : k + if (av && bv && typeof av === "object" && typeof bv === "object" && !Array.isArray(av)) { + out.push(...flatFieldChanges(av, bv, field)) + } else if (JSON.stringify(av) !== JSON.stringify(bv)) { + out.push({ field, from: av, to: bv }) + } + } + return out +} + +export type Badge = { label: string; value: string; tone?: "neutral" | "active" } + +const badgeStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined) + +/** Mode facets → badges. Defensive over a loose record (serves both System + * drive/topology and Formulation trajectory/time/param/robustness/flags). + * Skips none-robustness, false flags, and the "fixed" time default. */ +export function modeBadges(p: Record): Badge[] { + const out: Badge[] = [] + const tt = badgeStr(p.trajectory_type) + if (tt) out.push({ label: "type", value: tt }) + const pm = badgeStr(p.parameterization) + if (pm) out.push({ label: "pulse", value: pm.replace(/_/g, "-") }) + const tm = badgeStr(p.time_mode) + if (tm && tm !== "fixed") out.push({ label: "time", value: tm.replace(/_/g, "-"), tone: "active" }) + const rob = + p.robustness && typeof p.robustness === "object" + ? badgeStr((p.robustness as Record).kind) + : undefined + if (rob && rob !== "none") out.push({ label: "robust", value: rob, tone: "active" }) + if (p.free_phase === true) out.push({ label: "free-phase", value: "", tone: "active" }) + if (p.leakage === true) out.push({ label: "leakage", value: "", tone: "active" }) + // System modes share the badge language. + const drive = + p.drive && typeof p.drive === "object" ? badgeStr((p.drive as Record).arch) : undefined + if (drive) out.push({ label: "drive", value: drive }) + const topo = badgeStr(p.topology) + if (topo) out.push({ label: "topology", value: topo }) + return out +} + +// ---- receipt piece builders (spec §5) -------------------------------------- +// The receipt sees only the diff (from/to per key), not the full entity. + +export type DiffMap = Record +export type ReceiptPiece = { text: string; tone: "add" | "remove" | "change" } +/** chip = render the post-state compositeChip (creation / large / elided); + * pieces = a semantic delta. The card owns turning `entity` into a chip + * (via problem.ts compositeChip) — kept out of here to avoid an import cycle. */ +export type EntityReceipt = { kind: "chip"; entity: Record } | { kind: "pieces"; pieces: ReceiptPiece[] } + +const ELIDED = "…" + +function describeComponent(item: Record): string { + const bits = [item.role, typeof item.levels === "number" ? `${item.levels}lvl` : undefined].filter(Boolean).join("·") + return `${item.id ?? "?"}${bits ? ` (${bits})` : ""}` +} +function describeCoupling(item: Record): string { + const between = Array.isArray(item.between) ? item.between.map(String).join("↔") : "?" + return `${between} ${item.kind ?? ""}`.trim() +} +function setDotted(obj: Record, dotted: string, value: unknown): void { + const parts = dotted.split(".") + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { + if (typeof cur[parts[i]] !== "object" || cur[parts[i]] === null) cur[parts[i]] = {} + cur = cur[parts[i]] as Record + } + cur[parts[parts.length - 1]] = value +} + +/** System diff → semantic pieces, or a post-state chip on creation/large/elided. + * `components`/`couplings` diff via setDiff (add/remove/param-change); other keys + * render as `key old→new`. Pure; never throws. */ +export function systemReceiptPieces(diff: DiffMap): EntityReceipt { + const keys = Object.keys(diff) + const elided = keys.includes(ELIDED) || Object.values(diff).some((e) => e.to === ELIDED || e.from === ELIDED) + const comp = diff["components"] + const isCreation = + !!comp && (comp.from === null || comp.from === undefined || (Array.isArray(comp.from) && comp.from.length === 0)) + + const pieces: ReceiptPiece[] = [] + for (const [key, entry] of Object.entries(diff)) { + if (key === ELIDED) continue + if (key === "components" || key === "couplings") { + const from = (Array.isArray(entry.from) ? entry.from : []) as Record[] + const to = (Array.isArray(entry.to) ? entry.to : []) as Record[] + const keyFn = + key === "components" + ? (c: Record) => String(c.id) + : (c: Record) => `${(Array.isArray(c.between) ? c.between : []).join("↔")}:${c.kind}` + const describe = key === "components" ? describeComponent : describeCoupling + const d = setDiff(from, to, keyFn) + for (const a of d.added) pieces.push({ text: `+ ${describe(a)}`, tone: "add" }) + for (const r of d.removed) pieces.push({ text: `− ${describe(r)}`, tone: "remove" }) + for (const c of d.changed) + for (const ch of c.changes) + pieces.push({ text: `${c.key} ${ch.field.split(".").pop()} ${compactValue(ch.from)}→${compactValue(ch.to)}`, tone: "change" }) + } else { + pieces.push({ text: `${key.split(".").pop()} ${compactValue(entry.from)}→${compactValue(entry.to)}`, tone: "change" }) + } + } + + if (isCreation || elided || pieces.length > 4) { + const entity: Record = {} + for (const [key, entry] of Object.entries(diff)) { + if (key === ELIDED || entry.to === ELIDED) continue + setDotted(entity, key, entry.to) + } + return { kind: "chip", entity } + } + return { kind: "pieces", pieces } +} + +const FORM_MODE_TAG: Record = { + trajectory_type: "type", + time_mode: "time", + parameterization: "pulse", + "robustness.kind": "robust", +} + +/** Formulation diff → semantic pieces, or a post-state chip on creation/large/ + * elided. Modes render as `tag: from → to`, flags as `+/− flag`, objectives/ + * constraints via setDiff, everything else `key old→new`. Pure; never throws. */ +export function formulationReceiptPieces(diff: DiffMap): EntityReceipt { + const keys = Object.keys(diff) + const elided = keys.includes(ELIDED) || Object.values(diff).some((e) => e.to === ELIDED || e.from === ELIDED) + const tt = diff["trajectory_type"] + const isCreation = !!tt && (tt.from === null || tt.from === undefined) + + const pieces: ReceiptPiece[] = [] + for (const [key, entry] of Object.entries(diff)) { + if (key === ELIDED) continue + if (key === "objectives" || key === "constraints") { + const from = (Array.isArray(entry.from) ? entry.from : []) as Record[] + const to = (Array.isArray(entry.to) ? entry.to : []) as Record[] + const keyFn = (t: Record, i: number) => `${t.kind}:${t.label ?? i}` + const d = setDiff(from, to, keyFn) + for (const a of d.added) pieces.push({ text: `+ ${a.label ?? a.kind}`, tone: "add" }) + for (const r of d.removed) pieces.push({ text: `− ${r.label ?? r.kind}`, tone: "remove" }) + for (const c of d.changed) + for (const ch of c.changes) + pieces.push({ text: `${c.key.split(":")[0]} ${ch.field.split(".").pop()} ${compactValue(ch.from)}→${compactValue(ch.to)}`, tone: "change" }) + } else if (key === "free_phase" || key === "leakage") { + const flag = key.replace(/_/g, "-") + if (entry.to === true) pieces.push({ text: `+ ${flag}`, tone: "add" }) + else if (entry.to === false) pieces.push({ text: `− ${flag}`, tone: "remove" }) + } else if (key in FORM_MODE_TAG) { + pieces.push({ text: `${FORM_MODE_TAG[key]}: ${compactValue(entry.from)} → ${compactValue(entry.to)}`, tone: "change" }) + } else { + pieces.push({ text: `${key.split(".").pop()} ${compactValue(entry.from)}→${compactValue(entry.to)}`, tone: "change" }) + } + } + + if (isCreation || elided || pieces.length > 4) { + const entity: Record = {} + for (const [key, entry] of Object.entries(diff)) { + if (key === ELIDED || entry.to === ELIDED) continue + setDotted(entity, key, entry.to) + } + return { kind: "chip", entity } + } + return { kind: "pieces", pieces } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/fixtures/formulation-migration.json b/packages/app-bundle/overlay/packages/ui/src/amicode/fixtures/formulation-migration.json new file mode 100644 index 00000000..6dd6384c --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/fixtures/formulation-migration.json @@ -0,0 +1,105 @@ +{ + "_source": "CANONICAL. amicode packages/extension/test/fixtures/formulation-migration.json is the source of truth for the legacy->structured Formulation mapping (spec-20260709 §10). The opencode fork copy at packages/ui/src/amicode/fixtures/ is a VERBATIM mirror — edit here, then re-copy. Each `structured` holds ONLY mapped facet fields (no derived primaryKey / synthesized final_fidelity).", + "pairs": [ + { + "name": "gate_synthesis basic", + "legacy": { "problem": "gate_synthesis", "target": "CZ", "objective": "unitary infidelity", "constraints": ["amplitude bound (drive_max)"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "CZ", + "objectives": [], + "constraints": [{ "kind": "bounds", "params": {}, "label": "amplitude bound (drive_max)" }] + } + }, + { + "name": "state_prep -> ket", + "legacy": { "problem": "state_prep", "target": "|1>", "objective": "ket infidelity", "constraints": [] }, + "structured": { + "trajectory_type": "ket", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "|1>", + "objectives": [], + "constraints": [] + } + }, + { + "name": "min_time -> time_mode", + "legacy": { "problem": "min_time", "target": "CZ", "objective": "unitary infidelity", "constraints": ["amplitude bound"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "min_time", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "CZ", + "objectives": [], + "constraints": [{ "kind": "bounds", "params": {}, "label": "amplitude bound" }] + } + }, + { + "name": "custom objective + unknown constraint", + "legacy": { "problem": "gate_synthesis", "target": "X", "objective": "custom cost foo", "constraints": ["keep it smooth"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "X", + "objectives": [{ "kind": "custom", "params": {}, "label": "custom cost foo" }], + "constraints": [{ "kind": "custom", "params": {}, "label": "keep it smooth" }] + } + }, + { + "name": "leakage constraint -> flag", + "legacy": { "problem": "gate_synthesis", "target": "CZ", "objective": "unitary infidelity", "constraints": ["leakage suppression"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": true, + "target": "CZ", + "objectives": [], + "constraints": [] + } + }, + { + "name": "already structured (idempotent)", + "legacy": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "cubic_spline", + "robustness": { "kind": "ensemble", "params": { "n_systems": 3 } }, + "free_phase": true, + "leakage": false, + "target": "CZ", + "objectives": [{ "kind": "reg_du", "params": { "R": 0.00001 } }], + "constraints": [{ "kind": "bounds", "params": {} }] + }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "cubic_spline", + "robustness": { "kind": "ensemble", "params": { "n_systems": 3 } }, + "free_phase": true, + "leakage": false, + "target": "CZ", + "objectives": [{ "kind": "reg_du", "params": { "R": 0.00001 } }], + "constraints": [{ "kind": "bounds", "params": {} }] + } + } + ] +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/footer.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/footer.tsx new file mode 100644 index 00000000..fab77048 --- /dev/null +++ b/packages/app-bundle/overlay/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/app-bundle/overlay/packages/ui/src/amicode/formulation-projection.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/formulation-projection.test.ts new file mode 100644 index 00000000..c7bde272 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/formulation-projection.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { formulationProjection } from "./problem" + +const corpus = JSON.parse( + readFileSync(new URL("./fixtures/formulation-migration.json", import.meta.url), "utf8"), +) as { pairs: { name: string; legacy: any; structured: any }[] } + +// Compare ONLY the mapped facet fields (not the derived primaryKey / synthesized +// final_fidelity, which aren't in `structured`) — the anti-drift lock against +// the amicode-side normalizeFormulation (§9/§10). +const mapped = (p: any) => ({ + trajectory_type: p.trajectory_type, + time_mode: p.time_mode, + parameterization: p.parameterization, + robustness: p.robustness, + free_phase: p.free_phase, + leakage: p.leakage, + target: p.target, + objectives: p.objectives, + constraints: p.constraints, +}) + +describe("formulationProjection (shared corpus — anti-drift lock)", () => { + for (const pair of corpus.pairs) { + it(`maps facets: ${pair.name}`, () => { + expect(mapped(formulationProjection(pair.legacy))).toEqual(pair.structured) + }) + } +}) + +describe("formulationProjection primaryKey + robustness", () => { + const P = (o: any) => formulationProjection(o).primaryKey + it("derives per trajectory_type + free_phase + time_mode", () => { + expect(P({ trajectory_type: "gate" })).toBe("unitary_infidelity") + expect(P({ trajectory_type: "gate", free_phase: true })).toBe("unitary_free_phase") + expect(P({ trajectory_type: "ket" })).toBe("ket_infidelity") + expect(P({ trajectory_type: "multiket" })).toBe("ket_infidelity") + expect(P({ trajectory_type: "density" })).toBe("density_infidelity") + expect(P({ trajectory_type: "gate", time_mode: "min_time" })).toBe("min_time") + }) + it("min_time surfaces derivedFinalFidelity from time_params", () => { + const p = formulationProjection({ trajectory_type: "gate", time_mode: "min_time", time_params: { final_fidelity: 0.999 } }) + expect(p.derivedFinalFidelity).toBe(0.999) + }) + it("never throws on an unknown enum; passes the raw string through", () => { + const p = formulationProjection({ trajectory_type: "bogus" }) + expect(p.trajectory_type).toBe("bogus") + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/formulation-view.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/formulation-view.tsx new file mode 100644 index 00000000..c4e0a990 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/formulation-view.tsx @@ -0,0 +1,335 @@ +import { For, Show, createMemo } from "solid-js" +import katex from "katex" +import { + formulationProjection, + humanizeKey, + type PrimaryObjectiveKey, + type FormulationProjection, + type FormulationTerm, +} from "./problem" +import { modeBadges } from "./facets" + +// AMICODE Formulation hero (spec §6.2 / plan Task 14): a mode-badge header over +// the derived primary objective (LaTeX) + the FULL objective-term and constraint +// ledger, each rendered as LaTeX and grouped by category. The recorded facets +// (formulation.json) carry only the primary + any explicit terms; the rest of a +// Piccolo problem's structure (regularizers, integrators, boundary pins, bounds) +// is implied by the mode. We DERIVE that canonical structure here from the +// projection and merge recorded terms on top (recorded wins). This is static +// structure — not measured weights/values/norms (those live solver-side). Thin: +// logic lives in formulationProjection / modeBadges; styling in ./amicode.css. + +// Keyed by the DERIVED primaryKey (disjoint from ObjectiveKind). Unknown → no math. +const OBJECTIVE_LATEX: Record = { + ket_infidelity: "\\mathcal{F} = |\\langle \\psi_g | \\psi(T) \\rangle|^2", + unitary_infidelity: "\\mathcal{F} = \\tfrac{1}{d^2}\\,|\\mathrm{Tr}(U_{\\mathrm{goal}}^\\dagger U)|^2", + unitary_free_phase: "\\mathcal{F} = \\tfrac{1}{d^2}\\,|\\mathrm{Tr}(U_{\\mathrm{goal}}(\\theta)^\\dagger U)|^2", + density_infidelity: "\\mathcal{F} = \\mathrm{Tr}(\\rho_g\\, \\rho(T))", + min_time: "\\min\\; D \\textstyle\\sum_k \\Delta t_k \\quad \\mathrm{s.t.}\\; \\mathcal{F} \\ge \\mathcal{F}_0", +} +const PRIMARY_LABEL: Record = { + ket_infidelity: "ket infidelity", + unitary_infidelity: "unitary infidelity", + unitary_free_phase: "free-phase unitary infidelity", + density_infidelity: "density infidelity", + min_time: "min-time (D · ΣΔt)", +} +// Ensemble robustness IS the primary fidelity averaged over the M sampled systems, +// so we fold it into the primary objective (not a separate row): when ensemble is +// on, the primary equation becomes its ensemble-averaged form (U/ψ/ρ → sample m). +// min_time is intentionally absent — its D·ΣΔt objective is unaffected (the +// ensemble acts on the fidelity constraint), so it keeps the base equation. +const OBJECTIVE_LATEX_ENSEMBLE: Partial> = { + ket_infidelity: "\\bar{\\mathcal{F}} = \\tfrac{1}{M}\\textstyle\\sum_{m} |\\langle \\psi_g | \\psi_m(T) \\rangle|^2", + unitary_infidelity: + "\\bar{\\mathcal{F}} = \\tfrac{1}{M}\\textstyle\\sum_{m} \\tfrac{1}{d^2}\\,|\\mathrm{Tr}(U_{\\mathrm{goal}}^\\dagger U_m)|^2", + unitary_free_phase: + "\\bar{\\mathcal{F}} = \\tfrac{1}{M}\\textstyle\\sum_{m} \\tfrac{1}{d^2}\\,|\\mathrm{Tr}(U_{\\mathrm{goal}}(\\theta)^\\dagger U_m)|^2", + density_infidelity: "\\bar{\\mathcal{F}} = \\tfrac{1}{M}\\textstyle\\sum_{m} \\mathrm{Tr}(\\rho_g\\, \\rho_m(T))", +} + +// Per-term LaTeX + label, keyed by FormulationTerm.kind (objective terms, dynamics +// integrators, and typed constraints). Unknown kind → label only, no equation. +const TERM_LATEX: Record = { + // objective terms — Piccolo QuadraticRegularizer on the control & its derivatives + reg_u: "\\mathcal{R}_{u} = \\tfrac{1}{2}\\textstyle\\sum_k \\lVert u_k \\rVert^2", + reg_du: "\\mathcal{R}_{\\dot u} = \\tfrac{1}{2}\\textstyle\\sum_k \\lVert \\dot u_k \\rVert^2", + reg_ddu: "\\mathcal{R}_{\\ddot u} = \\tfrac{1}{2}\\textstyle\\sum_k \\lVert \\ddot u_k \\rVert^2", + // [dyn] integrator constraints + dyn_bilinear: "x_{k+1} = \\exp\\!\\big(\\Delta t_k\\, G(u_k)\\big)\\, x_k", + dyn_derivative: + "u_{k+1} = u_k + \\Delta t_k\\,\\dot u_k,\\quad \\dot u_{k+1} = \\dot u_k + \\Delta t_k\\,\\ddot u_k", + // [eq] equality constraints + state_pin: "x_1 = x_{\\mathrm{init}}", + control_endpoints: "u_1 = u_N = 0", + calibration_pin: "u_j = u_j^{\\mathrm{cal}}", + time_consistency: "t_{k+1} = t_k + \\Delta t_k", + equal_timesteps: "\\Delta t_1 = \\Delta t_2 = \\cdots = \\Delta t_N", + // [ineq] inequality constraints + final_fidelity: "\\mathcal{F}(T) \\ge \\mathcal{F}_0", + leakage: "\\textstyle\\sum_{l \\notin \\mathcal{C}} \\lvert \\langle l | \\psi \\rangle \\rvert^2 \\le \\epsilon", + // [bnd] bounds constraints + bounds: "\\lvert u_k \\rvert \\le u_{\\max}", + du_bound: "\\lvert \\dot u_k \\rvert \\le \\dot u_{\\max}", + ddu_bound: "\\lvert \\ddot u_k \\rvert \\le \\ddot u_{\\max}", + dt_bounds: "\\Delta t_{\\min} \\le \\Delta t_k \\le \\Delta t_{\\max}", + state_bound: "-1 \\le \\vec{x}_k \\le 1", +} +const TERM_LABEL: Record = { + reg_u: "regularizer", + reg_du: "regularizer", + reg_ddu: "regularizer", + dyn_bilinear: "quantum dynamics", + dyn_derivative: "derivative chain", + state_pin: "initial state", + control_endpoints: "control endpoints", + calibration_pin: "calibration pin", + time_consistency: "time consistency", + equal_timesteps: "equal timesteps", + final_fidelity: "final fidelity", + leakage: "leakage suppression", + bounds: "amplitude bound", + du_bound: "slew bound", + ddu_bound: "accel bound", + dt_bounds: "Δt bounds", + state_bound: "state bound", +} +// Constraint category tags (mirrors Piccolo's [dyn]/[eq]/[ineq]/[bnd]). Unknown → untagged. +const CONSTRAINT_CATEGORY: Record = { + dyn_bilinear: "dyn", + dyn_derivative: "dyn", + state_pin: "eq", + control_endpoints: "eq", + calibration_pin: "eq", + time_consistency: "eq", + equal_timesteps: "eq", + final_fidelity: "ineq", + leakage: "ineq", + bounds: "bnd", + du_bound: "bnd", + ddu_bound: "bnd", + dt_bounds: "bnd", + state_bound: "bnd", +} +const CATEGORY_ORDER = ["dyn", "eq", "ineq", "bnd", ""] + +interface Term { + kind: string + label?: string + params?: Record + latexRaw?: string +} + +const paramsText = (params: Record): string => + Object.entries(params) + .map(([k, v]) => `${k} ${v}`) + .join(" · ") +const termHead = (t: Term): string => { + const head = t.label ?? TERM_LABEL[t.kind] ?? t.kind + const p = t.params ? paramsText(t.params) : "" + return p ? `${head} · ${p}` : head +} +const renderMath = (kind: string): string | undefined => { + const latex = TERM_LATEX[kind] + return latex ? katex.renderToString(latex, { throwOnError: false }) : undefined +} +// A term may carry an inline LaTeX override (e.g. the consolidated regularizer, +// whose equation depends on which control derivatives the mode activates). +const renderTermMath = (t: Term): string | undefined => + t.latexRaw ? katex.renderToString(t.latexRaw, { throwOnError: false }) : renderMath(t.kind) +// The regularizer penalizes the control and (for a smooth pulse) its derivatives. +// Shown as ONE "regularizer" objective with a combined norm — not one row per order. +const regLatex = (smooth: boolean): string => + smooth + ? "\\mathcal{R} = \\tfrac{1}{2}\\textstyle\\sum_k \\Delta t_k^2 \\big( \\lVert u_k \\rVert^2 + \\lVert \\dot u_k \\rVert^2 + \\lVert \\ddot u_k \\rVert^2 \\big)" + : "\\mathcal{R} = \\tfrac{1}{2}\\textstyle\\sum_k \\Delta t_k^2 \\lVert u_k \\rVert^2" +const categoryOf = (kind: string): string => CONSTRAINT_CATEGORY[kind] ?? "" + +// Merge canonical (derived) structure with recorded terms: keep EVERY recorded +// term (never collapse two of the same kind — e.g. amplitude + detuning bound), +// and add a derived term only when its kind wasn't recorded. Derived first so the +// canonical scaffold reads before problem-specific specializations. +const mergeTerms = (derived: Term[], recorded: FormulationTerm[]): Term[] => { + const recordedKinds = new Set(recorded.map((r) => r.kind)) + const derivedOnly = derived.filter((d) => !recordedKinds.has(d.kind)) + return [...derivedOnly, ...recorded.map((r) => ({ kind: r.kind, label: r.label, params: r.params }))] +} + +// The objective terms Piccolo attaches beyond the primary: the regularizer +// (smoothness). Robustness is NOT a separate term here — ensemble robustness is +// folded into the primary objective (see OBJECTIVE_LATEX_ENSEMBLE + `ensemble`). +const canonicalObjectives = (p: FormulationProjection): Term[] => [ + { kind: "reg", label: "regularizer", latexRaw: regLatex(p.parameterization === "smooth") }, +] + +// The constraint ledger implied by the mode. Mirrors what Piccolo actually builds +// for a smooth pulse (verified against DirectTrajOpt / Piccolo templates): +// [dyn] quantum dynamics (BilinearIntegrator) — always +// [dyn] derivative chain (u,u̇ integrators) — smooth pulse +// [eq] initial state pin; control endpoints u₁=u_N=0 (smooth) +// [eq] time consistency (t_{k+1}=tₖ+Δtₖ) + equal timesteps — whenever Δt is a +// free variable (smooth pulses carry a free, uniform Δt; so does min-time) +// [bnd] amplitude bound; accel bound (smooth); state box bound +// [ineq] final-fidelity floor (min-time); leakage (when on) +// NOT emitted by default (Piccolo defaults): slew/du bound (du_bound = Inf) and a +// Δt box bound (Δt_bounds = nothing) — surfaced only if the recorded data has them. +const canonicalConstraints = (p: FormulationProjection): Term[] => { + const smooth = p.parameterization === "smooth" + const freeDt = smooth || p.time_mode === "min_time" + const t: Term[] = [{ kind: "dyn_bilinear" }] + if (smooth) t.push({ kind: "dyn_derivative" }) + t.push({ kind: "state_pin" }) + if (smooth) t.push({ kind: "control_endpoints" }) + if (freeDt) t.push({ kind: "time_consistency" }, { kind: "equal_timesteps" }) + t.push({ kind: "bounds" }) + if (smooth) t.push({ kind: "ddu_bound" }) + t.push({ kind: "state_bound" }) + if (p.time_mode === "min_time") t.push({ kind: "final_fidelity" }) + if (p.leakage) t.push({ kind: "leakage" }) + return t +} + +// The Solve scalars, humanized so they read as parameters rather than a raw blob. +// Surfaced as mode-style badges (label + value); unknown keys fall back to the +// shared humanizeKey. No symbol/unit — the raw value carries the meaning. +const SOLVE_LABEL: Record = { + T: "duration", + N: "timesteps", + max_iter: "max iterations", + integrator: "integrator", + dt: "timestep", +} +const solveRows = (solve: Record): { label: string; value: string }[] => + Object.entries(solve).map(([k, v]) => ({ + label: SOLVE_LABEL[k] ?? humanizeKey(k), + value: String(v), + })) + +// The "type" chip surfaces the specific gate when the target names one: +// gate + target "X" → "x-gate", "CZ" → "cz-gate". No named target (or a non-gate +// trajectory) → undefined, leaving the plain trajectory type ("gate"/"ket"/…). +const gateKind = (p: FormulationProjection): string | undefined => { + const t = p.target?.trim() + if (!t || p.trajectory_type !== "gate") return undefined + return /gate/i.test(t) ? t.toLowerCase() : `${t.toLowerCase()}-gate` +} + +export function FormulationView(props: { entity: Record }) { + const proj = createMemo(() => formulationProjection(props.entity)) + // Leakage is surfaced as a constraint row below, so drop it from the mode bar + // to avoid showing the same thing twice. + const badges = createMemo(() => { + const p = proj() + const gk = gateKind(p) + return modeBadges(p as unknown as Record) + .filter((b) => b.label !== "leakage") + .map((b) => (b.label === "type" && gk ? { ...b, value: gk } : b)) + }) + // Ensemble robustness (the only kind in the data) folds into the primary: it + // gates the averaged equation and the "M = n_systems" annotation on that row. + const ensemble = createMemo(() => { + const r = proj().robustness + if (!r?.kind || r.kind === "none") return undefined + const n = r.params?.n_systems + return { m: typeof n === "number" ? n : undefined } + }) + const primaryHtml = createMemo(() => { + const key = proj().primaryKey + const latex = (ensemble() && OBJECTIVE_LATEX_ENSEMBLE[key]) || OBJECTIVE_LATEX[key] + return latex ? katex.renderToString(latex, { throwOnError: false }) : undefined + }) + + const objectiveRows = createMemo(() => { + const p = proj() + return mergeTerms(canonicalObjectives(p), p.objectives).map((t) => ({ ...t, latex: renderTermMath(t) })) + }) + + // Every constraint: the derived canonical ledger + recorded constraints, plus a + // final-fidelity row carrying its threshold when the projection knows one. + // Sorted by category so [dyn]/[eq]/[ineq]/[bnd] read as groups. + const constraintRows = createMemo(() => { + const p = proj() + const merged = mergeTerms(canonicalConstraints(p), p.constraints) + const ff = p.time_params?.final_fidelity ?? p.derivedFinalFidelity + if (typeof ff === "number") { + const existing = merged.find((r) => r.kind === "final_fidelity") + if (existing) { + if (!existing.label) existing.label = `final fidelity ≥ ${ff}` + } else { + merged.push({ kind: "final_fidelity", label: `final fidelity ≥ ${ff}` }) + } + } + return merged + .map((t) => ({ ...t, cat: categoryOf(t.kind), latex: renderTermMath(t) })) + .sort((a, b) => CATEGORY_ORDER.indexOf(a.cat) - CATEGORY_ORDER.indexOf(b.cat)) + }) + + return ( +
+
+ + {(b) => ( + + {b.label}}> + {b.label} + {b.value} + + + )} + + + {(solve) => ( + )}> + {(row) => ( + + {row.label} + {row.value} + + )} + + )} + +
+ +
Objective
+
+
+
+ {PRIMARY_LABEL[proj().primaryKey]} + + {(e) => ensemble{e().m ? ` · M = ${e().m}` : ""}} + +
+ {(html) =>
} +
+ + {(o) => ( +
+
+ {termHead(o)} +
+ {(html) =>
} +
+ )} + +
+ + 0}> +
Constraints
+
+ + {(c) => ( +
+
+ {termHead(c)} +
+ {(html) =>
} +
+ )} + +
+ + +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.test.ts new file mode 100644 index 00000000..23a0246a --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { frontier, type QNode } from "./frontier" + +const graph: QNode[] = [ + { id: "platform", prereqs: [], batchable: false }, + { id: "count", prereqs: ["platform"], batchable: true }, + { id: "homogeneous", prereqs: ["count"], batchable: true }, + // topology is only RELEVANT when there is more than one component (value predicate) + { id: "topology", prereqs: ["count"], relevant: (a) => Number(a.count) > 1, batchable: true }, + // the gate's options are generated from the platform's VALUE (value-dependent) + { id: "gate", prereqs: ["platform"], optionsFrom: ["platform"], batchable: false }, +] + +describe("frontier DAG scheduler (spec §4.1)", () => { + test("with no answers, only prereq-free nodes are answerable", () => { + expect(frontier(graph, {}).map((n) => n.id)).toEqual(["platform"]) + }) + + test("answering platform unlocks count + gate (gate's optionsFrom now satisfied); platform drops out", () => { + const ids = frontier(graph, { platform: "transmon" }).map((n) => n.id) + expect(ids).toContain("count") + expect(ids).toContain("gate") + expect(ids).not.toContain("platform") + }) + + test("value-dependent guardrail: a node stays out until its optionsFrom source is answered", () => { + const g: QNode[] = [ + { id: "platform", prereqs: [], batchable: false }, + { id: "gate", prereqs: [], optionsFrom: ["platform"], batchable: false }, + ] + expect(frontier(g, {}).map((n) => n.id)).toEqual(["platform"]) // gate blocked by optionsFrom + expect(frontier(g, { platform: "transmon" }).map((n) => n.id)).toContain("gate") + }) + + test("relevance-pruning: topology is OUT when count=1 though its prereq is met; IN when count>1", () => { + const single = frontier(graph, { platform: "transmon", count: 1 }).map((n) => n.id) + expect(single).not.toContain("topology") + expect(single).toContain("homogeneous") // prereq met, no relevance gate → still in + const multi = frontier(graph, { platform: "transmon", count: 2 }).map((n) => n.id) + expect(multi).toContain("topology") + }) + + test("answered nodes are excluded", () => { + const ids = frontier(graph, { + platform: "transmon", + count: 2, + homogeneous: true, + topology: "linear-chain", + }).map((n) => n.id) + expect(ids).not.toContain("platform") + expect(ids).not.toContain("count") + expect(ids).not.toContain("topology") + expect(ids).toContain("gate") // still unanswered, optionsFrom(platform) satisfied + }) + + test("batchable flag partitions the frontier (mechanical batched, semantic singular)", () => { + const f = frontier(graph, { platform: "transmon", count: 2 }) + const batched = f.filter((n) => n.batchable).map((n) => n.id) + const singular = f.filter((n) => !n.batchable).map((n) => n.id) + expect(batched).toContain("homogeneous") + expect(batched).toContain("topology") + expect(singular).toContain("gate") + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.ts new file mode 100644 index 00000000..27fa045a --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/frontier.ts @@ -0,0 +1,44 @@ +// Pure DAG scheduler for the multipartite setup interview (spec-20260709 §4.1). +// +// v1: this is the tested REFERENCE that the AGENTS.md / SCORE frontier-batching +// policy mirrors — the LLM follows that prose to build each `question` payload; +// nothing wires `frontier` to a runtime caller yet (the acknowledged §4.1 gap). +// Pure, no I/O, so it is directly unit-testable and drift-checkable. + +export interface QNode { + id: string; + /** Node ids whose answers must exist before this node is askable. */ + prereqs: string[]; + /** Value predicate over PRIOR answers; default () => true. Prunes an + * answered-prereqs-but-IRRELEVANT node out of the frontier (e.g. `topology` + * only when component-count > 1; "which state" only if target === "state"). */ + relevant?: (answers: Record) => boolean; + /** Prior answer ids whose VALUES generate this node's options — a + * value-dependent node is not askable until they are answered (its option + * set cannot be computed otherwise). */ + optionsFrom?: string[]; + /** false = semantic/branching, ask SINGLY (platform, target, objective); + * true = mechanical, batchable into one multi-question `question` call. */ + batchable: boolean; +} + +const answered = (answers: Record, id: string): boolean => + Object.prototype.hasOwnProperty.call(answers, id) && answers[id] !== undefined; + +/** + * The nodes answerable NOW: not already answered, every `prereqs` id answered, + * every `optionsFrom` id answered (options computable), and `relevant(answers)` + * true. Pruned/blocked nodes are simply absent from the returned list. Pure. + * + * The caller batches all `batchable: true` results into ONE `question` tool call + * and asks each `batchable: false` result singly, re-running after each submit. + */ +export function frontier(graph: QNode[], answers: Record): QNode[] { + return graph.filter((n) => { + if (answered(answers, n.id)) return false; // already answered + if (!n.prereqs.every((id) => answered(answers, id))) return false; // prereqs unmet + if (n.optionsFrom && !n.optionsFrom.every((id) => answered(answers, id))) return false; // options not computable + if (n.relevant && !n.relevant(answers)) return false; // irrelevant given prior values + return true; + }); +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/getting-started.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/getting-started.tsx new file mode 100644 index 00000000..39379921 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/getting-started.tsx @@ -0,0 +1,222 @@ +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 +// session. Presentation only; the submit wiring (composer draft + submit) is +// app-side and arrives via onStart (same pattern as the entity rail's onAsk). +// Copy is en-only by design, consistent with the default-locale-en patch. + +// TODO(repertoire-chips): these become data — one chip per installed+entitled +// score (name/outcome/duration from SCORE.md frontmatter) once the server +// exposes the compiled repertoire; static, feature-diverse set until then. +// The set spans the lifetime: onboard (first run routes any chip into the +// overture) → design with memory/recommendations → warm-start from the pulse +// bank → go fast with Veloce. +// NOTE (Kate, chat-redesign): keep the FULL feature-diverse set — these are +// designed to become suggestion data (per profile/activity/entitled score, see +// the repertoire-chips TODO above); don't condense the static stand-in. +// Labels are the full recommendation text — NOT hand-shortened. These become +// server-generated suggestions (see the repertoire-chips TODO): the label will +// BE the recommendation, so we honor it verbatim and let the row wrap. +export const AMICODE_STARTERS: readonly { label: string; prompt: string }[] = [ + { + label: "Walk me through my first experiment", + prompt: "walk me through setting up my first experiment", + }, + { + label: "Help me with a coding task", + prompt: "I have a coding task — let's get started", + }, + // NOTE(spec B): the static "Resume my pulse design" chip was replaced by the + // conditional resumeName-driven chip below — Resume renders only when a + // problem workspace actually exists (no empty chrome). + { + label: "Warm-start from a previous result", + prompt: "warm-start a new experiment from my previous results", + }, + { + label: "Go fast with Veloce", + prompt: "turn on Veloce — auto-accept your high-confidence recommendations, and still confirm before any solve", + }, + { + label: "What can Amico do?", + prompt: "what can Amico do?", + }, +] + +// The three-beat arc of a research session, from setup to results. +const STEPS = ["① Define your system and problem", "② Optimize and iterate", "③ Analyze and refine"] + +/** amicode chat-redesign (Kate): chips-only row for the composer-as-hero start + * screen — no tagline, no byline, no steps. Quiet neutral pills (Kimi-style): + * a filled body (bg-layer-02) + a hairline (border-strong) so they read on the + * near-white start page; muted ink that lifts on hover. A transparent fill with + * a 10%-alpha border was ~1.25:1 vs the page — effectively invisible in light. */ +export type StarterChip = { label: string; prompt: string; dataSlot?: string } + +export function AmicodeStarterChips(props: { + onStart: (prompt: string) => void + // Dynamic chip set, ranked by the caller from the problem history + // (Resume → Warm-start → Retry → static pad). Falls back to AMICODE_STARTERS + // + the legacy resumeName/onResume pair when `chips` is not given. + chips?: StarterChip[] + resumeName?: string + onResume?: () => void +}) { + // The chips' OWN wrapping row (Kate: separate container from the folder + // picker) — centered, quiet pills. Class-based styling so hover works. + const pill = + "rounded-full border border-[var(--v2-border-border-strong)] bg-[var(--v2-background-bg-layer-02)] px-3 py-1 text-[12px] leading-4 whitespace-nowrap text-[var(--v2-text-text-muted)] cursor-pointer transition-colors duration-150 hover:text-[var(--v2-text-text-base)] hover:bg-[var(--v2-background-bg-layer-03)]" + return ( +
+ + + {(starter) => ( + + )} + + + + + + } + > + {(chips) => ( + + {(chip) => ( + + )} + + )} + +
+ ) +} + +export function AmicodeGettingStarted(props: { + onStart: (prompt: string) => void + // spec B: Resume verb — set only when a problem workspace exists (no empty chrome) + resumeName?: string + onResume?: () => void + // amicode: the compact new-session layout hides the how-it-works steps (tagline + // + chips only). Defaults on so the classic NewSessionView is unchanged. + showSteps?: boolean +}) { + return ( +
+
+ +
+
+ By Harmoniqs +
+ +
+ {(step) => {step}} +
+
+
+ + {(starter) => ( + + )} + + {props.resumeName && props.onResume && ( + + )} +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/home-cards.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/home-cards.tsx new file mode 100644 index 00000000..a1a3feca --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/home-cards.tsx @@ -0,0 +1,1402 @@ +import { For, Show, createEffect, createMemo, on, type JSX, createSignal, onCleanup } from "solid-js" +import { MarkDetailed } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" +import { fileToBase64 } from "./upload" +import { drivePaths, linePath } from "./run-plot" + +// AMICODE: home-screen card strip (the "central screen" Aaron wanted the H-bot +// and useful practitioner info on). Two identity heroes — MEET AMICO (who your +// pal is + what it can do) and ABOUT YOU (your profile, earned stats, and the +// live "Amico remembers" trust surface) — over a row of action cards. All +// presentation: the app owns fetching (/amicode/profile, /amicode/problems, +// /amicode/run-status) and passes data + callbacks. Inline styles on v2 tokens, +// matching the entity-rail/getting-started convention → dark/light correct. +// en-only by design, consistent with the default-locale-en patch. + +// --------------------------------------------------------------------------- +// profile response shape + parser (mirrors ./problem.ts's parse discipline: +// one success schema, tolerant of missing fields, never throws) +// --------------------------------------------------------------------------- +export interface ProfileStats { + problems: number + runs: number + best_fidelity: number | null + banked: number + since: string | null +} +export interface ProfileYou { + name: string + affiliation: string | null + scholar: string | null + affiliation_logo: string | null + focus: string | null + description: string | null + avatar: string | null + platforms: string[] + stats: ProfileStats + remembers: { title: string; detail: string }[] +} +export type ProfileView = { ok: true; you: ProfileYou } | { ok: false; error: string } + +export function parseProfileResponse(raw: unknown): ProfileView { + const r = raw as any + if (!r || typeof r !== "object" || r.ok !== true || !r.you) { + return { ok: false, error: typeof r?.error === "string" ? r.error : "unavailable" } + } + const you = r.you + const stats = you.stats ?? {} + return { + ok: true, + 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, + description: typeof you.description === "string" ? you.description : null, + avatar: typeof you.avatar === "string" ? you.avatar : null, + platforms: Array.isArray(you.platforms) ? you.platforms.filter((p: unknown) => typeof p === "string") : [], + stats: { + problems: Number(stats.problems) || 0, + runs: Number(stats.runs) || 0, + best_fidelity: typeof stats.best_fidelity === "number" ? stats.best_fidelity : null, + banked: Number(stats.banked) || 0, + since: typeof stats.since === "string" ? stats.since : null, + }, + remembers: Array.isArray(you.remembers) + ? you.remembers + .filter((m: any) => m && typeof m.title === "string" && m.title.trim()) + .map((m: any) => ({ title: String(m.title), detail: String(m.detail ?? m.title) })) + : [], + }, + } +} + +// --------------------------------------------------------------------------- +// display helpers +// --------------------------------------------------------------------------- +const PLATFORM_DISPLAY: Record = { + rydberg: "neutral atoms", + transmon: "transmon", + "cavity-transmon": "cavity-QED", + "spin qubits": "spin qubits", + fluxonium: "fluxonium", + ions: "trapped ions", + bosonic: "bosonic", +} +function platformName(key: string): string { + return PLATFORM_DISPLAY[key] ?? key +} +function derivedFocus(platforms: string[]): string { + const top = platforms.slice(0, 2).map(platformName) + if (top.length === 0) return "quantum control" + return `${top.join(" & ")} pulse design` +} +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean) + if (parts.length === 0) return "?" + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} +function fidelity(value: number | null): string { + if (value === null) return "—" + if (value >= 1) return "1.0" + return value.toFixed(5) +} +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] +function sinceLabel(iso: string | null): string | undefined { + if (!iso) return undefined + const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!m) return undefined + const month = MONTHS[Number(m[2]) - 1] ?? m[2] + const day = Number(m[3]) + const started = Date.parse(iso) + const label = `since ${month} ${day}` + if (Number.isNaN(started)) return label + const days = Math.max(0, Math.round((Date.now() - started) / 86_400_000)) + if (days <= 0) return `${label} · today` + return `${label} · ${days} day${days === 1 ? "" : "s"}` +} + +// --------------------------------------------------------------------------- +// primitives +// --------------------------------------------------------------------------- +const CARD: JSX.CSSProperties = { + display: "flex", + "flex-direction": "column", + "min-width": "0", + border: "1px solid var(--v2-border-border-base)", + "border-radius": "var(--radius-lg)", + background: "var(--v2-background-bg-layer-01)", + padding: "14px 16px", +} +// inline styles can't express @media — read the preference once; a mid-session +// OS toggle settles on next mount, which is fine for decorative motion +const reduceMotion = typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches + +const HERO_CARD: JSX.CSSProperties = { ...CARD } +const EYEBROW: JSX.CSSProperties = { + "font-size": "10px", + "font-weight": "700", + "letter-spacing": "0.1em", + "text-transform": "uppercase", + color: "var(--v2-text-text-faint)", +} +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} +
+ ) +} + +/** One "Amico remembers" line. The detail used to live only in a title + * tooltip — hover-only, invisible to keyboard and touch. Now a disclosure: + * the row is a button that unfolds its detail inline. */ +function RememberRow(props: { title: string; detail: string }) { + const [open, setOpen] = createSignal(false) + const hasDetail = () => props.detail.trim() !== "" && props.detail.trim() !== props.title.trim() + return ( +
+ + +
+ {props.detail} +
+
+
+ ) +} + +function PrimaryButton(props: { children: JSX.Element; onClick: () => void; slot?: string }) { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// MEET AMICO — static identity + capabilities (the H-bot's home) +// --------------------------------------------------------------------------- +const AMICO_CAN = [ + "design pulses from a conversation", + "optimize for fidelity, speed, or robustness", + "warm-start from your pulse bank", + "tune & calibrate on real hardware", +] + +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.key === " ") && !(e.target as HTMLElement).closest("button")) { + e.preventDefault() + props.onStart("") + } + }} + style={{ + ...HERO_CARD, + position: "relative", + overflow: "hidden", + cursor: "pointer", + // hover = elevation only — a bounds-moving lift jitters the grid + "box-shadow": hover() ? "var(--v2-elevation-raised, 0 6px 20px rgba(0,0,0,0.18))" : "none", + transition: reduceMotion ? "none" : "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 */} + + } + > + {(src) => ( + {props.name} + )} + + ) +} + +function Stat(props: { value: string; label: string }) { + return ( +
+ + {props.value} + + + {props.label} + +
+ ) +} + +function AboutYouCard(props: { + view: ProfileView | undefined + onEdit: () => void + onSave?: (fields: { + name?: string + affiliation?: string + focus?: string + scholar?: string + affiliation_logo?: string + }) => Promise + onStart: (prompt: string) => void +}) { + const you = createMemo(() => (props.view?.ok ? props.view.you : undefined)) + // In-place editing (replaces the edit-in-chat flow when onSave is wired): + // the pencil toggles a small form over the identity fields; Save posts and + // the card re-renders from the refetched profile. + const [editing, setEditing] = createSignal(false) + // A persisted-but-dead logo URL (e.g. the sunset Clearbit CDN) must fall + // back to the monogram tile, not render the broken-image glyph in the hero. + const [logoBroken, setLogoBroken] = createSignal(false) + createEffect( + on( + () => (props.view?.ok ? props.view.you.affiliation_logo : undefined), + () => setLogoBroken(false), + ), + ) + const [saving, setSaving] = createSignal(false) + const [draft, setDraft] = createSignal({ name: "", affiliation: "", focus: "", scholar: "", affiliation_logo: "" }) + const [suggestions, setSuggestions] = createSignal<{ name: string; domain: string; logo: string }[]>([]) + // combobox keyboard state: the highlighted suggestion (-1 = none) + const [activeSug, setActiveSug] = createSignal(-1) + const beginEdit = () => { + setSuggestions([]) // a dropdown left open at cancel must not haunt the next edit + const y = you() + setDraft({ + name: y?.name ?? "", + affiliation: y?.affiliation ?? "", + focus: y?.focus ?? "", + scholar: y?.scholar ?? "", + affiliation_logo: y?.affiliation_logo ?? "", + }) + setEditing(true) + } + // Institution lookup lives in institution-lookup.ts (shared with the + // onboarding wizard); this card owns debounce, sequencing, and signals. + let searchTimer: ReturnType | undefined + let searchSeq = 0 // out-of-order guard: a slow "har" response must not clobber "harvard"'s + onCleanup(() => { + if (searchTimer) clearTimeout(searchTimer) + }) + const searchInstitutions = (q: string) => { + if (searchTimer) clearTimeout(searchTimer) + if (q.trim().length < 2) { + searchSeq++ + setSuggestions([]) + return + } + searchTimer = setTimeout(() => { + const seq = ++searchSeq + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) + }, 200) + } + // Counter, not boolean: pick A then B while A's Wikidata round-trip is in + // flight — A's finally must not re-enable Save while B still resolves (the + // boolean version re-introduced the save-races-logo bug it claimed to fix). + const [resolvingLogo, setResolvingLogo] = createSignal(0) + const pickInstitution = async (sug: InstitutionSuggestion) => { + // Instant favicon mark, then resolve the real BRAND logo (Wikidata P154 → + // Wikipedia pageimage → favicon; see institution-lookup.ts). Save is held + // while resolving so the upgraded URL is what gets persisted. + setDraft({ ...draft(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) + setSuggestions([]) + setResolvingLogo((n) => n + 1) + try { + const logo = await resolveBrandLogo(sug.name, sug.domain) + if (logo && draft().affiliation === sug.name) setDraft({ ...draft(), affiliation_logo: logo }) + } finally { + setResolvingLogo((n) => Math.max(0, n - 1)) + } + } + + const openExternal = (url: string) => { + if (window.parent !== window) window.parent.postMessage({ source: "amicode", kind: "open-external", url }, "*") + else window.open(url, "_blank", "noreferrer") + } + + const [saveError, setSaveError] = createSignal(undefined) + const save = async () => { + if (!props.onSave) return + setSaving(true) + setSaveError(undefined) + try { + await props.onSave(draft()) + setEditing(false) + } catch { + // Silent reverts read as "the button is broken" — say what happened. + setSaveError("Couldn't save — server unreachable. Try again.") + } finally { + setSaving(false) + } + } + /** ⌘/Ctrl+V fallback: neither native paste nor navigator.clipboard reach a + * cross-origin iframe inside a VS Code webview (the parent has no + * clipboard-read to delegate) — so ask the EXTENSION HOST over the message + * bridge; it reads the OS clipboard and replies. navigator.clipboard is + * still tried first for plain-browser contexts. */ + const readClipboardViaBridge = () => + new Promise((resolve) => { + const nonce = Math.random().toString(36).slice(2) + const onMsg = (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string; nonce?: string; text?: string } | undefined + if (d?.source !== "amicode" || d.kind !== "clipboard" || d.nonce !== nonce) return + window.removeEventListener("message", onMsg) + resolve(typeof d.text === "string" ? d.text : "") + } + window.addEventListener("message", onMsg) + window.parent.postMessage({ source: "amicode", kind: "clipboard-request", nonce }, "*") + setTimeout(() => { + window.removeEventListener("message", onMsg) + resolve("") + }, 1500) + }) + // Fields carrying this handler also mark data-amc-clipboard="self" so the + // app-wide framed-clipboard fallback (utils/global-clipboard.ts) leaves + // their paste to this handler instead of double-inserting. + const pasteFallback = (apply: (value: string) => void) => async (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "v") return + // Plain-browser contexts: native paste works — never preventDefault it + // (Firefox/Safari would lose paste entirely; the bridge is iframe-only). + if (window.parent === window) return + e.preventDefault() + e.stopPropagation() + const el = e.currentTarget as HTMLInputElement + let clip = "" + try { + clip = (await navigator.clipboard.readText()) ?? "" + } catch { + /* delegated-permission miss */ + } + if (!clip && window.parent !== window) clip = await readClipboardViaBridge() + if (!clip) return + const start = el.selectionStart ?? el.value.length + const end = el.selectionEnd ?? el.value.length + apply(el.value.slice(0, start) + clip + el.value.slice(end)) + } + + const focusLine = createMemo(() => { + const y = you() + if (!y) return "" + const focus = y.focus ?? derivedFocus(y.platforms) + return y.affiliation ? `${y.affiliation} · ${focus}` : focus + }) + const fresh = createMemo(() => { + const y = you() + return !!y && y.stats.problems === 0 && y.stats.runs === 0 + }) + + return ( +
+
+
+
About you
+ + self-improving — Amico refines this as you work + +
+ + + +
+ + + {props.view && !props.view.ok ? "Profile unavailable." : "Loading…"} +
+ } + > + {(y) => ( + <> + +
+ + {(y().affiliation ?? "") + .split(/\s+/) + .map((w) => w[0]) + .join("") + .slice(0, 3) + .toUpperCase()} + + } + > + {/* LinkedIn-style logo card: white tile + padding keeps any + mark crisp and consistent on both themes */} + + {y().affiliation setLogoBroken(true)} + /> + + +
+
+ {y().affiliation} +
+
Institution
+
+
+
+
+
+
+ setDraft({ ...draft(), name: e.currentTarget.value })} + onKeyDown={pasteFallback((v) => setDraft({ ...draft(), name: v }))} + /> + } + > + + {y().name} + + + + + +
+
+ +
+
+ + (e.currentTarget.style.display = "none")} + /> + + 0} + aria-controls="amc-inst-listbox" + aria-activedescendant={activeSug() >= 0 ? `amc-inst-option-${activeSug()}` : undefined} + onInput={(e) => { + setDraft({ ...draft(), affiliation: e.currentTarget.value }) + setActiveSug(-1) + searchInstitutions(e.currentTarget.value) + }} + onKeyDown={(e) => { + const open = suggestions().length > 0 + if (open && e.key === "ArrowDown") { + e.preventDefault() + setActiveSug((activeSug() + 1) % suggestions().length) + return + } + if (open && e.key === "ArrowUp") { + e.preventDefault() + setActiveSug((activeSug() - 1 + suggestions().length) % suggestions().length) + return + } + if (open && e.key === "Enter" && activeSug() >= 0) { + e.preventDefault() + void pickInstitution(suggestions()[activeSug()]) + setActiveSug(-1) + return + } + if (open && e.key === "Escape") { + e.preventDefault() + setSuggestions([]) + setActiveSug(-1) + return + } + void pasteFallback((v) => { + setDraft({ ...draft(), affiliation: v }) + searchInstitutions(v) + })(e) + }} + /> +
+ 0}> +
+ + {(sug, i) => ( + + )} + +
+
+
+ setDraft({ ...draft(), focus: e.currentTarget.value })} + onKeyDown={pasteFallback((v) => setDraft({ ...draft(), focus: v }))} + /> + setDraft({ ...draft(), scholar: e.currentTarget.value })} + onKeyDown={pasteFallback((v) => setDraft({ ...draft(), scholar: v }))} + /> +
+ + + saves to your profile + + } + > + + {saveError()} + + +
+
+ } + > + {focusLine()} + +
+ 0}> +
+ + {(p, i) => ( + + {i() > 0 ? " " : ""}◇ {platformName(p)} + + )} + +
+
+ +
+ {y().description} +
+
+
+
+ + +
+
+ No experiments yet — tell Amico what you're working on and it'll start remembering. +
+ props.onStart("help me set up my first pulse-design problem")} + > + Get started → + + + } + > +
+
+ + + +
+ + 0}> +
+
+ Amico remembers +
+
+ {(m) => } +
+ + + + {(label) => ( +
+ {label()} +
+ )} +
+ + + )} + +
+ ) +} + +// --------------------------------------------------------------------------- +// action cards +// --------------------------------------------------------------------------- +function ActionCard(props: { eyebrow: string; slot: string; onClick?: () => void; children: JSX.Element }) { + return ( +
props.onClick?.()} + onKeyDown={(e) => { + if (!props.onClick) return + if ((e.key === "Enter" || e.key === " ") && !(e.target as HTMLElement).closest("button, input, a")) { + e.preventDefault() + props.onClick() + } + }} + style={{ + ...CARD, + gap: "6px", + cursor: props.onClick ? "pointer" : "default", + "min-height": "88px", + }} + > +
{props.eyebrow}
+ {props.children} +
+ ) +} + +const CARD_TITLE: JSX.CSSProperties = { + "font-size": "13px", + "font-weight": "600", + color: "var(--v2-text-text-base)", + overflow: "hidden", + "text-overflow": "ellipsis", + "white-space": "nowrap", +} +const CARD_SUB: JSX.CSSProperties = { + "font-size": "11px", + color: "var(--v2-text-text-muted)", + overflow: "hidden", + "text-overflow": "ellipsis", + "white-space": "nowrap", +} +const SPARK_W = 96 +const SPARK_H = 22 +/** Pulse-first: the latest pulse trace when a snapshot exists (per-drive paths + * on a shared scale, companions at 0.55), else the convergence series. */ +function liveSparkPaths(run: HomeLiveRun): string[] { + const pulse = run.pulse ?? [] + if (pulse.length >= 2) { + const paths = drivePaths(pulse, run.drives ?? 1, SPARK_W, SPARK_H) + if (paths.length > 0) return paths + } + const d = linePath(run.series ?? [], SPARK_W, SPARK_H) + return d ? [d] : [] +} + +function Sparkline(props: { paths: string[] }) { + return ( + 0}> + + + {(d, i) => ( + + )} + + + + ) +} + +// --------------------------------------------------------------------------- +// LIBRARY — upload papers so Amico learns YOUR work (the personalization card) +// --------------------------------------------------------------------------- +function LibraryCard(props: { + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper: (filename: string, dataB64: string) => Promise + onStart: (prompt: string) => void +}) { + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal(undefined) + let fileInput: HTMLInputElement | undefined + + const upload = async (files: FileList | null) => { + if (!files || files.length === 0) return + setBusy(true) + setError(undefined) + try { + for (const file of Array.from(files)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + } + } catch { + setError("Upload failed — is the server up?") + } finally { + setBusy(false) + if (fileInput) fileInput.value = "" + } + } + + return ( + + void upload(e.currentTarget.files)} + /> +
+ {(props.library?.count ?? 0) > 0 + ? `${props.library!.count} paper${props.library!.count === 1 ? "" : "s"}` + : "Make Amico smarter"} +
+
{error() ?? props.library?.latestName ?? "upload papers — Amico learns your work"}
+
+ + + + +
+
+ ) +} + +export interface HomeLiveRun { + name?: string + iteration?: number | null + fidelity?: number | null + series?: number[] + // latest pulse snapshot, drives flattened back-to-back (see run-plot drivePaths) + pulse?: number[] + drives?: number +} + +// --------------------------------------------------------------------------- +// height compaction — the home page must fit ONE screen on a laptop (no box +// scrolling; scrolling is a phone affordance). Cards style inline, so these +// overrides need !important; thresholds are the WEBVIEW height (editor chrome +// already spent), not the display's. +// --------------------------------------------------------------------------- +const COMPACT_CSS = ` +@media (max-height: 880px) { + [data-slot="amicode-home-shell"] { padding-top: 16px !important; gap: 14px !important; } + [data-slot="amicode-home-band"] { max-height: 250px !important; } + [data-component="amicode-home-cards"] { gap: 8px !important; } + [data-component="amicode-card-meet"], [data-component="amicode-card-you"] { padding: 10px 14px !important; } + [data-component="amicode-card-meet"] svg { width: 36px !important; } + [data-slot="amicode-meet-identity"] { margin-top: 6px !important; } + [data-slot="amicode-meet-divider"] { margin: 8px 0 !important; } + [data-slot="amicode-meet-can"] { margin-bottom: 8px !important; } + [data-slot="amicode-you-institution"] { margin-top: 6px !important; padding: 6px 8px !important; } + [data-component="amicode-action-card"] { padding: 10px 12px !important; } + [data-slot="amicode-card-meet-cta"] { padding: 8px 16px !important; font-size: 13px !important; } + [data-component="amicode-footer"] { padding-top: 6px !important; } +} +@media (max-height: 760px) { + [data-slot="amicode-home-shell"] { padding-top: 8px !important; gap: 8px !important; padding-bottom: 10px !important; } + [data-slot="amicode-home-band"] { max-height: 180px !important; } + [data-component="amicode-card-meet"], [data-component="amicode-card-you"] { padding: 8px 12px !important; } + [data-slot="amicode-card-meet-cta"] { padding: 6px 14px !important; } + /* content tier: the capability bullets STAY (user-requested suggestions); + only the decor engine-line goes, and the bullets tighten */ + [data-slot="amicode-meet-engine"] { display: none !important; } + [data-slot="amicode-meet-can"] { gap: 2px !important; margin-bottom: 6px !important; } + [data-slot="amicode-meet-divider"] { margin: 6px 0 !important; } + [data-component="amicode-card-meet"] svg { width: 30px !important; } +} +` + +export function AmicodeHomeCards(props: { + profile: ProfileView | undefined + onStart: (prompt: string) => void + // Library ("make Amico smarter"): uploaded papers land in ~/.amico/library + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper?: (filename: string, dataB64: string) => Promise + onEditProfile: () => void + onSaveProfile?: (fields: { + name?: string + affiliation?: string + focus?: string + scholar?: string + affiliation_logo?: string + }) => Promise + // Jump back in + resumeName?: string + resumeMeta?: string + onResume?: () => void + // Now solving + liveRun?: HomeLiveRun + onOpenLiveRun?: () => void + // Pulse bank + onWarmStart?: () => void + // Run gallery (shareable cards) + onOpenGallery?: () => void +}) { + const stats = createMemo(() => (props.profile?.ok ? props.profile.you.stats : undefined)) + return ( +
+ +
+ + +
+ +
+ {/* Jump back in */} + + +
{props.resumeName}
+ +
{props.resumeMeta}
+
+
+ Resume → +
+
+
+ + {/* Now solving */} + + {(run) => ( + +
{run().name ?? "current run"}
+
+ + iter {run().iteration ?? "—"} · F {fidelity(run().fidelity ?? null)} + +
+
+ +
+
+ )} +
+ + {/* Pulse bank */} + 0}> + +
+ {stats()!.banked} pulse{stats()!.banked === 1 ? "" : "s"} banked +
+
+ Warm-start → +
+
+
+ + {/* Run gallery — the trophy case of shareable solve cards */} + + +
Run gallery
+
shareable cards of your solves
+
+ Browse & share → +
+
+
+ + {/* Library — papers that make Amico smarter (replaces the old starter + chips: Open chat already owns "start something"). Uploads go to + POST /amicode/library; the extension grants the agent read access + to ~/.amico/library so "read my latest paper" just works. */} + + + +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/institution-lookup.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/institution-lookup.ts new file mode 100644 index 00000000..a5588f25 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/institution-lookup.ts @@ -0,0 +1,58 @@ +// AMICODE: institution name/logo lookup — shared by the About-You card and the +// onboarding wizard. Pipeline (client-side by design, all CORS-open): +// 1. Clearbit autocomplete → name + domain (its logo CDN is sunset; only the +// suggest API lives on) +// 2. Wikidata P154 "logo image" → Commons FilePath @512px (crisp BRAND mark — +// the NYU torch, not the seal) +// 3. Wikipedia pageimage original (when P154 is absent) +// 4. Google faviconV2 @256 (instant placeholder + last resort) +// Pure async functions — callers own debounce/sequencing/signals. + +export type InstitutionSuggestion = { name: string; domain: string; logo: string } + +/** Instant favicon mark for a domain (placeholder + last-resort logo). */ +export function institutionLogoUrl(domain: string): string { + return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(domain)}&size=256` +} + +/** Clearbit autocomplete: name + domain suggestions (top 5). Never rejects. */ +export async function suggestInstitutions(query: string): Promise { + const q = query.trim() + if (q.length < 2) return [] + try { + const r = await fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(q)}`) + const rows = r.ok ? await r.json() : [] + return Array.isArray(rows) ? rows.slice(0, 5) : [] + } catch { + return [] + } +} + +const wikiJson = (url: string) => + fetch(url) + .then((r) => (r.ok ? r.json() : undefined)) + .catch(() => undefined) + +/** Best brand logo for an institution: Wikidata P154 → Wikipedia pageimage → + * favicon. Never rejects; always returns SOME url. */ +export async function resolveBrandLogo(name: string, domain: string): Promise { + const found = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(name)}&language=en&format=json&origin=*`, + ) + const qid = found?.search?.[0]?.id + if (qid) { + const claims = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P154&format=json&origin=*`, + ) + const file = claims?.claims?.P154?.[0]?.mainsnak?.datavalue?.value + if (typeof file === "string" && file) { + return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=512` + } + } + const page = await wikiJson( + `https://en.wikipedia.org/w/api.php?action=query&format=json&origin=*&redirects=1&titles=${encodeURIComponent(name)}&prop=pageimages&piprop=original`, + ) + const orig = (Object.values(page?.query?.pages ?? {})[0] as any)?.original?.source + if (typeof orig === "string" && /\.(svg|png|jpe?g|webp)$/i.test(orig)) return orig + return institutionLogoUrl(domain) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.test.ts new file mode 100644 index 00000000..8e77470a --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { shouldShowWizard } from "./onboarding-wizard" + +// The wizard is session-zero UI: it must appear exactly once — for a fresh +// profile that was never dismissed — and never fight a filled-in profile. +describe("shouldShowWizard", () => { + test("fresh profile, not dismissed → show", () => { + expect(shouldShowWizard({}, false)).toBe(true) + expect(shouldShowWizard({ affiliation: "", scholar: "", focus: "" }, false)).toBe(true) + }) + test("any identity field set → never show (wizard or card already filled it)", () => { + expect(shouldShowWizard({ affiliation: "NYU" }, false)).toBe(false) + expect(shouldShowWizard({ scholar: "https://scholar.google.com/x" }, false)).toBe(false) + expect(shouldShowWizard({ focus: "transmon gates" }, false)).toBe(false) + }) + test("dismissed → never show, even fresh", () => { + expect(shouldShowWizard({}, true)).toBe(false) + }) + test("profile not loaded yet → never flash the wizard", () => { + expect(shouldShowWizard(undefined, false)).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.tsx new file mode 100644 index 00000000..c98f1c2e --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/onboarding-wizard.tsx @@ -0,0 +1,601 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Mark, MarkDetailed } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" +import { fileToBase64 } from "./upload" + +// AMICODE: first-run onboarding wizard — the dedicated welcome UI (session +// zero, visual edition). Three steps: brand welcome → about-you (name, focus, +// institution with live logo lookup, Scholar) → preview + open chat. Saving +// goes through the SAME POST /amicode/profile the About-You card uses (the +// host passes onComplete), so the home page reflects it instantly and the +// chat's overture interview skips what's already answered. + +export type WizardFields = { + name: string + affiliation: string + focus: string + scholar: string + affiliation_logo: string +} + +/** Show the wizard only for a genuinely fresh profile: nothing identity-like + * saved yet and no prior dismiss. Pure — unit-tested. */ +export function shouldShowWizard( + profile: { affiliation?: string | null; scholar?: string | null; focus?: string | null } | undefined, + dismissed: boolean, +): boolean { + if (dismissed || profile === undefined) return false + return !profile.affiliation && !profile.scholar && !profile.focus +} + +const LABEL: Record = { + "font-size": "11px", + "font-weight": "650", + "letter-spacing": "0.06em", + "text-transform": "uppercase", + color: "var(--v2-text-text-muted)", + "margin-bottom": "4px", +} + +export function AmicodeOnboardingWizard(props: { + initialName?: string + onComplete: (fields: WizardFields) => Promise + /** Optional: enables the library step (papers that make Amico smarter). */ + onUploadPaper?: (filename: string, dataB64: string) => Promise + /** Optional: enables the finale "attach your own vault" affordance. Given a + * reference (owner/repo, git URL, or a local path), resolves to the attached + * mount name; rejects with a human-readable message on failure. */ + onAttachVault?: (ref: string) => Promise<{ name: string }> + onDismiss: () => void + onOpenChat: () => void +}) { + const [step, setStep] = createSignal<0 | 1 | 2 | 3>(0) + const FINAL = 3 + // library step state (skipped entirely when onUploadPaper isn't wired) + const [uploaded, setUploaded] = createSignal([]) + const [uploadBusy, setUploadBusy] = createSignal(false) + const [uploadError, setUploadError] = createSignal(undefined) + let paperInput: HTMLInputElement | undefined + const uploadPapers = async (files: FileList | null) => { + if (!files || files.length === 0 || !props.onUploadPaper) return + setUploadBusy(true) + setUploadError(undefined) + try { + for (const file of Array.from(files)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + setUploaded([...uploaded(), file.name]) + } + } catch { + setUploadError("Upload failed — PDFs only, up to 30MB.") + } finally { + setUploadBusy(false) + if (paperInput) paperInput.value = "" + } + } + const [fields, setFields] = createSignal({ + name: props.initialName ?? "", + affiliation: "", + focus: "", + scholar: "", + affiliation_logo: "", + }) + const [suggestions, setSuggestions] = createSignal([]) + const [resolvingLogo, setResolvingLogo] = createSignal(0) + const [saving, setSaving] = createSignal(false) + const [saveError, setSaveError] = createSignal(undefined) + + // finale: attach-your-own-vault (skipped entirely when onAttachVault isn't wired) + const [attachOpen, setAttachOpen] = createSignal(false) + const [attachRef, setAttachRef] = createSignal("") + const [attaching, setAttaching] = createSignal(false) + const [attached, setAttached] = createSignal([]) + const [attachError, setAttachError] = createSignal(undefined) + const attach = async () => { + const ref = attachRef().trim() + if (!ref || !props.onAttachVault) return + setAttaching(true) + setAttachError(undefined) + try { + const { name } = await props.onAttachVault(ref) + setAttached([...attached(), name]) + setAttachRef("") + } catch (e) { + setAttachError(e instanceof Error ? e.message : "Couldn't attach that vault.") + } finally { + setAttaching(false) + } + } + + // same debounce + out-of-order discipline as the About-You card + let searchTimer: ReturnType | undefined + let searchSeq = 0 + onCleanup(() => { + if (searchTimer) clearTimeout(searchTimer) + }) + const search = (q: string) => { + if (searchTimer) clearTimeout(searchTimer) + if (q.trim().length < 2) { + searchSeq++ + setSuggestions([]) + return + } + searchTimer = setTimeout(() => { + const seq = ++searchSeq + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) + }, 200) + } + const pick = async (sug: InstitutionSuggestion) => { + setFields({ ...fields(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) + setSuggestions([]) + setResolvingLogo((n) => n + 1) + try { + const logo = await resolveBrandLogo(sug.name, sug.domain) + if (logo && fields().affiliation === sug.name) setFields({ ...fields(), affiliation_logo: logo }) + } finally { + setResolvingLogo((n) => Math.max(0, n - 1)) + } + } + + const saveAndPreview = async () => { + setSaving(true) + setSaveError(undefined) + try { + await props.onComplete(fields()) + setStep(props.onUploadPaper ? 2 : FINAL) + } catch { + setSaveError("Couldn't save — server unreachable. Try again.") + } finally { + setSaving(false) + } + } + + const Dots = () => ( +
+ + {(i) => ( + + )} + +
+ ) + + const PrimaryBtn = (p: { label: string; onClick: () => void; disabled?: boolean }) => ( + + ) + const QuietBtn = (p: { label: string; onClick: () => void }) => ( + + ) + + return ( +
+
+ {/* step 0 — welcome */} + +
+ +
+
+ Welcome to Amicode +
+
+ Amico is your research and coding companion — it runs experiments from a conversation, remembers your + results, and adapts to your workflow. Thirty seconds of setup makes it yours. +
+
+
+ setStep(1)} /> + props.onDismiss()} /> +
+
+
+ + {/* step 1 — about you */} + +
+
+
+ About you +
+
+ This fills your home page and helps Amico tailor its physics to you. +
+
+
+
Name
+ setFields({ ...fields(), name: e.currentTarget.value })} + placeholder="Ada Lovelace" + /> +
+
+
Institution
+
+ + (e.currentTarget.style.display = "none")} + /> + + { + setFields({ ...fields(), affiliation: e.currentTarget.value }) + search(e.currentTarget.value) + }} + placeholder="Start typing — we'll find the logo" + /> +
+ 0}> +
+ + {(sug) => ( + + )} + +
+
+
+
+
Research area
+ setFields({ ...fields(), focus: e.currentTarget.value })} + placeholder="e.g. optimal control, ML research, materials science" + /> +
+
+
Google Scholar (optional)
+ setFields({ ...fields(), scholar: e.currentTarget.value })} + placeholder="https://scholar.google.com/…" + /> +
+ +
{saveError()}
+
+
+ 0 ? "Finding logo…" : "Continue"} + disabled={saving() || resolvingLogo() > 0} + onClick={() => void saveAndPreview()} + /> + setStep(0)} /> + + props.onDismiss()} /> + +
+
+
+ + {/* step 2 — library: papers that make Amico smarter (optional) */} + +
+
+
+ Teach Amico your work +
+
+ Upload papers (PDFs) — Amico reads them to learn your methods and results. You can add more anytime from + the Library card on the home page. +
+
+ void uploadPapers(e.currentTarget.files)} + /> + 0}> +
+ + {(name) => ( +
+ + + {name} + +
+ )} +
+
+
+ +
{uploadError()}
+
+
+ 0 ? "Add another PDF" : "Upload a PDF"} + disabled={uploadBusy()} + onClick={() => paperInput?.click()} + /> + 0 ? "Continue" : "Continue without papers"} + disabled={uploadBusy()} + onClick={() => setStep(FINAL)} + /> + + setStep(1)} /> + +
+
+
+ + {/* final step — done: the profile as the home page will show it */} + +
+
+ }> + + (e.currentTarget.style.display = "none")} + /> + + +
+
+ {fields().name || "You"} +
+
+ {fields().affiliation || "Independent"} +
+
+
+
+ You're set — a personal vault is ready. Amico will remember your context across sessions. +
+ {/* attach-your-own-vault: quiet by default so it never crowds the payoff shot */} + +
+ + setAttachOpen(true)} /> + + +
+
Attach a vault
+
+ setAttachRef(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void attach() + }} + placeholder="owner/repo, a git URL, or a local path" + /> + void attach()} /> +
+
+ A GitHub repo you can access, or a folder with an .amico-vault.toml marker. +
+ + {(name) => ( +
+ + + {name} + +
+ )} +
+ +
{attachError()}
+
+
+
+
+
+
+ props.onDismiss()} /> + props.onOpenChat()} /> +
+
+
+ + +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/problem.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/problem.test.ts new file mode 100644 index 00000000..ff669a05 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/problem.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "bun:test" +import { + parseProblemsResponse, + parseProblemResponse, + parseRunStatusResponse, + chipText, + formatTs, + mergeChips, + runChipText, + runVerdict, + deviceVerdict, + calibrationVerdict, + railState, + entityRows, + historyRows, + editPromptText, + compositeChip, + compositeSystemRows, + systemProjection, +} from "./problem" + +describe("wire parsers are tolerant and never throw", () => { + test("problems: happy + failure + garbage", () => { + const ok = parseProblemsResponse({ + ok: true, + active: "a", + problems: [{ slug: "a", name: "A", status: "designing", recorded: "t", entity_kinds: ["system"] }], + error: null, + }) + expect(ok.ok).toBe(true) + expect(ok.problems[0]).toMatchObject({ slug: "a", name: "A", entityKinds: ["system"] }) + expect( + parseProblemsResponse({ ok: false, active: null, problems: [], error: "no_problems_dir: x" }).error, + ).toContain("no_problems_dir") + expect(parseProblemsResponse(null).ok).toBe(false) + expect(parseProblemsResponse("garbage").ok).toBe(false) + }) + test("problem: entities/events/runs/score_stages survive partial garbage", () => { + const view = parseProblemResponse({ + ok: true, + problem: { name: "X", slug: "x", status: "designing" }, + entities: { system: { platform: "transmon", levels: 4 }, junk: null }, + score_stages: ["system", "formulation", 3], + events: [{ seq: 1, entity: "system", action: "created" }, "garbage"], + runs: [{ run_id: "r1", lab: "default", tier: "free" }], + error: null, + }) + expect(view.ok).toBe(true) + expect(Object.keys(view.entities)).toEqual(["system", "junk"]) + expect(view.scoreStages).toEqual(["system", "formulation"]) + expect(view.events).toHaveLength(1) + expect(view.runs[0]).toMatchObject({ runId: "r1", tier: "free" }) + }) + test("run-status: happy + garbage", () => { + const runs = parseRunStatusResponse({ + ok: true, + runs: [{ run_id: "r1", status: "solving", fidelity: 0.03, iteration: 5 }, "junk"], + error: null, + }) + expect(runs).toEqual([{ runId: "r1", status: "solving", fidelity: 0.03, iteration: 5 }]) + expect(parseRunStatusResponse(null)).toEqual([]) + expect(parseRunStatusResponse({ ok: false, runs: [], error: "x" })).toEqual([]) + }) +}) + +describe("chipText per-kind compact renderers", () => { + test("system: structured fields", () => { + expect(chipText("system", { platform: "transmon", levels: 4, params: { drive_max: 0.2 } })).toBe( + "transmon · 4 lvl · cap 0.2", + ) + expect(chipText("system", { platform: "rydberg" })).toBe("rydberg") + }) + test("formatTs compacts ISO, passes non-ISO through", () => { + expect(formatTs("2026-07-10T15:53:30.450Z")).toBe("07-10 15:53") + expect(formatTs("just now")).toBe("just now") + }) + test("formulation: structured mode facets (NOT the phantom problem_type)", () => { + expect( + chipText("formulation", { + trajectory_type: "gate", + time_mode: "min_time", + robustness: { kind: "ensemble" }, + free_phase: true, + }), + ).toBe("gate · min-time · ensemble · free-phase") + // legacy free-form maps through the projection: + expect(chipText("formulation", { problem: "state_prep", target: "|1>" })).toBe("ket") + // an entity carrying ONLY the phantom problem_type is ignored → default gate: + expect(chipText("formulation", { problem_type: "gate_synthesis" })).toBe("gate") + }) + test("unknown kind: generic key=value join, nested flattened bare, notes skipped", () => { + expect(chipText("pulse", { format: "jld2", knots: 51, notes: "long text" })).toBe("format=jld2 · knots=51") + }) +}) + +describe("mergeChips: present entities × score_stages pending", () => { + test("orders known kinds, appends pending for missing score stages", () => { + const chips = mergeChips({ system: { platform: "transmon" } }, ["system", "formulation", "run"]) + expect(chips.map((c) => `${c.kind}:${c.pending}`)).toEqual(["system:false", "formulation:true", "run:true"]) + }) + test("no score → only present entities, no pending, canonical order", () => { + const chips = mergeChips({ formulation: { target: "X" }, system: {} }, []) + expect(chips.map((c) => c.kind)).toEqual(["system", "formulation"]) + expect(chips.every((c) => !c.pending)).toBe(true) + }) +}) + +describe("runChipText", () => { + test("solving with live f renders F = 1 - f; finished renders fidelity", () => { + expect(runChipText([{ runId: "r", status: "solving", fidelity: 0.032, iteration: 12 }])).toBe("solving… F=0.968") + expect(runChipText([{ runId: "r", status: "solving", fidelity: null, iteration: null }])).toBe("solving…") + expect(runChipText([{ runId: "r", status: "finished", fidelity: 0.9998, iteration: 60 }])).toBe( + "F=0.9998 · 60 iter", + ) + expect(runChipText([{ runId: "r", status: "failed", fidelity: null, iteration: null }])).toBe("failed") + expect(runChipText([])).toBeUndefined() + }) + test("solving f outside [0,1] renders no F readout (objective ≠ fidelity)", () => { + expect(runChipText([{ runId: "r", status: "solving", fidelity: 79.3, iteration: 0 }])).toBe("solving…") + }) + test("near-unity fidelity never rounds to a bare 1 — precision extends until the gap shows", () => { + expect(runChipText([{ runId: "r", status: "finished", fidelity: 0.99997, iteration: 60 }])).toBe( + "F=0.99997 · 60 iter", + ) + expect(runChipText([{ runId: "r", status: "finished", fidelity: 0.99999997, iteration: 60 }])).toBe( + "F=0.99999997 · 60 iter", + ) + // solving mirror: a tiny live objective is a near-unity F + expect(runChipText([{ runId: "r", status: "solving", fidelity: 0.00003, iteration: 12 }])).toBe( + "solving… F=0.99997", + ) + // a true 1 (and only a true 1) still renders bare + expect(runChipText([{ runId: "r", status: "finished", fidelity: 1, iteration: 60 }])).toBe("F=1 · 60 iter") + }) +}) + +describe("railState", () => { + test("loading / ready / stale-last-good / unavailable", () => { + expect(railState(undefined, undefined).kind).toBe("loading") + const good = parseProblemResponse({ + ok: true, + problem: { name: "X", slug: "x" }, + entities: {}, + score_stages: [], + events: [], + runs: [], + error: null, + }) + expect(railState(good, undefined).kind).toBe("ready") + const bad = parseProblemResponse({ + ok: false, + problem: null, + entities: {}, + score_stages: [], + events: [], + runs: [], + error: "bad_json: x", + }) + expect(railState(bad, good)).toMatchObject({ kind: "ready", stale: true }) // last-good wins + expect(railState(bad, undefined).kind).toBe("unavailable") + }) +}) + +describe("entity view helpers", () => { + test("entityRows flattens one nesting level to dotted keys", () => { + expect(entityRows({ platform: "transmon", params: { omega: 4.8 }, notes: "n" })).toEqual([ + { key: "platform", value: "transmon" }, + { key: "params.omega", value: "4.8" }, + { key: "notes", value: "n" }, + ]) + }) + test("historyRows filters kind newest-first", () => { + const events = [ + { seq: 1, entity: "system", action: "created", ts: "t1" }, + { seq: 2, entity: "formulation", action: "created", ts: "t2" }, + { seq: 3, entity: "system", action: "updated", ts: "t3" }, + ] + expect(historyRows(events as any, "system").map((e) => e.seq)).toEqual([3, 1]) + }) + test("editPromptText names the entity and field, trailing space for the new value", () => { + expect(editPromptText("system", "levels", 4)).toBe("Change the system levels (currently 4) to ") + expect(editPromptText("system", "params.omega", 4.8)).toBe("Change the system omega (currently 4.8) to ") + }) +}) + +describe("composite system display derivations (spec-20260709)", () => { + const flat = { platform: "transmon", levels: 3, params: { omega: 4.8, drive_max: 0.2 } } + const n1 = { + platform: "transmon", + components: [{ id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, drive_max: 0.2 } }], + couplings: [], + drive: { arch: "per-component" }, + } + const cz = { + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8 } }, + { id: "q2", role: "qubit", levels: 3, params: { omega: 4.9 } }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + } + + test("chipText: N=1 composite matches the legacy flat look; N>1 summarizes components/coupling/arch", () => { + expect(chipText("system", flat as any)).toBe("transmon · 3 lvl · cap 0.2") + expect(chipText("system", n1 as any)).toBe("transmon · 3 lvl · cap 0.2") // N=1 back-compat + expect(chipText("system", cz as any)).toBe("transmon · 2×qubit · cross-resonance · per-component") + }) + + test("legacy flat entity still renders (defensive dual-shape)", () => { + expect(chipText("system", flat as any)).toBeDefined() + expect(entityRows(flat as any).some((r) => r.key === "levels")).toBe(true) // generic flat path + }) + + test("entityRows: composite → component-table + coupling-list rows, not a JSON blob", () => { + const rows = compositeSystemRows(cz as any) + expect(rows.find((r) => r.key === "platform")?.value).toBe("transmon") + expect(rows.find((r) => r.key === "drive")?.value).toBe("per-component") + expect(rows.find((r) => r.key === "topology")?.value).toBe("single-pair") + expect(rows.find((r) => r.key === "component q1")?.value).toContain("qubit") + expect(rows.find((r) => r.key === "component q1")?.value).toContain("omega=4.8") + const cpRow = rows.find((r) => r.key.startsWith("coupling")) + expect(cpRow?.key).toBe("coupling q1↔q2") + expect(cpRow?.value).toContain("cross-resonance") + // and entityRows routes composites here (no raw array blob) + expect(entityRows(cz as any).some((r) => r.value.startsWith("["))).toBe(false) + }) + + test("systemProjection: composite read-through; flat collapses to N=1 (isComposite flag)", () => { + const p = systemProjection(cz as any) + expect(p.isComposite).toBe(true) + expect(p.components.map((c) => c.id)).toEqual(["q1", "q2"]) + expect(p.couplings[0].between).toEqual(["q1", "q2"]) + expect(p.driveArch).toBe("per-component") + const pf = systemProjection(flat as any) + expect(pf.isComposite).toBe(false) + expect(pf.components).toHaveLength(1) + expect(pf.components[0]).toMatchObject({ id: "q1", role: "qubit", levels: 3 }) + expect(pf.couplings).toEqual([]) + // rydberg flat → atom role + global arch (mirrors normalizeSystem) + const pr = systemProjection({ platform: "rydberg", levels: 3, params: {} } as any) + expect(pr.components[0].role).toBe("atom") + expect(pr.driveArch).toBe("global") + }) + + test("compositeChip is defensive on garbage (never throws)", () => { + expect(() => compositeChip({} as any)).not.toThrow() + expect(() => compositeSystemRows({ components: "nope" } as any)).not.toThrow() + expect(() => systemProjection(null as any)).not.toThrow() + }) +}) + +describe("runVerdict (ring-2 Run hero)", () => { + test("finished run → fidelity + status + iters + tier", () => { + const v = runVerdict( + [{ runId: "r1", status: "finished", fidelity: 0.9982, iteration: 312 }], + [{ runId: "r1", tier: "free" }], + ) + expect(v).toEqual({ fidelity: "0.9982", status: "finished", iteration: 312, tier: "free" }) + }) + test("solving run renders F = 1 - f and defaults tier to vetted", () => { + const v = runVerdict([{ runId: "r1", status: "solving", fidelity: 0.032, iteration: 12 }], [{ runId: "r1" }]) + expect(v).toMatchObject({ fidelity: "0.968", status: "solving", tier: "vetted" }) + }) + test("no runs → null (no hero)", () => { + expect(runVerdict([], [])).toBeNull() + }) + test("run ref but no live status → 'recorded' with tier, no fidelity", () => { + expect(runVerdict([], [{ runId: "r1", tier: "free" }])).toEqual({ + fidelity: null, + status: "recorded", + iteration: null, + tier: "free", + }) + }) + test("matches the LATEST run ref by id; failed carries no F", () => { + const statuses = [ + { runId: "r1", status: "finished", fidelity: 0.9, iteration: 100 }, + { runId: "r2", status: "failed", fidelity: null, iteration: null }, + ] as const + const v = runVerdict([...statuses], [{ runId: "r1" }, { runId: "r2" }]) + expect(v).toMatchObject({ status: "failed", fidelity: null, tier: "vetted" }) + }) +}) + +describe("deviceVerdict (ring-2 Device hero)", () => { + test("empty entity → null", () => { + expect(deviceVerdict({})).toBeNull() + }) + test("real device_session surfaces pulse/run/note linkage, no connection", () => { + const v = deviceVerdict({ pulse_ref: "/a/b/pulse.jld2", run_dir: "/runs/r1", note: "hi" }) + expect(v).toMatchObject({ connection: null, pulseRef: "/a/b/pulse.jld2", runDir: "/runs/r1", note: "hi" }) + }) + test("proposed v2 fields → connection + provider + ready", () => { + const v = deviceVerdict({ status: "online", provider: "QuEra Aquila", ready: true }) + expect(v).toMatchObject({ connection: "online", provider: "QuEra Aquila", ready: true }) + }) + test("boolean online:false maps to offline", () => { + expect(deviceVerdict({ online: false })).toMatchObject({ connection: "offline" }) + }) +}) + +describe("calibrationVerdict (ring-2 Calibration hero)", () => { + const NOW = Date.parse("2026-07-23T12:00:00Z") + test("empty entity → null", () => { + expect(calibrationVerdict({}, NOW)).toBeNull() + }) + test("no timestamp and no source → null (falls back to raw fields)", () => { + expect(calibrationVerdict({ foo: 1 }, NOW)).toBeNull() + }) + test("recent calibration → fresh; old → stale, with relative age", () => { + const fresh = calibrationVerdict({ calibrated: "2026-07-23T09:00:00Z", source: "QuEra" }, NOW) + expect(fresh).toMatchObject({ freshness: "fresh", source: "QuEra" }) + expect(fresh?.ageLabel).toBe("3h ago") + const stale = calibrationVerdict({ calibrated: "2026-07-20T12:00:00Z" }, NOW) + expect(stale).toMatchObject({ freshness: "stale" }) + expect(stale?.ageLabel).toBe("3d ago") + }) + test("source only (no timestamp) → freshness null but still a verdict", () => { + expect(calibrationVerdict({ source: "lab-A" }, NOW)).toMatchObject({ freshness: null, source: "lab-A" }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/problem.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/problem.ts new file mode 100644 index 00000000..fd29b27b --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/problem.ts @@ -0,0 +1,766 @@ +// AMICODE: single consumer of the problem-endpoint wire shapes (spec B — +// GET /amicode/problems, /amicode/problem, /amicode/run-status served by +// packages/opencode/src/server/amicode/problems.ts). Pure, tolerant parsing + +// display derivations (chips, pending merge, run chip, rail failure states, +// entity-view rows). Same defensive style as ./vaults.ts: typeof checks on +// every field, drop non-conforming array entries, never throw. JSX-free so it +// is directly testable under `bun test`. +import { entityLabel } from "./receipt" +import { compactValue, formatSci } from "./facets" + +// --- view types -------------------------------------------------------------- + +export type ProblemSummary = { + slug: string + name: string + status: string + recorded?: string + entityKinds: string[] +} +export type ProblemsView = { ok: boolean; active?: string; problems: ProblemSummary[]; error?: string } + +export type EventView = { + seq: number + ts?: string + entity: string + action: string + diff?: Record + source?: { tool?: string; stage?: string } +} +export type RunRefView = { runId: string; lab?: string; tier?: string; recorded?: string } +export type ProblemView = { + ok: boolean + name?: string + slug?: string + status?: string + entities: Record> + scoreStages: string[] + events: EventView[] + runs: RunRefView[] + error?: string +} +export type RunStatusView = { + runId: string + status: "solving" | "finished" | "failed" | "stalled" | "stopped" | "aborted" + fidelity: number | null + iteration: number | null +} +export type Chip = { kind: string; label: string; text?: string; pending: boolean } +export type RailState = + | { kind: "loading" } + | { kind: "ready"; view: ProblemView; stale: boolean } + | { kind: "unavailable"; error?: string } + +// --- wire parsers ------------------------------------------------------------ + +function str(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +export function parseProblemsResponse(raw: unknown): ProblemsView { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, problems: [], error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) { + const error = str(data.error) ?? "problem endpoint reported a failure" + return { ok: false, problems: [], error } + } + const problems = (Array.isArray(data.problems) ? data.problems : []) + .filter((entry): entry is Record => typeof entry === "object" && entry !== null) + .map( + (entry): ProblemSummary => ({ + slug: str(entry.slug) ?? "(unknown)", + name: str(entry.name) ?? str(entry.slug) ?? "(unknown)", + status: str(entry.status) ?? "designing", + recorded: str(entry.recorded), + entityKinds: Array.isArray(entry.entity_kinds) + ? entry.entity_kinds.filter((k): k is string => typeof k === "string") + : [], + }), + ) + return { ok: true, active: str(data.active), problems } +} + +export function parseProblemResponse(raw: unknown): ProblemView { + const empty = { entities: {}, scoreStages: [], events: [], runs: [] } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, ...empty, error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) return { ok: false, ...empty, error: str(data.error) ?? "problem endpoint reported a failure" } + const problem = (typeof data.problem === "object" && data.problem !== null ? data.problem : {}) as Record< + string, + unknown + > + const entities: Record> = {} + if (typeof data.entities === "object" && data.entities !== null) + for (const [kind, value] of Object.entries(data.entities as Record)) + entities[kind] = typeof value === "object" && value !== null ? (value as Record) : {} + const events = (Array.isArray(data.events) ? data.events : []) + .filter((entry): entry is Record => typeof entry === "object" && entry !== null) + .filter((entry) => typeof entry.seq === "number" && typeof entry.entity === "string") + .map( + (entry): EventView => ({ + seq: entry.seq as number, + ts: str(entry.ts), + entity: entry.entity as string, + action: str(entry.action) ?? "", + diff: + typeof entry.diff === "object" && entry.diff !== null + ? (entry.diff as Record) + : undefined, + source: + typeof entry.source === "object" && entry.source !== null + ? (entry.source as { tool?: string; stage?: string }) + : undefined, + }), + ) + const runs = (Array.isArray(data.runs) ? data.runs : []) + .filter((entry): entry is Record => typeof entry === "object" && entry !== null) + .filter((entry) => typeof entry.run_id === "string") + .map( + (entry): RunRefView => ({ + runId: entry.run_id as string, + lab: str(entry.lab), + tier: str(entry.tier), + recorded: str(entry.recorded), + }), + ) + return { + ok: true, + name: str(problem.name), + slug: str(problem.slug), + status: str(problem.status), + entities, + scoreStages: (Array.isArray(data.score_stages) ? data.score_stages : []).filter( + (k): k is string => typeof k === "string", + ), + events, + runs, + } +} + +export function parseRunStatusResponse(raw: unknown): RunStatusView[] { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [] + const data = raw as Record + if (data.ok !== true || !Array.isArray(data.runs)) return [] + return data.runs + .filter((entry): entry is Record => typeof entry === "object" && entry !== null) + .filter( + (entry) => + typeof entry.run_id === "string" && + (entry.status === "solving" || + entry.status === "finished" || + entry.status === "failed" || + entry.status === "stalled" || + entry.status === "stopped" || + entry.status === "aborted"), + ) + .map( + (entry): RunStatusView => ({ + runId: entry.run_id as string, + status: entry.status as RunStatusView["status"], + fidelity: typeof entry.fidelity === "number" ? entry.fidelity : null, + iteration: typeof entry.iteration === "number" ? entry.iteration : null, + }), + ) +} + +// --- chips ------------------------------------------------------------------- + +const CHIP_ORDER = ["system", "formulation", "run", "device_session", "calibration"] + +function shortValue(value: unknown): string { + return compactValue(value) // spec §4: array/object-aware, never a JSON blob +} + +/** Compact per-kind chip text; undefined when the entity has nothing to show. */ +export function chipText(kind: string, entity: Record): string | undefined { + if (kind === "system") { + // composite (spec-20260709): a components[] array → summarize the composite. + // Defensive in-place parse (this fork repo CANNOT import the plugin-side + // normalizeSystem — spec §3 cross-repo note); a legacy flat entity falls through. + if (Array.isArray(entity.components)) return compositeChip(entity) + const params = (typeof entity.params === "object" && entity.params !== null ? entity.params : {}) as Record< + string, + unknown + > + const parts = [ + str(entity.platform), + typeof entity.levels === "number" ? `${entity.levels} lvl` : undefined, + typeof params.drive_max === "number" ? `cap ${formatSci(params.drive_max)}` : undefined, + ].filter((p): p is string => Boolean(p)) + return parts.length > 0 ? parts.join(" · ") : undefined + } + if (kind === "formulation") { + // Structured facets via formulationProjection (dual-shape) — NOT the phantom + // `problem_type` the plugin never writes (spec §6.3 drift fix). + const p = formulationProjection(entity) + const parts = [ + p.trajectory_type, + p.time_mode === "min_time" ? "min-time" : undefined, + p.robustness.kind !== "none" ? p.robustness.kind : undefined, + p.free_phase ? "free-phase" : undefined, + ].filter((x): x is string => Boolean(x)) + return parts.length > 0 ? parts.join(" · ") : undefined + } + if (kind === "run") return undefined // run chip text comes from runChipText + // generic: flatten one level of nesting to bare keys, skip null/notes/recorded + const SKIP = new Set(["notes", "recorded"]) + const entries: string[] = [] + for (const [key, value] of Object.entries(entity)) { + if (SKIP.has(key) || value === null || value === undefined) continue + if (typeof value === "object" && !Array.isArray(value)) { + for (const [inner, innerValue] of Object.entries(value as Record)) { + if (innerValue === null || innerValue === undefined) continue + entries.push(`${inner}=${shortValue(innerValue)}`) + } + } else entries.push(`${key}=${shortValue(value)}`) + } + if (entries.length === 0) return undefined + return entries.length > 4 ? entries.slice(0, 4).join(" · ") + " · …" : entries.join(" · ") +} + +/** Present chips in canonical-then-first-seen order, then dimmed pending chips + * for score stages not yet recorded (the path ahead, filled left to right). */ +export function mergeChips(entities: Record>, scoreStages: string[]): Chip[] { + const present = Object.keys(entities) + const ordered = [ + ...CHIP_ORDER.filter((kind) => present.includes(kind)), + ...present.filter((kind) => !CHIP_ORDER.includes(kind)), + ] + const chips: Chip[] = ordered.map((kind) => ({ + kind, + label: entityLabel(kind), + text: chipText(kind, entities[kind]), + pending: false, + })) + for (const kind of scoreStages) { + if (present.includes(kind)) continue + chips.push({ kind, label: entityLabel(kind), pending: true }) + } + return chips +} + +// --- run chip ---------------------------------------------------------------- + +function formatFidelity(value: number): string { + // 4 decimals for ordinary values (trailing zeros trimmed) — but NEVER let + // rounding collapse a near-unity or near-zero fidelity into exactly "1"/"0": + // the gap from perfect IS the result (Kate 2026-07-28, a 0.99997 run showed + // "F = 1"). Precision extends one digit at a time until the gap survives, + // capped at 10; only a true 1 or 0 renders bare. + if (value === 1 || value === 0) return String(value) + for (let digits = 4; digits <= 10; digits++) { + const rounded = Number(value.toFixed(digits)) + if (rounded !== 1 && rounded !== 0) return String(rounded) + } + return String(value) +} + +export function runChipText(statuses: RunStatusView[]): string | undefined { + if (statuses.length === 0) return undefined + const solving = [...statuses].reverse().find((s) => s.status === "solving") + if (solving) { + const f = solving.fidelity + // wire carries the raw objective f (≈ infidelity) for solving runs; only + // render F = 1 - f when f plausibly IS an infidelity (result.toml's + // finished fidelity needs no transform) + if (f !== null && f >= 0 && f <= 1) return `solving… F=${formatFidelity(1 - f)}` + return "solving…" + } + const finished = [...statuses].reverse().find((s) => s.status === "finished") + if (finished && finished.fidelity !== null) { + const iter = finished.iteration !== null ? ` · ${finished.iteration} iter` : "" + return `F=${formatFidelity(finished.fidelity)}${iter}` + } + if (finished) return "finished" + const stalled = [...statuses].reverse().find((s) => s.status === "stalled") + if (stalled) return stalled.iteration !== null ? `stalled · iter ${stalled.iteration}` : "stalled" + // A deliberate user stop/abort is a successful action — it must never read + // as "failed" or vanish from the rail (one-spine: run-terminal.ts). + const stopped = [...statuses].reverse().find((s) => s.status === "stopped" || s.status === "aborted") + if (stopped) return stopped.status + return "failed" +} + +// --- run verdict (ring-2 Run dialog hero) ------------------------------------ + +export type RunVerdict = { + /** formatted fidelity F, or null when the status carries no usable number */ + fidelity: string | null + status: RunStatusView["status"] | "recorded" + iteration: number | null + tier: string | null +} + +/** Verdict for the latest run of the active problem — fidelity + status + + * iteration + tier — the fact the Run dialog used to omit while the inline + * chip showed it. Prefers the live run-status entry matching the latest run + * ref, falls back to the last status, then to a bare "recorded" when only a + * run ref exists. Null when there are no runs at all (→ no hero). Fidelity + * conventions mirror runChipText: finished = raw F, solving = 1 - f. */ +export function runVerdict(statuses: RunStatusView[], runs: RunRefView[]): RunVerdict | null { + const latestRef = runs.length > 0 ? runs[runs.length - 1] : undefined + const tier = latestRef ? (latestRef.tier ?? "vetted") : null + let status: RunStatusView | undefined + if (latestRef) status = statuses.find((s) => s.runId === latestRef.runId) + if (!status && statuses.length > 0) status = statuses[statuses.length - 1] + if (!status) return latestRef ? { fidelity: null, status: "recorded", iteration: null, tier } : null + let fidelity: string | null = null + if (status.status === "solving") { + if (status.fidelity !== null && status.fidelity >= 0 && status.fidelity <= 1) + fidelity = formatFidelity(1 - status.fidelity) + } else if (status.fidelity !== null) { + fidelity = formatFidelity(status.fidelity) + } + return { fidelity, status: status.status, iteration: status.iteration, tier } +} + +// --- device verdict (ring-2 Device dialog hero) ------------------------------ +// device_session has no dedicated schema yet (real entities carry only +// pulse_ref/run_dir/note). This reads the PROPOSED v2 fields (connection / +// provider / ready / queue) when present and always surfaces the real pulse/run +// linkage — design-leads-schema-follows. Returns null only for an empty entity. + +export type DeviceVerdict = { + connection: "online" | "offline" | "maintenance" | null + provider: string | null + ready: boolean | null + queue: string | null + pulseRef: string | null + runDir: string | null + note: string | null +} + +export function deviceVerdict(entity: Record): DeviceVerdict | null { + if (!entity || Object.keys(entity).length === 0) return null + let connection: DeviceVerdict["connection"] = null + const statusStr = str(entity.status) + if (statusStr === "online" || statusStr === "offline" || statusStr === "maintenance") connection = statusStr + else if (typeof entity.online === "boolean") connection = entity.online ? "online" : "offline" + const provider = str(entity.provider) ?? null + const ready = typeof entity.ready === "boolean" ? entity.ready : null + const queue = + str(entity.queue) ?? (typeof entity.queue === "number" ? `${entity.queue} in queue` : null) + const pulseRef = str(entity.pulse_ref) ?? null + const runDir = str(entity.run_dir) ?? null + const note = str(entity.note) ?? str(entity.notes) ?? null + if (connection === null && !provider && ready === null && !queue && !pulseRef && !runDir && !note) return null + return { connection, provider, ready, queue, pulseRef, runDir, note } +} + +// --- calibration verdict (ring-2 Calibration dialog hero) -------------------- +// No calibration entity or schema exists in Phase 0 (amico-catalog: `calibrated` +// lands later). This reads the PROPOSED fields (a timestamp + source) and +// derives a freshness verdict; nowMs is injected for testability. Returns null +// when there is no timestamp and no source → the dialog falls back to the raw +// field table. + +export type CalibrationVerdict = { + freshness: "fresh" | "stale" | null + ageLabel: string | null + source: string | null + calibratedAt: string | null +} + +/** Proposed heuristic until real calibration validity windows exist: ≤12h fresh. */ +const CALIB_FRESH_MS = 12 * 60 * 60 * 1000 + +function relAge(ms: number): string { + const sec = Math.max(0, Math.round(ms / 1000)) + if (sec < 60) return `${sec}s ago` + const min = Math.round(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 48) return `${hr}h ago` + return `${Math.round(hr / 24)}d ago` +} + +export function calibrationVerdict(entity: Record, nowMs: number): CalibrationVerdict | null { + if (!entity || Object.keys(entity).length === 0) return null + const calibratedAt = + str(entity.calibrated) ?? str(entity.calibrated_at) ?? str(entity.recorded) ?? str(entity.timestamp) ?? null + const source = str(entity.source) ?? str(entity.device) ?? str(entity.provider) ?? str(entity.lab) ?? null + let freshness: CalibrationVerdict["freshness"] = null + let ageLabel: string | null = null + if (calibratedAt) { + const ms = Date.parse(calibratedAt) + if (Number.isFinite(ms)) { + const age = nowMs - ms + freshness = age <= CALIB_FRESH_MS ? "fresh" : "stale" + ageLabel = relAge(age) + } + } + if (freshness === null && !source && !calibratedAt) return null + return { freshness, ageLabel, source, calibratedAt } +} + +// --- rail state -------------------------------------------------------------- + +export function railState(current: ProblemView | undefined, lastGood: ProblemView | undefined): RailState { + if (current === undefined) return { kind: "loading" } + if (current.ok) return { kind: "ready", view: current, stale: false } + if (lastGood?.ok) return { kind: "ready", view: lastGood, stale: true } + return { kind: "unavailable", error: current.error } +} + +// --- entity view helpers ------------------------------------------------------- + +export type EntityRow = { key: string; value: string } + +/** Field/label humanizer for the entity view: last dotted segment, underscores + * to spaces, sentence case — `params.drive_max` → "Drive max", `problem_type` + * → "Problem type". Presentation only; the raw key is still shown alongside. */ +export function humanizeKey(key: string): string { + const raw = (key.split(".").pop() ?? key).replaceAll("_", " ").trim() + if (!raw) return key + return raw.charAt(0).toUpperCase() + raw.slice(1) +} + +/** The dotted prefix of a flattened row key (the group it belongs to), or + * undefined for a top-level scalar. `params.drive_max` → "params". */ +export function fieldGroup(key: string): string | undefined { + const dot = key.indexOf(".") + return dot === -1 ? undefined : key.slice(0, dot) +} + +/** Stable field rows: scalars in object order, one level of object nesting + * flattened to dotted keys, undefined skipped. */ +export function entityRows(entity: Record): EntityRow[] { + // composite system (spec-20260709): a components[] array → component-table + + // coupling-list rows instead of one JSON blob. Defensive; legacy flat falls through. + if (Array.isArray(entity.components)) return compositeSystemRows(entity) + const rows: EntityRow[] = [] + for (const [key, value] of Object.entries(entity)) { + if (value === undefined) continue + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + for (const [inner, innerValue] of Object.entries(value as Record)) { + if (innerValue === undefined) continue + rows.push({ key: `${key}.${inner}`, value: shortValue(innerValue) }) + } + continue + } + rows.push({ key, value: shortValue(value) }) + } + return rows +} + +export function historyRows(events: EventView[], kind: string): EventView[] { + return events.filter((event) => event.entity === kind).sort((a, b) => b.seq - a.seq) +} + +/** Draft-only edit-in-chat prompt: trailing space so the user types the value. */ +export function editPromptText(kind: string, key: string, value: unknown): string { + const bare = key.split(".").pop() ?? key + return `Change the ${kind} ${bare} (currently ${shortValue(value)}) to ` +} + +// --- composite system display (spec-20260709 §3) ----------------------------- +// All of the below parse BOTH the flat-legacy and composite shapes defensively +// in place — this fork repo cannot import the plugin-side normalizeSystem, so +// tolerance lives in the reader (spec §3 cross-repo note). Never throw. + +const paramBits = (params: unknown): string[] => { + if (typeof params !== "object" || params === null) return [] + return Object.entries(params as Record).map(([k, v]) => `${k}=${shortValue(v)}`) +} + +/** Compact composite chip, e.g. "transmon · 3 lvl · cap 0.2" (N=1, back-compat look) + * or "transmon · 2×qubit · cross-resonance · per-component" (N>1). */ +export function compositeChip(entity: Record): string | undefined { + const components = Array.isArray(entity.components) ? (entity.components as Record[]) : [] + const couplings = Array.isArray(entity.couplings) ? (entity.couplings as Record[]) : [] + const driveArch = + entity.drive && typeof entity.drive === "object" ? str((entity.drive as Record).arch) : undefined + const parts: string[] = [] + const platform = str(entity.platform) + if (platform) parts.push(platform) + if (components.length === 1) { + const c = components[0] + if (typeof c.levels === "number") parts.push(`${c.levels} lvl`) + const params = (typeof c.params === "object" && c.params !== null ? c.params : {}) as Record + if (typeof params.drive_max === "number") parts.push(`cap ${formatSci(params.drive_max)}`) + } else if (components.length > 1) { + const roles = new Set(components.map((c) => str(c.role)).filter(Boolean)) + parts.push(roles.size === 1 ? `${components.length}×${[...roles][0]}` : `${components.length} comps`) + } + if (couplings.length === 1) parts.push(str(couplings[0].kind) ?? "1 coupling") + else if (couplings.length > 1) parts.push(`${couplings.length} couplings`) + if (components.length > 1 && driveArch) parts.push(driveArch) + return parts.length > 0 ? parts.join(" · ") : undefined +} + +/** Component-table + coupling-list rows for a composite system entity. */ +export function compositeSystemRows(entity: Record): EntityRow[] { + const rows: EntityRow[] = [] + const platform = str(entity.platform) + if (platform) rows.push({ key: "platform", value: platform }) + const driveArch = + entity.drive && typeof entity.drive === "object" ? str((entity.drive as Record).arch) : undefined + if (driveArch) rows.push({ key: "drive", value: driveArch }) + if (typeof entity.topology === "string") rows.push({ key: "topology", value: entity.topology }) + for (const c of (Array.isArray(entity.components) ? entity.components : []) as Record[]) { + const bits = [ + str(c.role), + typeof c.levels === "number" ? `${c.levels} lvl` : undefined, + ...paramBits(c.params), + ].filter((b): b is string => Boolean(b)) + rows.push({ key: `component ${str(c.id) ?? "?"}`, value: bits.join(", ") || "—" }) + } + for (const cp of (Array.isArray(entity.couplings) ? entity.couplings : []) as Record[]) { + const between = Array.isArray(cp.between) ? (cp.between as unknown[]).map(String).join("↔") : "" + const params = paramBits(cp.params).join(", ") + rows.push({ key: `coupling ${between}`, value: `${str(cp.kind) ?? "?"}${params ? ` (${params})` : ""}` }) + } + if (typeof entity.notes === "string") rows.push({ key: "notes", value: entity.notes }) + return rows +} + +export interface SystemProjection { + platform?: string + driveArch?: string + topology?: string + components: { id: string; role: string; levels?: number; params: Record }[] + couplings: { between: string[]; kind: string; params: Record }[] + /** The model the researcher CONFIRMED, term by term (amicode_set_model's + * `hamiltonian`). Present → the card renders exactly this; absent → it falls + * back to a canonical form for the platform and labels it as inferred. */ + hamiltonian?: { terms: HamiltonianTermProjection[]; notes?: string } + notes?: string + /** false = a legacy FLAT entity read-collapsed to N=1 (no couplings). */ + isComposite: boolean +} + +export interface HamiltonianTermProjection { + kind: string + latex: string + acts_on?: string[] + label?: string +} + +const asNumberRecord = (v: unknown): Record => { + const out: Record = {} + if (typeof v === "object" && v !== null) { + for (const [k, val] of Object.entries(v as Record)) if (typeof val === "number") out[k] = val + } + return out +} + +/** Recorded Hamiltonian, tolerant of raw JSON: a term without usable `latex` is + * dropped rather than rendered as a hole, and an empty result reads as "nothing + * recorded" so the card falls back to inference instead of showing `Ĥ/ℏ = `. */ +const asHamiltonian = (v: unknown): SystemProjection["hamiltonian"] => { + if (typeof v !== "object" || v === null) return undefined + const raw = v as Record + if (!Array.isArray(raw.terms)) return undefined + const terms = (raw.terms as Record[]) + .filter((t) => t && typeof t.latex === "string" && t.latex.trim() !== "") + .map((t) => ({ + kind: typeof t.kind === "string" ? t.kind : "drift", + latex: t.latex as string, + ...(Array.isArray(t.acts_on) ? { acts_on: (t.acts_on as unknown[]).map(String) } : {}), + ...(typeof t.label === "string" ? { label: t.label } : {}), + })) + return terms.length === 0 ? undefined : { terms, ...(typeof raw.notes === "string" ? { notes: raw.notes } : {}) } +} + +/** Structured projection for the entity view (spec §3 point 3). Composite → read + * through; legacy flat → collapse to N=1 in place (role from platform: rydberg→atom + * else qubit — mirrors normalizeSystem without importing it). Never throws. */ +export function systemProjection(input: Record): SystemProjection { + const entity = (input ?? {}) as Record + const platform = str(entity.platform) + const driveArch = + entity.drive && typeof entity.drive === "object" ? str((entity.drive as Record).arch) : undefined + if (Array.isArray(entity.components)) { + return { + platform, + driveArch, + topology: typeof entity.topology === "string" ? entity.topology : undefined, + components: (entity.components as Record[]).map((c) => ({ + id: str(c.id) ?? "?", + role: str(c.role) ?? "?", + ...(typeof c.levels === "number" ? { levels: c.levels } : {}), + params: asNumberRecord(c.params), + })), + couplings: (Array.isArray(entity.couplings) ? (entity.couplings as Record[]) : []).map((cp) => ({ + between: Array.isArray(cp.between) ? (cp.between as unknown[]).map(String) : [], + kind: str(cp.kind) ?? "?", + params: asNumberRecord(cp.params), + })), + ...(asHamiltonian(entity.hamiltonian) ? { hamiltonian: asHamiltonian(entity.hamiltonian) } : {}), + ...(typeof entity.notes === "string" ? { notes: entity.notes } : {}), + isComposite: true, + } + } + // legacy flat → N=1 + const role = platform?.toLowerCase() === "rydberg" ? "atom" : "qubit" + const comp: SystemProjection["components"][number] = { id: "q1", role, params: asNumberRecord(entity.params) } + if (typeof entity.levels === "number") comp.levels = entity.levels + return { + platform, + driveArch: driveArch ?? (platform?.toLowerCase() === "rydberg" ? "global" : "per-component"), + components: [comp], + couplings: [], + ...(typeof entity.notes === "string" ? { notes: entity.notes } : {}), + isComposite: false, + } +} + +// --- formulation projection (spec §6.2) -------------------------------------- +// Dual-shape (legacy free-form OR structured facets). The fork CANNOT import the +// plugin's normalizeFormulation, so this re-implements the §10 mapping IN PLACE, +// pinned by the shared fixture corpus (formulation-projection.test.ts). Never +// throws; unknown enums pass through raw. + +export type PrimaryObjectiveKey = + | "ket_infidelity" + | "unitary_infidelity" + | "unitary_free_phase" + | "density_infidelity" + | "min_time" +export interface FormulationTerm { + kind: string + params: Record + label?: string +} +export interface FormulationProjection { + trajectory_type: string + time_mode: string + parameterization: string + robustness: { kind: string; params: Record } + free_phase: boolean + leakage: boolean + leakage_params?: Record + time_params?: Record + target: string + objectives: FormulationTerm[] + constraints: FormulationTerm[] + primaryKey: PrimaryObjectiveKey + derivedFinalFidelity?: number + solve?: Record + isStructured: boolean +} + +const fmtTerm = (o: any): FormulationTerm => { + const out: FormulationTerm = { + kind: typeof o?.kind === "string" ? o.kind : "custom", + params: o?.params && typeof o.params === "object" ? o.params : {}, + } + if (typeof o?.label === "string") out.label = o.label + return out +} +const fmtRobustness = (r: any): { kind: string; params: Record } => + r && typeof r === "object" && typeof r.kind === "string" + ? { kind: r.kind, params: r.params && typeof r.params === "object" ? r.params : {} } + : { kind: "none", params: {} } +const inferTrajFromTarget = (target: string): string => (/^\s*\||prep|state/i.test(target) ? "ket" : "gate") +const fmtConstraintKind = (lc: string): string => { + if (/slew|\bdu\b/.test(lc)) return "du_bound" + if (/ddu|accel/.test(lc)) return "ddu_bound" + if (/Δt|timestep|\bdt\b/.test(lc)) return "dt_bounds" + if (/calibration|\bpin\b/.test(lc)) return "calibration_pin" + if (/amplitude|bound/.test(lc)) return "bounds" + return "custom" +} +const derivePrimaryKey = (trajectory_type: string, free_phase: boolean, time_mode: string): PrimaryObjectiveKey => { + if (time_mode === "min_time") return "min_time" + if (trajectory_type === "ket" || trajectory_type === "multiket") return "ket_infidelity" + if (trajectory_type === "density" || trajectory_type === "multidensity") return "density_infidelity" + if (trajectory_type === "gate") return free_phase ? "unitary_free_phase" : "unitary_infidelity" + return "unitary_infidelity" +} + +export function formulationProjection(input: Record): FormulationProjection { + const r = (input ?? {}) as Record + let trajectory_type: string + let time_mode: string + let parameterization: string + let free_phase: boolean + let leakage: boolean + let target: string + let robustness: { kind: string; params: Record } + let objectives: FormulationTerm[] + let constraints: FormulationTerm[] + let time_params: Record | undefined + let leakage_params: Record | undefined + let solve: Record | undefined + let isStructured: boolean + + if (typeof r.trajectory_type === "string") { + isStructured = true + trajectory_type = r.trajectory_type + time_mode = r.time_mode === "min_time" ? "min_time" : "fixed" + parameterization = typeof r.parameterization === "string" ? r.parameterization : "smooth" + robustness = fmtRobustness(r.robustness) + free_phase = r.free_phase === true + leakage = r.leakage === true + target = typeof r.target === "string" ? r.target : "" + objectives = Array.isArray(r.objectives) ? r.objectives.map(fmtTerm) : [] + constraints = Array.isArray(r.constraints) ? r.constraints.map(fmtTerm) : [] + if (r.time_params && typeof r.time_params === "object") time_params = r.time_params + if (r.leakage_params && typeof r.leakage_params === "object") leakage_params = r.leakage_params + if (r.solve && typeof r.solve === "object") solve = r.solve + } else { + isStructured = false + const problem = typeof r.problem === "string" ? r.problem : "" + target = typeof r.target === "string" ? r.target : "" + trajectory_type = "gate" + time_mode = "fixed" + if (problem === "state_prep") trajectory_type = "ket" + else if (problem === "min_time") { + time_mode = "min_time" + trajectory_type = inferTrajFromTarget(target) + } + parameterization = "smooth" + robustness = { kind: "none", params: {} } + free_phase = false + leakage = false + objectives = [] + const objStr = typeof r.objective === "string" ? r.objective.trim() : "" + if (objStr && !/infidelity/i.test(objStr)) objectives.push({ kind: "custom", params: {}, label: objStr }) + constraints = [] + for (const c of Array.isArray(r.constraints) ? r.constraints : []) { + if (typeof c !== "string") continue + const lc = c.toLowerCase() + if (/leakage/.test(lc)) { + leakage = true + continue + } + if (/final.?fidelity/.test(lc)) { + const m = c.match(/[\d.]+/) + time_params = { ...(time_params ?? {}), final_fidelity: m ? Number(m[0]) : 0.99 } + continue + } + constraints.push({ kind: fmtConstraintKind(lc), params: {}, label: c }) + } + if (r.solve && typeof r.solve === "object") solve = r.solve + } + + const out: FormulationProjection = { + trajectory_type, + time_mode, + parameterization, + robustness, + free_phase, + leakage, + target, + objectives, + constraints, + primaryKey: derivePrimaryKey(trajectory_type, free_phase, time_mode), + isStructured, + } + if (time_params) out.time_params = time_params + if (leakage_params) out.leakage_params = leakage_params + if (solve) out.solve = solve + if (time_mode === "min_time" && time_params && typeof time_params.final_fidelity === "number") + out.derivedFinalFidelity = time_params.final_fidelity + return out +} + +/** Compact an ISO timestamp for the entity-view ("2026-07-10T15:53:30.450Z" → + * "07-10 15:53") — the full ISO is wide and clips the dialog. Non-ISO passes through. */ +export function formatTs(ts: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/.exec(ts) + return m ? `${m[2]}-${m[3]} ${m[4]}` : ts +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.test.ts new file mode 100644 index 00000000..581f40ad --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { countAmicodeParts, sessionHasAmicodeParts } from "./rail-gate" + +// Regression coverage for the missing entity chips. +// +// The gate used to be `part.tool.startsWith("amicode_")` alone, so a session that +// did its amicode work through the shell showed NO chips — observed 2026-07-29 on +// a session that created a problem workspace, wrote a solvespec, and drove a solve +// to iteration 29 with frames on disk, entirely via bash + amico-run. + +const msgs = [{ id: "m1" }] +const parts = + (...p: Array>) => + () => + p as never + +const amicodeTool = (status = "completed") => ({ type: "tool", tool: "amicode_record_system", state: { status } }) +const bash = (command: string) => ({ type: "tool", tool: "bash", state: { status: "running", input: { command } } }) + +describe("rail gate", () => { + test("an amicode_* tool part opens the gate (unchanged behaviour)", () => { + expect(sessionHasAmicodeParts(msgs, parts(amicodeTool()))).toBe(true) + }) + + test("a shell part that launches amico-run opens the gate", () => { + expect(sessionHasAmicodeParts(msgs, parts(bash("nohup amico-run --spec s.json solve.jl &")))).toBe(true) + }) + + test("a shell part that only touches the problems dir opens the gate", () => { + // the workspace gets created before any run exists + expect(sessionHasAmicodeParts(msgs, parts(bash("mkdir -p ~/.amico/problems/x-gate-1")))).toBe(true) + }) + + test("an unrelated session still shows nothing — the gate's whole purpose", () => { + expect(sessionHasAmicodeParts(msgs, parts(bash("git status"), bash("npm test")))).toBe(false) + expect(sessionHasAmicodeParts(msgs, parts({ type: "text", text: "hello" }))).toBe(false) + expect(sessionHasAmicodeParts([], () => undefined)).toBe(false) + }) + + test("a part whose input has not arrived yet does not match, and does not throw", () => { + expect(sessionHasAmicodeParts(msgs, parts({ type: "tool", tool: "bash", state: { status: "pending" } }))).toBe( + false, + ) + expect(sessionHasAmicodeParts(msgs, parts({ type: "tool", tool: "bash" }))).toBe(false) + }) + + // The refetch key must NOT be broadened: shell parts don't mutate the problem + // view through the tool seam, so counting them would refetch the problem on + // unrelated shell activity. + test("completed counts only amicode_* tools, never shell", () => { + const c = countAmicodeParts(msgs, parts(amicodeTool("completed"), bash("amico-run solve.jl"))) + expect(c.any).toBe(2) + expect(c.completed).toBe(1) + }) + + test("a non-completed amicode tool counts for the gate but not the refetch key", () => { + const c = countAmicodeParts(msgs, parts(amicodeTool("running"))) + expect(c.any).toBe(1) + expect(c.completed).toBe(0) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.ts new file mode 100644 index 00000000..67768e3f --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/rail-gate.ts @@ -0,0 +1,78 @@ +// Does THIS session's transcript show amicode work? The entity rail renders only +// when it does, so a fresh or non-amicode session shows no empty chrome (the +// active problem pointer is global, the rail is session-scoped). +// +// Extracted from entity-rail.tsx to be testable, and broadened. The original test +// was `part.tool.startsWith("amicode_")` — a session that did its amicode work +// through the SHELL therefore showed no chips at all. Observed 2026-07-29: the +// agent created a problem workspace, wrote a solvespec, and launched a solve that +// reached iteration 29 with frames on disk — entirely via bash and `amico-run` — +// and the rail stayed hidden the whole time, because it never called an +// `amicode_*` tool. The chips had real entities to describe and refused to. +// +// A shell-driven solve is still unambiguously an amicode session, so the gate now +// also recognises a bash part whose command drives amicode. It stays SESSION-scoped +// (the point of the gate), so an unrelated session still shows nothing. + +/** Substrings in a shell command that mark it as amicode work. `amico-run` is the + * launcher; the problems dir is where a workspace gets created before any run. */ +const SHELL_MARKERS = ["amico-run", ".amico/problems", "amico plan", "amico spec"] as const + +export interface GatePartLike { + type?: string + tool?: string + state?: { + status?: string + input?: Record + } +} + +function isAmicodeToolPart(part: GatePartLike): boolean { + return part?.type === "tool" && typeof part.tool === "string" && part.tool.startsWith("amicode_") +} + +/** A shell part that drives amicode. Reads only `input.command`, so a part whose + * input has not arrived yet simply doesn't match (it will on the next render). */ +function isAmicodeShellPart(part: GatePartLike): boolean { + if (part?.type !== "tool") return false + const command = part.state?.input?.command + if (typeof command !== "string") return false + return SHELL_MARKERS.some((marker) => command.includes(marker)) +} + +export interface AmicodePartCounts { + /** Parts that mark this as an amicode session — the render gate. */ + any: number + /** Completed `amicode_*` tool parts — the refetch key. Deliberately NOT + * broadened: shell parts don't mutate the problem view through the tool seam, + * so counting them would refetch on unrelated shell activity. */ + completed: number +} + +export function countAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly T[] | undefined, +): AmicodePartCounts { + let any = 0 + let completed = 0 + for (const message of messages) { + for (const part of partsFor(message.id) ?? []) { + if (isAmicodeToolPart(part)) { + any++ + if (part.state?.status === "completed") completed++ + } else if (isAmicodeShellPart(part)) { + any++ + } + } + } + return { any, completed } +} + +/** The rail's session gate, exported so the host chrome can collapse the header's + * chip padding when the rail will not render. */ +export function sessionHasAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly T[] | undefined, +): boolean { + return countAmicodeParts(messages, partsFor).any > 0 +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.test.ts new file mode 100644 index 00000000..0f7b2287 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { latestSeqForEntity, receiptIsCurrent } from "./receipt-currency" +import type { EventView } from "./problem" + +const ev = (seq: number, entity: string): EventView => ({ seq, entity, action: "updated" }) + +// Three formulation updates and one system record, as a real session accumulates them. +const EVENTS: EventView[] = [ev(2, "system"), ev(3, "formulation"), ev(5, "formulation"), ev(7, "formulation")] +const VIEW = { slug: "ghz-state-rydberg", events: EVENTS } + +describe("latestSeqForEntity", () => { + test("returns the highest seq for that kind only", () => { + expect(latestSeqForEntity(EVENTS, "formulation")).toBe(7) + expect(latestSeqForEntity(EVENTS, "system")).toBe(2) + }) + test("undefined when the kind has no events (the view-lag case)", () => { + expect(latestSeqForEntity(EVENTS, "run")).toBeUndefined() + expect(latestSeqForEntity([], "formulation")).toBeUndefined() + }) + test("does not assume the events are sorted", () => { + expect(latestSeqForEntity([ev(9, "run"), ev(4, "run")], "run")).toBe(9) + }) +}) + +describe("receiptIsCurrent", () => { + test("the newest receipt for a kind is current", () => { + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, VIEW)).toBe(true) + }) + + // THE BUG: superseded receipts render the live view today, so three formulation + // updates paint three identical copies of the present. + test("superseded receipts for the same kind are NOT current", () => { + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 3 }, VIEW)).toBe(false) + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 5 }, VIEW)).toBe(false) + }) + + // THE WORSE BUG: the transcript fetches the globally-active problem with no slug, + // so switching problems mid-chat retroactively rewrote earlier receipts. + test("a receipt from another problem is NOT current, even at a matching seq", () => { + expect(receiptIsCurrent({ problem: "transmon-state-prep", entity: "formulation", seq: 7 }, VIEW)).toBe(false) + }) + + test("no view → not current (cannot establish currency; render captured)", () => { + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, undefined)).toBe(false) + }) + + test("missing seq → not current", () => { + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation" }, VIEW)).toBe(false) + }) + + // Preserves the existing card.tsx warning: "a record+update lands as two events but + // one receipt, and the view can lag a beat, so tighter gates hid the view entirely." + test("a kind with no events yet is current — the view may lag the receipt", () => { + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "run", seq: 11 }, VIEW)).toBe(true) + }) + + test("a view without a slug falls back to the seq check rather than collapsing everything", () => { + const noSlug = { events: EVENTS } + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, noSlug)).toBe(true) + expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 3 }, noSlug)).toBe(false) + }) + + test("a sentinel without a problem falls back to the seq check", () => { + expect(receiptIsCurrent({ entity: "formulation", seq: 7 }, VIEW)).toBe(true) + expect(receiptIsCurrent({ entity: "formulation", seq: 3 }, VIEW)).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.ts new file mode 100644 index 00000000..18917fdc --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-currency.ts @@ -0,0 +1,64 @@ +import type { EventView } from "./problem" + +// AMICODE: is a given diff receipt the CURRENT state of the ACTIVE problem? +// +// WHY THIS EXISTS (spec-20260727-164748 §9.4). Every amicode_* receipt used to +// render the full entity view inline, and that view read the LIVE problem view +// rather than a snapshot at its own seq. Two consequences, both wrong: +// +// 1. N updates to one entity painted N identical copies of the present — the +// transcript looked like a history and carried none. +// 2. The transcript fetches the globally-active problem with NO ?slug=, and +// ~/.amico/problems/active is a single global file. So switching problems +// mid-chat retroactively rewrote every earlier receipt to the new problem. +// +// The rule: exactly the current receipt renders live; everything else renders +// from the captured sentinel diff it already carries. This module is the pure +// predicate for "current", kept out of card.tsx so it is testable without +// rendering anything. +// +// Deliberately permissive in the ambiguous cases. card.tsx's original gate note +// warns that "a record+update lands as two events but one receipt, and the view +// can lag a beat, so tighter gates hid the view entirely" — so a kind with no +// events YET stays current rather than collapsing to a chip that would look +// like data loss. + +/** Highest `seq` among events for `kind`, or undefined when that kind has none. + * Does not assume the events are sorted. */ +export function latestSeqForEntity(events: EventView[], kind: string): number | undefined { + let latest: number | undefined + for (const event of events) { + if (event.entity !== kind) continue + if (latest === undefined || event.seq > latest) latest = event.seq + } + return latest +} + +export interface ReceiptRef { + /** Problem slug the receipt was captured against (sentinel `problem`). */ + problem?: string + entity: string + seq?: number +} + +export interface CurrencyView { + slug?: string + events: EventView[] +} + +/** True when this receipt should render LIVE (it is the current state of the + * active problem); false when it should render from its captured diff. */ +export function receiptIsCurrent(receipt: ReceiptRef, view: CurrencyView | undefined): boolean { + // No view: currency is unknowable. Render captured rather than guess. + if (!view) return false + // A receipt with no seq cannot be placed in the entity's history. + if (typeof receipt.seq !== "number") return false + // Captured against a DIFFERENT problem than the one now active → history. + // Skipped when either side lacks a slug: we cannot detect a switch, so fall + // through to the seq check rather than collapsing every receipt. + if (receipt.problem !== undefined && view.slug !== undefined && receipt.problem !== view.slug) return false + // Superseded by a later event for the same kind → history. + const latest = latestSeqForEntity(view.events, receipt.entity) + if (latest !== undefined && receipt.seq !== latest) return false + return true +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.test.ts new file mode 100644 index 00000000..01fd87ac --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test" +import { collapseReceiptRuns, receiptRunKey, type ReceiptCandidate } from "./receipt-runs" +import { parseDiffSentinel } from "./receipt" + +const sentinel = (over: Partial> = {}) => + parseDiffSentinel( + `AMICODE_DIFF ${JSON.stringify({ + problem: "x-gate", + entity: "recommend", + action: "proposed", + seq: 1, + diff: {}, + ...over, + })}`, + ) + +describe("receiptRunKey", () => { + test("a parsed sentinel for a non-inline entity yields its (problem, entity, action)", () => { + expect(receiptRunKey(sentinel())).toEqual({ problem: "x-gate", entity: "recommend", action: "proposed" }) + }) + + test("undefined sentinel (unparseable output) → undefined", () => { + expect(receiptRunKey(undefined)).toBeUndefined() + }) + + // INLINE_KINDS entities may resolve to the live InlineEntityView depending on + // receipt-currency's reactive currency check — this pure module can't see that, + // so it refuses to key them at all rather than risk collapsing across it. + test("an INLINE_KINDS entity (system/formulation/run/device_session/calibration) → undefined", () => { + expect(receiptRunKey(sentinel({ entity: "system" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "formulation" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "run" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "device_session" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "calibration" }))).toBeUndefined() + }) +}) + +// Candidates below use plain string refs ("a", "b", …) standing in for the +// PartGroup entries message-part.tsx actually passes. +const key = (entity: string, action = "proposed", problem = "x-gate") => ({ problem, entity, action }) + +describe("collapseReceiptRuns", () => { + test("empty input → empty output", () => { + expect(collapseReceiptRuns([])).toEqual([]) + }) + + test("a run of 4 identical (problem, entity, action) → one entry, count 4, latest = max seq", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("recommend"), seq: 2 }, + { ref: "c", key: key("recommend"), seq: 3 }, + { ref: "d", key: key("recommend"), seq: 4 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0]).toEqual({ refs: ["a", "b", "c", "d"], latestRef: "d", count: 4 }) + }) + + test("does not assume seqs are sorted — the highest numeric seq wins regardless of position", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 3 }, + { ref: "b", key: key("recommend"), seq: 1 }, + { ref: "c", key: key("recommend"), seq: 9 }, + { ref: "d", key: key("recommend"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0].latestRef).toBe("c") + expect(runs[0].count).toBe(4) + }) + + test("all members missing seq → latest falls back to the LAST member (transcript order)", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend") }, + { ref: "b", key: key("recommend") }, + { ref: "c", key: key("recommend") }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0].latestRef).toBe("c") + }) + + test("differing action → two entries, counts 1 and 1 (a state change must not hide)", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend", "proposed"), seq: 1 }, + { ref: "b", key: key("recommend", "gated"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs[0]).toEqual({ refs: ["a"], latestRef: "a", count: 1 }) + expect(runs[1]).toEqual({ refs: ["b"], latestRef: "b", count: 1 }) + }) + + test("differing entity → not merged", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("problem"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs.map((r) => r.count)).toEqual([1, 1]) + }) + + test("differing problem → not merged", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend", "proposed", "x-gate"), seq: 1 }, + { ref: "b", key: key("recommend", "proposed", "y-gate"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs.map((r) => r.count)).toEqual([1, 1]) + }) + + test("a non-mergeable part interrupting a run → two separate runs either side of it", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("recommend"), seq: 2 }, + { ref: "other" }, // e.g. a text part, a different tool, an INLINE_KINDS receipt + { ref: "c", key: key("recommend"), seq: 3 }, + { ref: "d", key: key("recommend"), seq: 4 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(3) + expect(runs[0]).toEqual({ refs: ["a", "b"], latestRef: "b", count: 2 }) + expect(runs[1]).toEqual({ refs: ["other"], latestRef: "other", count: 1 }) + expect(runs[2]).toEqual({ refs: ["c", "d"], latestRef: "d", count: 2 }) + }) + + test("a single card → count 1, shape unchanged from the uncollapsed case", () => { + const runs = collapseReceiptRuns([{ ref: "solo", key: key("recommend"), seq: 1 }]) + expect(runs).toEqual([{ refs: ["solo"], latestRef: "solo", count: 1 }]) + }) + + test("unparseable sentinel (no key) → never merged, not even with an adjacent identical unparseable one", () => { + const items: ReceiptCandidate[] = [{ ref: "a" }, { ref: "b" }] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs).toEqual([ + { refs: ["a"], latestRef: "a", count: 1 }, + { refs: ["b"], latestRef: "b", count: 1 }, + ]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.ts new file mode 100644 index 00000000..a66a9221 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt-runs.ts @@ -0,0 +1,116 @@ +// AMICODE: collapses RUNS of consecutive amicode_* receipt cards that share +// (problem, entity, action) into one card carrying a count — the fix for a +// real user report: "these repeated amico cards add a lot of clutter can we +// avoid this?" Four amicode_* calls that all update the same entity via the +// same action render four visually-identical cards; amico-presence.ts's +// design intent is that Amico pops in, works, and pops out clean — a wall of +// look-alike receipts is exactly the clutter that breaks. +// +// Pure + DOM-free (fork convention — see thinking.ts, run-series.ts, +// receipt-currency.ts): this module knows nothing about SDK part/message +// types. It works over an abstract `Ref` — message-part.tsx supplies +// PartGroup entries as Refs and reduces the result back into its render list. +// +// Conservative by construction, per the spec's rules: +// - Only a PARSED AMICODE_DIFF sentinel carries (problem, entity, action). +// A receipt whose sentinel doesn't parse (the legacy Chip fallback — +// receipt.ts's parseDiffSentinel, card.tsx's Chip) has no key and never +// merges with anything, including another unparseable receipt. +// - INLINE_KINDS entities (receipt.ts) never merge either, even though +// their sentinel parses fine. Whether such a receipt renders as a Chip or +// the live InlineEntityView depends on receipt-currency.ts's +// receiptIsCurrent, which reads the LIVE problem view — reactive +// UI-bridge state this pure module has no access to. Collapsing one of +// those into a single representative card risks silently dropping +// whichever render path the representative doesn't take, which would be +// exactly the information loss this feature must not cause. Left to +// render exactly as before; only entities that always take the Chip body +// (e.g. "recommend", "problem") are collapse-eligible. +// - "Latest" reuses receipt-currency's seq semantics — highest numeric seq +// wins, order not assumed (see latestSeqForEntity) — rather than +// inventing a parallel notion of "current". A collapsed card must open +// the highest seq in its run because later receipts supersede earlier +// ones. + +import { INLINE_KINDS, type DiffSentinel } from "./receipt" + +export interface ReceiptKey { + problem: string + entity: string + action: string +} + +function sameKey(a: ReceiptKey, b: ReceiptKey): boolean { + return a.problem === b.problem && a.entity === b.entity && a.action === b.action +} + +/** The (problem, entity, action) a receipt would merge on, or undefined when + * it must never merge: no sentinel parsed, or its entity has a live inline + * view this module can't safely predict the routing for (see module docs). */ +export function receiptRunKey(sentinel: DiffSentinel | undefined): ReceiptKey | undefined { + if (!sentinel) return undefined + if (INLINE_KINDS.has(sentinel.entity)) return undefined + return { problem: sentinel.problem, entity: sentinel.entity, action: sentinel.action } +} + +export interface ReceiptCandidate { + ref: Ref + /** undefined ⇒ this candidate can never merge, with anything. */ + key?: ReceiptKey + seq?: number +} + +export interface ReceiptRun { + /** Members in transcript order. Length 1 for a non-merged (or unmergeable) receipt. */ + refs: Ref[] + /** The member to render: highest seq; ties or all-missing seq prefer the LATER member. */ + latestRef: Ref + count: number +} + +/** Highest-seq member of a run. Ties, or a run where no member carries a + * seq, prefer the member that occurs LATER in the run — transcript order is + * itself a proxy for recency when the sentinel omits `seq`. Mirrors + * receipt-currency's latestSeqForEntity (max wins, order not assumed) + * without requiring every member to carry a seq the way EventView does. */ +function pickLatest(items: ReceiptCandidate[]): Ref { + let best = items[0] + for (let i = 1; i < items.length; i++) { + const item = items[i] + if (item.seq !== undefined && (best.seq === undefined || item.seq > best.seq)) best = item + else if (item.seq === undefined && best.seq === undefined) best = item + } + return best.ref +} + +/** Collapse consecutive candidates that share a key into one run. A + * candidate with no key never merges with its neighbours — not even with + * another keyless candidate — so it always surfaces as its own run of one, + * and it breaks any run adjacent to it. */ +export function collapseReceiptRuns(items: ReceiptCandidate[]): ReceiptRun[] { + const runs: ReceiptRun[] = [] + let open: ReceiptCandidate[] = [] + + const flushOpen = () => { + if (open.length === 0) return + runs.push({ refs: open.map((item) => item.ref), latestRef: pickLatest(open), count: open.length }) + open = [] + } + + for (const item of items) { + if (!item.key) { + flushOpen() + runs.push({ refs: [item.ref], latestRef: item.ref, count: 1 }) + continue + } + const current = open[open.length - 1] + if (current?.key && sameKey(current.key, item.key)) open.push(item) + else { + flushOpen() + open.push(item) + } + } + flushOpen() + + return runs +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.test.ts new file mode 100644 index 00000000..546e1214 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { parseDiffSentinel, receiptText, stripSentinel } from "./receipt" + +const LINE = + 'AMICODE_DIFF {"problem":"x-gate","entity":"system","action":"updated","seq":7,"diff":{"levels":{"from":3,"to":4},"params.omega":{"from":null,"to":4.8}}}' + +describe("parseDiffSentinel", () => { + test("parses the LAST line when it is a sentinel", () => { + const parsed = parseDiffSentinel(`System updated.\nNext step…\n${LINE}`) + expect(parsed).toMatchObject({ problem: "x-gate", entity: "system", action: "updated", seq: 7 }) + expect(parsed!.diff["levels"]).toEqual({ from: 3, to: 4 }) + }) + test("non-string / no sentinel / sentinel not last / malformed JSON → undefined", () => { + expect(parseDiffSentinel(undefined)).toBeUndefined() + expect(parseDiffSentinel("plain prose")).toBeUndefined() + expect(parseDiffSentinel(`${LINE}\ntrailing prose`)).toBeUndefined() + expect(parseDiffSentinel("AMICODE_DIFF {nope")).toBeUndefined() + expect(parseDiffSentinel('AMICODE_DIFF "just-a-string"')).toBeUndefined() + }) +}) + +describe("receiptText", () => { + test("one line, pretty entity name, dotted keys rendered bare, from→to", () => { + const parsed = parseDiffSentinel(LINE)! + expect(receiptText(parsed)).toBe("System · levels 3→4 · omega 4.8") + }) + test("create renders bare values (from null), truncation marker key renders as ellipsis", () => { + const created = parseDiffSentinel( + 'AMICODE_DIFF {"problem":"p","entity":"formulation","action":"created","seq":2,"diff":{"problem_type":{"from":null,"to":"gate_synthesis"},"…":{"from":null,"to":null}}}', + )! + expect(receiptText(created)).toBe("Formulation · problem_type gate_synthesis · …") + }) + test("empty diff falls back to the action", () => { + const archived = parseDiffSentinel( + 'AMICODE_DIFF {"problem":"p","entity":"problem","action":"archived","seq":9,"diff":{}}', + )! + expect(receiptText(archived)).toBe("Problem · archived") + }) +}) + +describe("stripSentinel", () => { + test("removes a trailing sentinel line, leaves other text intact", () => { + expect(stripSentinel(`prose\n${LINE}`)).toBe("prose") + expect(stripSentinel("prose only")).toBe("prose only") + expect(stripSentinel(undefined)).toBe("") + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.ts new file mode 100644 index 00000000..b974d478 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/receipt.ts @@ -0,0 +1,117 @@ +// AMICODE: single consumer of the AMICODE_DIFF sentinel wire (spec A — +// amicode_tools.ts sentinelLine). The sentinel is the LAST line of an +// amicode_* tool return: `AMICODE_DIFF {problem, entity, action, seq, diff}`. +// Strict parse; ANY failure → undefined and callers fall back to the legacy +// chip, so old sessions keep rendering. Never throws. + +import { compactValue } from "./facets" + +const PREFIX = "AMICODE_DIFF " + +export type DiffEntry = { from: unknown; to: unknown } +export type DiffSentinel = { + problem: string + entity: string + action: string + seq?: number + diff: Record +} + +export function parseDiffSentinel(output: unknown): DiffSentinel | undefined { + if (typeof output !== "string") return undefined + const lines = output.trimEnd().split("\n") + const last = lines[lines.length - 1] + if (!last?.startsWith(PREFIX)) return undefined + try { + const raw = JSON.parse(last.slice(PREFIX.length)) + if (typeof raw !== "object" || raw === null) return undefined + if (typeof raw.entity !== "string" || typeof raw.action !== "string") return undefined + const diff: Record = {} + if (typeof raw.diff === "object" && raw.diff !== null) + for (const [key, value] of Object.entries(raw.diff as Record)) + diff[key] = { from: value?.from ?? null, to: value?.to ?? null } + return { + problem: typeof raw.problem === "string" ? raw.problem : "", + entity: raw.entity, + action: raw.action, + seq: typeof raw.seq === "number" ? raw.seq : undefined, + diff, + } + } catch { + return undefined + } +} + +// Entities with a live in-transcript view (card.tsx's InlineEntityView, fed by +// the entity rail) instead of just a diff chip. Whether any GIVEN receipt for +// one of these renders as the chip or the live view depends on +// receipt-currency.ts's receiptIsCurrent (reactive, live-problem-view state) — +// so anything keying off this set (e.g. receipt-runs.ts) must treat it as "no +// safe static answer" rather than guessing. +export const INLINE_KINDS = new Set(["system", "formulation", "run", "device_session", "calibration"]) + +export const ENTITY_LABELS: Record = { + system: "System", + formulation: "Formulation", + run: "Run", + device_session: "Device", + calibration: "Calibration", + problem: "Problem", +} +export function entityLabel(kind: string): string { + const known = ENTITY_LABELS[kind] + if (known) return known + const raw = kind.replaceAll("_", " ") + return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : kind +} + +function short(value: unknown): string { + return compactValue(value) // spec §4: array/object-aware, never a JSON blob +} + +// A receipt's body, broken into typed pieces so the UI can render each change +// as a discrete unit (dimmed old value · arrow · new value) instead of a flat +// string. `receiptText` below joins these back into the exact one-line form the +// legacy chip and the tests expect — the structured and string renders never +// diverge because they come from the same source. +export type ReceiptChange = + | { kind: "elision" } + | { kind: "set"; key: string; to: string } + | { kind: "change"; key: string; from: string; to: string } +export type ReceiptParts = { label: string; changes: ReceiptChange[]; action: string } + +export function receiptParts(sentinel: DiffSentinel): ReceiptParts { + const changes: ReceiptChange[] = Object.entries(sentinel.diff).map(([key, entry]) => { + if (key === "…") return { kind: "elision" } + const bare = key.split(".").pop() ?? key + if (entry.from === null || entry.from === undefined) return { kind: "set", key: bare, to: short(entry.to) } + return { kind: "change", key: bare, from: short(entry.from), to: short(entry.to) } + }) + return { label: entityLabel(sentinel.entity), changes, action: sentinel.action } +} + +/** `System · levels 3→4 · omega 4.8` — dotted diff keys render bare (last + * segment); creates (from null) render value-only; the spec-A elision key + * `…` renders as a bare ellipsis. Empty diff → the action. One line, always. */ +export function receiptText(sentinel: DiffSentinel): string { + const { label, changes, action } = receiptParts(sentinel) + const parts = changes.map((change) => { + if (change.kind === "elision") return "…" + if (change.kind === "set") return `${change.key} ${change.to}` + return `${change.key} ${change.from}→${change.to}` + }) + const body = parts.length > 0 ? parts.join(" · ") : action + return `${label} · ${body}` +} + +/** For any raw-output display path: drop a trailing sentinel line. NOTE: the + * current fork has NO such path — card.tsx deliberately never renders the raw + * tool return, and amicode_* parts never reach GenericTool (message-part.tsx + * routes them all to AmicodeToolCard). Exported + tested so the spec's strip + * rule holds by construction if a raw path is ever added. */ +export function stripSentinel(output: unknown): string { + if (typeof output !== "string") return "" + const lines = output.trimEnd().split("\n") + if (lines[lines.length - 1]?.startsWith(PREFIX)) lines.pop() + return lines.join("\n").trimEnd() +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/run-card.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/run-card.test.ts new file mode 100644 index 00000000..ff5c9737 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/run-card.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { fidelityParts, parseRunCardsResponse, renderRunCardSvg, runCardFilename } from "./run-card" + +const CARD = { + slug: "cz-gate", + problem: "CZ on Rydberg", + platform: "rydberg", + gate: "CZ", + runId: "r20260707-010101Z-aaaa", + lab: "default", + fidelity: 0.99994, + iterations: 47, + elapsedMs: 130_000, + finishedAt: 1_783_000_000_000, + series: [ + { iter: 1, f: 0.5 }, + { iter: 2, f: 1e-4 }, + ], + pulse: { dt: 0.5, values: [1, 2, 3, 4, 5, 6] }, + pulseMeta: { drives: 2, knots: 3 }, +} + +describe("fidelityParts", () => { + test("splits the leading nines for the gold highlight", () => { + expect(fidelityParts(0.99994)).toEqual({ pre: "0.", nines: "9999", rest: "4" }) + expect(fidelityParts(0.5)).toEqual({ pre: "0.", nines: "", rest: "50000" }) + expect(fidelityParts(1)).toEqual({ pre: "1.", nines: "", rest: "00000" }) + }) +}) + +describe("renderRunCardSvg", () => { + test("carries the headline, brand, curves and per-drive pulses", () => { + const svg = renderRunCardSvg(CARD) + expect(svg).toContain("CZ · rydberg") + expect(svg).toContain("47 iterations") + expect(svg).toContain("harmoniqs.ai") + expect(svg).toContain("AMICODE") + // 2 drives → 2 pulse paths (second at companion opacity) + expect(svg.match(/stroke="#F2C94C" stroke-width="2\.5"/g)?.length).toBe(2) + expect(svg).toContain('opacity="0.55"') + }) + test("pulse panel reads first (left column), convergence second", () => { + const svg = renderRunCardSvg(CARD) + expect(svg.indexOf("FINAL PULSE")).toBeLessThan(svg.indexOf("CONVERGENCE")) + }) + test("escapes hostile strings", () => { + const svg = renderRunCardSvg({ ...CARD, problem: 'x"/>' }) + expect(svg).not.toContain("