From d0b2a1c4188d1aeb817a4e9664b296fcb1375075 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 20 Aug 2026 19:02:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(solver):=20release=20the=20HP=20tier=20?= =?UTF-8?q?=E2=80=94=20the=20piccolo=20half=20of=20the=20mode=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #167 gave the tier a durable way IN: a validated Company Compute credential grants `issimo` and writes {mode:"hp",status:"switching"} for the extension's watcher to re-prep from. Nothing ever wrote the other direction, so connections.ts carried exactly one `switching` writer and it was hardcoded to hp. The toggle's Piccolo button called saveSolverMode() — localStorage — and left the ops dir on hp, so amico-run went on refusing every local launch while the UI read "Piccolo". Disconnect was the same story: pick(modeAfterDisconnect()) never reached the server, so the "disconnecting Company Compute can never leave HP selected" invariant was not enforced anywhere. - requestPiccoloFlip() mirrors requestHpFlip: revoke the entitlement AND request the switch in one operation. Both, or the setup is split-brained — a piccolo mode file beside a granted `issimo` is exactly what amicode#259's reconcileSolverMode heals straight back to hp. - POST /amicode/solver-mode serves piccolo ONLY. hp still arrives exclusively with a validated credential; a second hp writer is the duplicate flip ADR 0001 forbids. - releaseRequestForPick() names that rule on the client and pick() routes through it, so disconnect gets the durable release for free. revokeIssimo deliberately rethrows anything that is not ENOENT: readSolverMode falls back to piccolo on the same broken ops dir, so swallowing an IO fault would short-circuit the flip and report success having written nothing. A test covers that path. Not included: the staged switch wizard from the stale #14. It polls the UI through the server restart and is separable from making the switch work. Refs #78 --- .../components/amicode-defaults-capsule.tsx | 9 ++ .../src/components/status-popover-body.tsx | 25 +++++ packages/app/src/pages/home.tsx | 1 + .../src/server/amicode/connections.ts | 86 +++++++++++++++ .../server/routes/instance/httpapi/server.ts | 11 ++ .../server/amicode-connections-routes.test.ts | 38 +++++++ .../test/server/amicode-connections.test.ts | 104 ++++++++++++++++++ packages/ui/src/amicode/solver-toggle.test.ts | 17 +++ packages/ui/src/amicode/solver-toggle.tsx | 10 ++ .../src/components/amicode-solver-toggle.tsx | 1 + 10 files changed, 302 insertions(+) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 894145309..a8273e245 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -6,6 +6,7 @@ import { hpAfterConnect, hpClickAction, loadSolverMode, + releaseRequestForPick, modeAfterDisconnect, saveSolverMode, solverConnectionDot, @@ -46,6 +47,9 @@ export type AmicodeComputeControl = { onSubmit: (payload: CredentialSubmitPayload) => Promise onDisconnect: (id: string) => void onRevalidate: (id: string) => void + /** opencode#78: request the durable release of the HP tier. Optional so bare + * mounts (storybook) keep the localStorage-only legacy behavior. */ + onSelectPiccolo?: () => void refetch: () => void } @@ -74,6 +78,11 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl const pick = (m: SolverMode) => { setMode(m) saveSolverMode(m) + // opencode#78: localStorage is the DISPLAY state; the durable half lives in + // the ops dir and only the server may write it. Releasing the tier is the + // one direction the client requests — hp arrives with a validated + // credential (submitCredential), never from a button. + if (releaseRequestForPick(m)) props.compute?.onSelectPiccolo?.() } const dot = createMemo(() => solverConnectionDot(props.compute?.view())) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 85b54f55c..af70b3d38 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -682,6 +682,30 @@ export function createAmicodeConnectionsState(shown: Accessor) { const onChooseProject = (payload: ChooseProjectPayload) => void runConnectionAction(payload.id, "/amicode/connections/choose-project", payload) const onCancelAuth = (id: string) => void runConnectionAction(id, "/amicode/connections/disconnect", { id }) + // opencode#78: releasing the HP tier. Deliberately NOT runConnectionAction — + // that helper keys its overlay on a connection id and reads a `connection` + // out of the response; this route answers {ok, mode, error} about the solver, + // not about a connection, so routing it through there would render the + // release as a failed credential mutation. Fire-and-forget with a refetch: + // the server restart the switch triggers makes any awaited status stale + // anyway. + const onSelectPiccolo = () => { + const conn = server.current + if (!conn) return + void (async () => { + try { + await fetch(new URL("/amicode/solver-mode", conn.http.url), { + method: "POST", + headers: { ...amicodeHeaders(conn), "content-type": "application/json" }, + body: JSON.stringify({ mode: "piccolo" }), + }) + } catch { + /* the extension watcher is the durable half; a lost request is retried + by the next pick rather than surfaced as a connection error */ + } + refetchConnections() + })() + } // Google browser OAuth: POST to the server's auth start route; the server opens the // system browser via McpBrowser (which now respects BROWSER in VS Code remote). // Fallback to window.open when the server does not return a URL (e.g. token-based @@ -753,6 +777,7 @@ export function createAmicodeConnectionsState(shown: Accessor) { onSubmitCredential, onDisconnectConnection, onRevalidateConnection, + onSelectPiccolo, onChooseProject, onCancelAuth, onStartAuth, diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index f3634edb7..e725fb6c2 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -216,6 +216,7 @@ function HomeDesign() { onSubmit: chromeConnections.onSubmitCredential, onDisconnect: chromeConnections.onDisconnectConnection, onRevalidate: chromeConnections.onRevalidateConnection, + onSelectPiccolo: chromeConnections.onSelectPiccolo, refetch: chromeConnections.refetchConnections, } let focusSessionSearch: (() => void) | undefined diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 467ff69c5..789a86f4d 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -1076,6 +1076,38 @@ function grantIssimo(file: string): { alreadyGranted: boolean } { return { alreadyGranted: false } } +/** Revoke `issimo` PRESERVING every other code — the exact mirror of + * grantIssimo, byte-compatible with the extension's applyEntitlementForMode + * (`codes = [...]` + optional `expired = [...]`). Returns whether the code + * was already absent, so a settled piccolo setup writes nothing. */ +function revokeIssimo(file: string): { alreadyRevoked: boolean } { + let codes: string[] = [] + let expired: string[] = [] + try { + const parsed = parseTomlLite(readFileSync(file, "utf8")) + if (parsed.ok) { + const value = parsed.value as { codes?: unknown; expired?: unknown } + if (Array.isArray(value.codes)) codes = value.codes.filter((c): c is string => typeof c === "string") + if (Array.isArray(value.expired)) expired = value.expired.filter((c): c is string => typeof c === "string") + } + } catch (e) { + // A file that isn't there has no grant to revoke — the fresh-install state, + // and a legitimate no-op. Any OTHER fault (an unwritable ops dir, a path + // blocked by a regular file) must NOT masquerade as "already released": + // readSolverMode falls back to piccolo on the same broken dir, so swallowing + // it here would short-circuit the whole flip and report success having + // written nothing. + if ((e as NodeJS.ErrnoException)?.code === "ENOENT") return { alreadyRevoked: true } + throw e + } + if (!codes.includes("issimo")) return { alreadyRevoked: true } + codes = codes.filter((c) => c !== "issimo") + const lines = [`codes = [${codes.map((c) => JSON.stringify(c)).join(", ")}]`] + if (expired.length > 0) lines.push(`expired = [${expired.map((c) => JSON.stringify(c)).join(", ")}]`) + atomicWriteFileSync(file, lines.join("\n") + "\n") + return { alreadyRevoked: false } +} + /** Tolerant {mode,status} read — the extension's readSolverModeState * semantics: anything absent/off-shape collapses to piccolo/ready. */ function readSolverMode(file: string): { mode: "piccolo" | "hp"; status: "ready" | "switching" } { @@ -1095,6 +1127,10 @@ function readSolverMode(file: string): { mode: "piccolo" | "hp"; status: "ready" * module contract — never a token, path, or errno. */ export const HP_FLIP_WARNING = "hp_flip_failed: connected, but the HP solver switch could not be requested" +/** Sibling of HP_FLIP_WARNING for the release direction. Value-free by the + * module contract — never a token, path, or errno. */ +export const PICCOLO_FLIP_WARNING = "piccolo_flip_failed: the local solver switch could not be requested" + /** After a VALID save: grant the entitlement, then request the hp switch the * watcher re-preps from — but ONLY when a re-prep would change anything (the * mode isn't hp yet, or the last prep ran without the grant). A repeat save @@ -1115,6 +1151,49 @@ function requestHpFlip(): string | undefined { } } +/** The mirror of requestHpFlip, and the half opencode#78 was missing: RELEASE + * the tier. Revokes the entitlement and requests the piccolo switch in one + * operation — both, or the setup is split-brained (a piccolo mode file beside + * a granted `issimo` is precisely what amicode#259's reconcileSolverMode + * heals straight back to hp). No-ops when the setup is already settled at + * piccolo with no grant, so the watcher — whose one re-prep restarts THIS + * server — is never poked for nothing. NEVER throws. */ +function requestPiccoloFlip(): string | undefined { + try { + const { alreadyRevoked } = revokeIssimo(entitlementsFile()) + const modeFile = solverModeFile() + if (alreadyRevoked && readSolverMode(modeFile).mode === "piccolo") return undefined + atomicWriteFileSync(modeFile, JSON.stringify({ mode: "piccolo", status: "switching" })) + return undefined + } catch { + return PICCOLO_FLIP_WARNING + } +} + +/** POST /amicode/solver-mode — body {mode:"piccolo"}. Releasing the tier is + * the ONLY direction this route serves: hp is granted by connecting a + * credential (submitCredentialResponse), and a second hp writer is the + * duplicate-flip ADR 0001 forbids. */ +export function solverModeResponse(rawBody: string, deps: MutationDeps = {}): string { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return synthesizeSolverMode("non_loopback", "solver mutations serve loopback binds only") + const parsed = parseMutationBody(rawBody) + if (!parsed || typeof parsed.mode !== "string") + return synthesizeSolverMode("bad_request", 'body must be JSON {mode:"piccolo"}') + if (parsed.mode !== "piccolo") + return synthesizeSolverMode( + "unsupported_mode", + "only piccolo is selectable here; hp follows a Company Compute credential", + ) + const warning = requestPiccoloFlip() + // Unlike the hp flip — a rider on a credential save that stands on its own — + // the flip IS this route's whole operation, so trouble is a failure, not a + // partial one. PICCOLO_FLIP_WARNING is already in the sibling "code: detail" + // shape, so it rides the error field verbatim. + if (warning) return JSON.stringify({ ok: false, mode: null, error: warning }) + return JSON.stringify({ ok: true, mode: "piccolo", error: null }) +} + // --- mutation bodies (POST routes). One shape per route family, sibling // discipline: never reject, ok:false + "code: detail" on failure. SECURITY: // every failure message is a FIXED string — nothing the caller sent (token, @@ -1124,6 +1203,12 @@ export function synthesizeConnection(code: string, detail: string): string { return JSON.stringify({ ok: false, connection: null, error: `${code}: ${detail}` }) } +/** Sibling shape for the solver-mode family — no `connection` rides this + * route, so it carries the settled-mode field instead. */ +export function synthesizeSolverMode(code: string, detail: string): string { + return JSON.stringify({ ok: false, mode: null, error: `${code}: ${detail}` }) +} + // --- loopback guard: credential mutations serve LOCAL callers only. The bind // hostname is recorded by Server.listen (server.ts) at listen time; the // in-process webHandler never binds a socket, so "never recorded" counts as @@ -1197,6 +1282,7 @@ function renderCurrent(id: ConnectionType, warning?: string): string { interface MutationBody { id?: unknown + mode?: unknown base_url?: unknown token?: unknown username?: unknown diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 94ae3d796..23f7941ee 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -485,6 +485,17 @@ const amicodeConnectionsRoute = HttpRouter.use((router) => }) }), ) + // opencode#78: RELEASING the solver tier. Selecting hp is not served here — + // that flip rides a validated Company Compute credential (the route above), + // and a second hp writer is the duplicate flip ADR 0001 forbids. + yield* router.add("POST", "/amicode/solver-mode", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + return HttpServerResponse.text(AmicodeConnections.solverModeResponse(body), { + contentType: "application/json", + }) + }), + ) }), ).pipe(Layer.provide(authOnlyRouterLayer)) diff --git a/packages/opencode/test/server/amicode-connections-routes.test.ts b/packages/opencode/test/server/amicode-connections-routes.test.ts index 2e669a64c..1e42c777c 100644 --- a/packages/opencode/test/server/amicode-connections-routes.test.ts +++ b/packages/opencode/test/server/amicode-connections-routes.test.ts @@ -234,6 +234,44 @@ describe("connections routes — full lifecycle, no extension host (AC6)", () => }) }) +describe("solver-mode route — releasing the HP tier over the route tree (opencode#78)", () => { + test("POST /amicode/solver-mode {mode:'piccolo'} releases the tier; {mode:'hp'} is refused", async () => { + const server = app() + const stub = await stubSolveService(() => 200) + try { + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: stub.url, token: "tok-release" }), + ) + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["issimo"]\n') + + const released = await (await server.request("/amicode/solver-mode", post({ mode: "piccolo" }))).json() + expect(released.ok).toBe(true) + expect(released.mode).toBe("piccolo") + expect(readFileSync(entitlementsFile(), "utf8")).toBe("codes = []\n") + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "piccolo", status: "switching" }) + + // hp is not selectable here — it follows a credential, and a second hp + // writer is the duplicate flip ADR 0001 forbids + const refused = await (await server.request("/amicode/solver-mode", post({ mode: "hp" }))).json() + expect(refused.ok).toBe(false) + expect(JSON.parse(readFileSync(solverModeFile(), "utf8")).mode).toBe("piccolo") + } finally { + await stub.close() + } + }) + + test("the route carries the same auth wrapper as every other amicode route (#163)", async () => { + const guarded = app({ password: "pw", username: "user" }) + expect((await guarded.request("/amicode/solver-mode", post({ mode: "piccolo" }))).status).toBe(401) + const authed = await guarded.request("/amicode/solver-mode", { + ...post({ mode: "piccolo" }), + headers: { Authorization: basic("user", "pw") }, + }) + expect(authed.status).toBe(200) + }) +}) + describe("connections routes — HP flip artifacts over the route tree (167 AC1, AC3, AC4)", () => { test("valid submit over the route → both flip artifacts; disconnect leaves them (one-way); 401 never flips", async () => { const server = app() diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 4051df5f3..72312b779 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -29,6 +29,7 @@ import { pasqalValidatorScript, probeCompanyCompute, solverModeFile, + solverModeResponse, statusResponse, STALE_MS, statusBody, @@ -1751,3 +1752,106 @@ describe("pasqal two-step project picker (#194)", () => { expect(parsed.connection.identity).toBe("proj-direct") }) }) + +// --- Piccolo flip: the way BACK OUT of HP (opencode#78). #167 gave the tier a +// durable way IN — a valid credential grants `issimo` and requests the hp +// switch — but nothing ever wrote the other direction, so `connections.ts` had +// exactly one `switching` writer and it was hardcoded to hp. The toggle's +// Piccolo button wrote localStorage and left the ops dir on hp, which is why +// amico-run kept refusing local launches while the UI read "Piccolo". + +describe("Piccolo flip — releasing the HP tier (opencode#78)", () => { + test("selecting piccolo revokes issimo AND writes the piccolo switching request", async () => { + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) // land in hp + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["issimo"]\n') + + const parsed = JSON.parse(solverModeResponse(JSON.stringify({ mode: "piccolo" }))) + expect(parsed.ok).toBe(true) + expect(parsed.error).toBeNull() + // both artifacts flip together: a piccolo request with `issimo` still + // granted is the split-brain state #259's reconcileSolverMode heals + // straight back to hp + expect(readFileSync(entitlementsFile(), "utf8")).toBe("codes = []\n") + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "piccolo", status: "switching" }) + }) +}) + +describe("Piccolo flip — the same guards every other mutation carries", () => { + test("non-loopback bind is refused, and refuses INERTLY — an hp setup is left untouched", async () => { + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + const modeBytes = readFileSync(solverModeFile(), "utf8") + + setBindHostname("0.0.0.0") + const refused = JSON.parse(solverModeResponse(JSON.stringify({ mode: "piccolo" }))) + setBindHostname(undefined) + + expect(refused.ok).toBe(false) + expect(refused.error).toContain("non_loopback") + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) + expect(readFileSync(solverModeFile(), "utf8")).toBe(modeBytes) + }) + + test("only release: {mode:'hp'} and malformed bodies are refused, and write nothing (ADR 0001 single hp writer)", async () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(solverModeFile(), JSON.stringify({ mode: "hp", status: "ready" })) + writeFileSync(entitlementsFile(), 'codes = ["issimo"]\n') + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + const modeBytes = readFileSync(solverModeFile(), "utf8") + + const rejects = [ + JSON.stringify({ mode: "hp" }), // hp is granted by connecting a key, never by asking + JSON.stringify({ mode: "nonsense" }), + JSON.stringify({}), + "not json {{{", + ] + for (const body of rejects) { + const parsed = JSON.parse(solverModeResponse(body)) + expect(parsed.ok).toBe(false) + expect(parsed.mode).toBeNull() + expect(typeof parsed.error).toBe("string") + } + // an hp setup survives every one of them byte-for-byte + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) + expect(readFileSync(solverModeFile(), "utf8")).toBe(modeBytes) + }) +}) + +describe("Piccolo flip — release semantics mirror the grant (167 AC4 idiom)", () => { + test("revoke PRESERVES every other code and the expired list — read-modify-write, byte-compatible", () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(entitlementsFile(), 'codes = ["pasqal-hackathon-2026", "issimo"]\nexpired = ["old-2025"]\n') + solverModeResponse(JSON.stringify({ mode: "piccolo" })) + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["pasqal-hackathon-2026"]\nexpired = ["old-2025"]\n') + }) + + test("repeat release on an already-settled piccolo setup: the watcher is NOT poked again", () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(entitlementsFile(), "codes = []\n") + writeFileSync( + solverModeFile(), + JSON.stringify({ mode: "piccolo", status: "ready", switched_at: new Date().toISOString() }), + ) + const modeBytes = readFileSync(solverModeFile(), "utf8") + expect(JSON.parse(solverModeResponse(JSON.stringify({ mode: "piccolo" }))).ok).toBe(true) + expect(readFileSync(solverModeFile(), "utf8")).toBe(modeBytes) // no restart for a no-op + }) + + test("piccolo already selected but the grant LINGERS → the switch IS re-requested (the split-brain heal)", () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(solverModeFile(), JSON.stringify({ mode: "piccolo", status: "ready" })) + writeFileSync(entitlementsFile(), 'codes = ["issimo"]\n') // exactly the state that stranded the tier + solverModeResponse(JSON.stringify({ mode: "piccolo" })) + expect(readFileSync(entitlementsFile(), "utf8")).toBe("codes = []\n") + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "piccolo", status: "switching" }) + }) + + test("flip write failure → ok:false with a fixed, value-free warning (no path, no errno)", () => { + writeFileSync(path.join(dir, "blocker-piccolo"), "") + process.env.AMICODE_OPS_DIR = path.join(dir, "blocker-piccolo", "ops") + const raw = solverModeResponse(JSON.stringify({ mode: "piccolo" })) + expect(JSON.parse(raw).ok).toBe(false) + expect(JSON.parse(raw).error).toStartWith("piccolo_flip_failed:") + expect(raw).not.toContain(dir) + }) +}) diff --git a/packages/ui/src/amicode/solver-toggle.test.ts b/packages/ui/src/amicode/solver-toggle.test.ts index 9216d65d9..27870bded 100644 --- a/packages/ui/src/amicode/solver-toggle.test.ts +++ b/packages/ui/src/amicode/solver-toggle.test.ts @@ -71,3 +71,20 @@ describe("connect flips HP only on a landed connection (#200 AC2/AC7)", () => { expect(modeAfterDisconnect()).toBe("piccolo") }) }) + +// ── opencode#78: the toggle's picks reach the server ──────────────────────── +import { releaseRequestForPick } from "./solver-toggle" + +describe("which picks travel to the server (opencode#78)", () => { + test("piccolo releases the tier; hp NEVER requests a flip from the client", () => { + // selecting piccolo is the only durable write the toggle may ask for + expect(releaseRequestForPick("piccolo")).toBe("piccolo") + // hp follows a validated Company Compute credential — a client-side hp + // writer would be the duplicate flip ADR 0001 forbids + expect(releaseRequestForPick("hp")).toBeUndefined() + }) + + test("the disconnect invariant routes through the same release", () => { + expect(releaseRequestForPick(modeAfterDisconnect())).toBe("piccolo") + }) +}) diff --git a/packages/ui/src/amicode/solver-toggle.tsx b/packages/ui/src/amicode/solver-toggle.tsx index fecc71bba..eddf5c936 100644 --- a/packages/ui/src/amicode/solver-toggle.tsx +++ b/packages/ui/src/amicode/solver-toggle.tsx @@ -62,6 +62,16 @@ export function modeAfterDisconnect(): SolverMode { return "piccolo" } +/** opencode#78: a pick only travels to the server when it RELEASES the tier. + * Selecting hp is never a client request — that flip rides a validated + * Company Compute credential (the server's submitCredentialResponse), and a + * second hp writer is exactly the duplicate flip ADR 0001 forbids. Returning + * the mode rather than a bare boolean keeps the call site honest about WHAT + * it is asking the server for. */ +export function releaseRequestForPick(mode: SolverMode): "piccolo" | undefined { + return mode === "piccolo" ? "piccolo" : undefined +} + export function AmicodeSolverToggle() { const [mode, setMode] = createSignal(loadSolverMode()) const pick = (m: SolverMode) => { diff --git a/packages/ui/src/components/amicode-solver-toggle.tsx b/packages/ui/src/components/amicode-solver-toggle.tsx index 453352c26..c94f06695 100644 --- a/packages/ui/src/components/amicode-solver-toggle.tsx +++ b/packages/ui/src/components/amicode-solver-toggle.tsx @@ -7,6 +7,7 @@ export { hpClickAction, hpAfterConnect, modeAfterDisconnect, + releaseRequestForPick, type SolverMode, type SolverConnectionDot, type HpClickAction,