From 1edbd9e50a58d2a1e7fd202ad38393df0a1cb48b Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 15:51:01 -0400 Subject: [PATCH 01/11] feat(amicode): solver toggle owns the Company Compute connection (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HP radio in the defaults capsule wears a truthful status dot and hosts the connection itself: clicking HP unconnected expands the same ConnectionCard the status popover used to render — a credential that lands connected flips HP on; disconnect always drops back to Piccolo (HP is cloud-only). Connected + already-HP clicks open the card in management state. - solver-toggle: pure decision helpers (dot mapping, click routing, flip-on-connect, mode-after-disconnect) with unit tests - connections: statusTabConnections() render filter — the status popover's Connections tab shows execution targets only; Company Compute row removed - connections-tab: ConnectionCard exported for the capsule mount - status-popover-body: connections state extracted verbatim into createAmicodeConnectionsState() so the home chrome hosts an always-warm instance for the dot; popover wiring unchanged - stale 'Preview — GPU cloud solver rolling out' tooltip removed Part of harmoniqs/amicode#200 (AC1-5, 7, 8; AC6 lands extension-side). Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 126 +++++++++++++++++- .../src/components/status-popover-body.tsx | 32 +++-- packages/app/src/pages/home.tsx | 20 ++- packages/ui/src/amicode/connections-tab.tsx | 9 +- packages/ui/src/amicode/connections.test.ts | 15 +++ packages/ui/src/amicode/connections.ts | 7 + packages/ui/src/amicode/solver-toggle.test.ts | 44 ++++++ packages/ui/src/amicode/solver-toggle.tsx | 39 +++++- .../components/amicode-connections-tab.tsx | 5 +- .../src/components/amicode-solver-toggle.tsx | 13 +- 10 files changed, 287 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 271d509d56..68c511070c 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -1,7 +1,22 @@ -import { Show, createMemo, createSignal, onCleanup } from "solid-js" +import { Show, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" import { useModels } from "@/context/models" import { AmicodeDefaultModel } from "./amicode-default-model" -import { loadSolverMode, saveSolverMode, type SolverMode } from "@opencode-ai/ui/amicode-solver-toggle" +import { + hpAfterConnect, + hpClickAction, + loadSolverMode, + modeAfterDisconnect, + saveSolverMode, + solverConnectionDot, + type SolverMode, +} from "@opencode-ai/ui/amicode-solver-toggle" +import { + ConnectionCard, + type ConnectionActionView, + type ConnectionsTabLabels, + type ConnectionView, + type CredentialSubmitPayload, +} from "@opencode-ai/ui/amicode-connections-tab" // AMICODE: the chrome strip's DEFAULTS CAPSULE (nav redesign, Kate 2026-07-15). // One compact control carries both session defaults — model and solver — so the @@ -12,16 +27,69 @@ import { loadSolverMode, saveSolverMode, type SolverMode } from "@opencode-ai/ui // high-performance solver is on, the face wears the accent border — the PRO // funnel stays visible at every width instead of being the first casualty // of a narrow column. +// +// amicode#200: the solver toggle owns the Company Compute connection. The HP +// radio wears a status dot (green connected / amber attention / gray no key), +// and clicking it while unconnected expands the SAME connection card the +// status popover used to host — connect here, HP flips on when the credential +// lands connected. Connected + already-HP clicks open the card in its +// management state (identity, validated-at, revalidate, disconnect). +// Disconnect always drops the mode back to Piccolo: HP is cloud-only. -export function AmicodeDefaultsCapsule() { +/** The Company Compute slice of createAmicodeConnectionsState, passed down by + * the host chrome. Optional so bare mounts (storybook) keep legacy behavior. */ +export type AmicodeComputeControl = { + view: Accessor + labels: Accessor + actionError: Accessor + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void + refetch: () => void +} + +const DOT_CLASS: Record, string> = { + connected: "bg-icon-success-base", + attention: "bg-icon-warning-base", + none: "bg-border-weak-base", +} + +export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl }) { const models = useModels() const [open, setOpen] = createSignal(false) const [mode, setMode] = createSignal(loadSolverMode()) + const [computeOpen, setComputeOpen] = createSignal(false) const pick = (m: SolverMode) => { setMode(m) saveSolverMode(m) } + const dot = createMemo(() => solverConnectionDot(props.compute?.view())) + const onHpClick = () => { + if (!props.compute) { + pick("hp") // legacy seam: unwired hosts keep the old behavior + return + } + const action = hpClickAction(mode(), dot()) + if (action === "activate") { + pick("hp") + setComputeOpen(false) + return + } + // connect | manage — reveal the connection card inline; freshen the view + setComputeOpen(true) + props.compute.refetch() + } + const submitCredential = async (payload: CredentialSubmitPayload) => { + const result = await props.compute!.onSubmit(payload) + if (hpAfterConnect(result)) pick("hp") + return result + } + const disconnectCompute = (id: string) => { + props.compute!.onDisconnect(id) + pick(modeAfterDisconnect()) + } + const modelName = createMemo(() => { const pin = models.pin.get() if (!pin) return "Auto" @@ -31,15 +99,20 @@ export function AmicodeDefaultsCapsule() { const hp = () => mode() === "hp" let root: HTMLDivElement | undefined + const close = () => { + setOpen(false) + setComputeOpen(false) + } const onDocPointer = (e: PointerEvent) => { - if (root && !root.contains(e.target as Node)) setOpen(false) + if (root && !root.contains(e.target as Node)) close() } const onDocKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false) + if (e.key === "Escape") close() } const toggle = () => { const next = !open() setOpen(next) + if (!next) setComputeOpen(false) if (next) { document.addEventListener("pointerdown", onDocPointer, true) document.addEventListener("keydown", onDocKey, true) @@ -146,7 +219,21 @@ export function AmicodeDefaultsCapsule() { > Piccolo - + +
+ } + > + {(conn) => ( + + )} + +
+
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index cf01cb28b8..2f2abd192f 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -583,12 +583,30 @@ function createAmicodeStatusTabs(opts: { shown: Accessor; onManageVault return view?.ok ? view.mounts.length : 0 } - // amicode: Connections tab (#166) — same per-active-server fetch idiom as - // vaults above. Mutations are ONE round trip (#165 contract): the overlay - // renders "validating" while the POST runs, then the terminal connection - // from the SAME response replaces it. No polling loop. + const connections = createAmicodeConnectionsState(opts.shown) + + return { + vaultsView, + vaultsCount, + refetchVaults, + onManageVaults: opts.onManageVaults, + ...connections, + } +} + +// amicode#200: the Connections state, extracted verbatim from the tabs factory +// so the defaults capsule (the solver toggle owns Company Compute now) can host +// its own always-warm instance. Same fetch idiom, same one-round-trip mutation +// contract (#165/#166); depends only on app-root contexts. +export function createAmicodeConnectionsState(shown: Accessor) { + const server = useServer() + const language = useLanguage() + + // Mutations are ONE round trip (#165 contract): the overlay renders + // "validating" while the POST runs, then the terminal connection from the + // SAME response replaces it. No polling loop. const [connectionsRaw, { refetch: refetchConnections }] = createResource( - () => (opts.shown() && server.current ? ServerConnection.key(server.current) : undefined), + () => (shown() && server.current ? ServerConnection.key(server.current) : undefined), async () => { const conn = server.current if (!conn) return undefined @@ -699,10 +717,6 @@ function createAmicodeStatusTabs(opts: { shown: Accessor; onManageVault })) return { - vaultsView, - vaultsCount, - refetchVaults, - onManageVaults: opts.onManageVaults, connectionsView, connectionsCount, connectionsLabels, diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 59f3b364b9..2c65cdbf0d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -63,7 +63,9 @@ import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" import { AMICODE_MANAGE_VAULTS_PROMPT } from "@opencode-ai/ui/amicode-vaults-tab" -import { AmicodeDefaultsCapsule } from "@/components/amicode-defaults-capsule" +import { AmicodeDefaultsCapsule, type AmicodeComputeControl } from "@/components/amicode-defaults-capsule" +import { createAmicodeConnectionsState } from "@/components/status-popover-body" +import { COMPANY_COMPUTE_ID } from "@opencode-ai/ui/amicode-connections-tab" import { GlobalConnectionsPopover } from "@/components/status-popover" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" @@ -171,6 +173,20 @@ function HomeDesign() { const global = useGlobal() const command = useCommand() const notification = useNotification() + + // amicode#200: the defaults capsule owns the Company Compute connection — + // an always-warm connections instance so the HP dot is truthful from mount + // (popover instances stay open-gated; mutations reconcile via fresh GETs). + const chromeConnections = createAmicodeConnectionsState(() => true) + const computeControl: AmicodeComputeControl = { + view: () => chromeConnections.connectionsView()?.connections.find((c) => c.id === COMPANY_COMPUTE_ID), + labels: chromeConnections.connectionsLabels, + actionError: chromeConnections.connectionsActionError, + onSubmit: chromeConnections.onSubmitCredential, + onDisconnect: chromeConnections.onDisconnectConnection, + onRevalidate: chromeConnections.onRevalidateConnection, + refetch: chromeConnections.refetchConnections, + } let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", @@ -816,7 +832,7 @@ function HomeDesign() { vertical dropdown (amicode.css). Rendered ONCE, so each control keeps its own popover state across the reflow. */}
- +
+ {/* aria-live: the card's state copy (validating → connected / + error) announces to screen readers; focus lands here on open + so keyboard users reach the form without tabbing blind. + The dot on the radio is color-only — its aria-label carries + the state in text (WCAG color-not-only). */}
setTimeout(() => el.focus(), 0)} style={{ "margin-top": "4px", border: "1px solid var(--v2-border-border-base)", "border-radius": "7px", padding: "4px 2px", + outline: "none", }} > setUsername(event.currentTarget.value)} @@ -404,6 +405,7 @@ export function ConnectionCard(props: { name="password" autocomplete="off" placeholder={props.labels.passwordPlaceholder} + aria-label={props.labels.passwordPlaceholder} value={password()} disabled={model().formDisabled} onInput={(event) => setPassword(event.currentTarget.value)} @@ -417,6 +419,7 @@ export function ConnectionCard(props: { autocomplete="off" spellcheck={false} placeholder={props.labels.baseUrlPlaceholder} + aria-label={props.labels.baseUrlPlaceholder} value={baseUrl()} disabled={model().formDisabled} onInput={(event) => setBaseUrl(event.currentTarget.value)} @@ -429,6 +432,7 @@ export function ConnectionCard(props: { name="token" autocomplete="off" placeholder={props.labels.tokenPlaceholder} + aria-label={props.labels.tokenPlaceholder} value={token()} disabled={model().formDisabled} onInput={(event) => setToken(event.currentTarget.value)} @@ -442,6 +446,7 @@ export function ConnectionCard(props: { autocomplete="off" spellcheck={false} placeholder={props.labels.projectIdPlaceholder} + aria-label={props.labels.projectIdPlaceholder} value={projectId()} disabled={model().formDisabled} onInput={(event) => setProjectId(event.currentTarget.value)} From a8b3dcbdc42702fddcb8545d3c88b801dc798588 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 16:26:25 -0400 Subject: [PATCH 04/11] =?UTF-8?q?ux(amicode):=20details=20gear=20on=20the?= =?UTF-8?q?=20HP=20row;=20'Company=20Compute'=20=E2=86=92=20'Solver=20API?= =?UTF-8?q?=20key'=20(amicode#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's live-test feedback: after a successful connect the form collapses to the slim connected card, and once the popover closes there was NO visible route back — 'click the radio again' was a hidden affordance. And 'Company Compute' presented one credential as a separate product. - HP row grows a persistent details (gear) button whenever a credential exists (connected or troubled) — opens the same card in management state. Radio click simplifies: connected → select the solver; unconnected → open the connect form. hpClickAction loses the hidden 'manage' branch; showConnectionDetails() carries the affordance contract (tested). - Display copy renames 'Company Compute' to 'Solver API key' everywhere user-facing — one credential, one service, presented as what it is. The wire id 'company-compute' is server contract and unchanged. Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 138 +++++++++++------- packages/ui/src/amicode/connections.test.ts | 2 +- packages/ui/src/amicode/connections.ts | 5 +- packages/ui/src/amicode/solver-toggle.test.ts | 22 ++- packages/ui/src/amicode/solver-toggle.tsx | 23 ++- .../src/components/amicode-solver-toggle.tsx | 1 + 6 files changed, 125 insertions(+), 66 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 0f456ff86d..cb2962b2da 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -1,12 +1,14 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" import { useModels } from "@/context/models" import { AmicodeDefaultModel } from "./amicode-default-model" +import { Icon } from "@opencode-ai/ui/icon" import { hpAfterConnect, hpClickAction, loadSolverMode, modeAfterDisconnect, saveSolverMode, + showConnectionDetails, solverConnectionDot, type SolverMode, } from "@opencode-ai/ui/amicode-solver-toggle" @@ -82,16 +84,25 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl pick("hp") // legacy seam: unwired hosts keep the old behavior return } - const action = hpClickAction(mode(), dot()) - if (action === "activate") { + if (hpClickAction(dot()) === "activate") { pick("hp") - setComputeOpen(false) return } - // connect | manage — reveal the connection card inline; freshen the view + // not connected — reveal the connect card inline; freshen the view setComputeOpen(true) props.compute.refetch() } + // details (gear): the persistent way back to the connection card once a + // credential exists — connect success must never strand the user with no + // visible route to identity / revalidate / disconnect (Kate, 2026-07-22) + const onDetailsClick = () => { + if (computeOpen()) { + setComputeOpen(false) + return + } + setComputeOpen(true) + props.compute?.refetch() + } const submitCredential = async (payload: CredentialSubmitPayload) => { const result = await props.compute!.onSubmit(payload) if (hpAfterConnect(result)) pick("hp") @@ -247,53 +258,80 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl > Piccolo - + + + Piccolissimo + Altissimo + + {/* amicode pill spec (media/ui/atoms/pill.ts): round, currentColor + border, 10% currentColor tint — state lives in the text color. */} + + PRO + + + + + +
{/* aria-live: the card's state copy (validating → connected / error) announces to screen readers; focus lands here on open diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts index 30d21fa3d1..4235cc4acd 100644 --- a/packages/ui/src/amicode/connections.test.ts +++ b/packages/ui/src/amicode/connections.test.ts @@ -478,7 +478,7 @@ describe("driftCopy (170 AC4)", () => { describe("connectionTitle", () => { test("known products get names; unknown ids render verbatim", () => { - expect(connectionTitle("company-compute")).toBe("Company Compute") + expect(connectionTitle("company-compute")).toBe("Solver API key") expect(connectionTitle("pasqal-cloud")).toBe("Pasqal Cloud") expect(connectionTitle("(unknown)")).toBe("(unknown)") }) diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index f7a7619670..da14cf35fd 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -88,7 +88,10 @@ export const PASQAL_ID = "pasqal-cloud" /** Product names are not translated; ids without one render verbatim. */ export function connectionTitle(id: string): string { - if (id === COMPANY_COMPUTE_ID) return "Company Compute" + // amicode#200 (Kate): one credential, one service — present it as what it + // is (the API key that unlocks the cloud solvers), not a separate product. + // The wire id stays "company-compute": server contract, not presentation. + if (id === COMPANY_COMPUTE_ID) return "Solver API key" if (id === PASQAL_ID) return "Pasqal Cloud" return id } diff --git a/packages/ui/src/amicode/solver-toggle.test.ts b/packages/ui/src/amicode/solver-toggle.test.ts index 0dae8cae91..a9656de7c7 100644 --- a/packages/ui/src/amicode/solver-toggle.test.ts +++ b/packages/ui/src/amicode/solver-toggle.test.ts @@ -30,7 +30,13 @@ describe("solver mode persistence", () => { }) // ── amicode#200: the solver toggle owns the Company Compute connection ────── -import { solverConnectionDot, hpClickAction, hpAfterConnect, modeAfterDisconnect } from "./solver-toggle" +import { + solverConnectionDot, + hpClickAction, + hpAfterConnect, + modeAfterDisconnect, + showConnectionDetails, +} from "./solver-toggle" import type { ConnectionView } from "./connections" const view = (state: ConnectionView["state"]): ConnectionView => ({ @@ -53,11 +59,15 @@ describe("solver connection dot (#200 AC1)", () => { }) describe("HP segment click routing (#200 AC2/AC3)", () => { - test("unconnected always routes to connect; connected activates, or manages when already hp", () => { - expect(hpClickAction("piccolo", "none")).toBe("connect") - expect(hpClickAction("hp", "attention")).toBe("connect") - expect(hpClickAction("piccolo", "connected")).toBe("activate") - expect(hpClickAction("hp", "connected")).toBe("manage") + test("unconnected always routes to connect; connected activates", () => { + expect(hpClickAction("none")).toBe("connect") + expect(hpClickAction("attention")).toBe("connect") + expect(hpClickAction("connected")).toBe("activate") + }) + test("details affordance shows whenever a credential exists (connected or troubled)", () => { + expect(showConnectionDetails("connected")).toBe(true) + expect(showConnectionDetails("attention")).toBe(true) + expect(showConnectionDetails("none")).toBe(false) }) }) diff --git a/packages/ui/src/amicode/solver-toggle.tsx b/packages/ui/src/amicode/solver-toggle.tsx index 47cb3b7d65..93ff633f93 100644 --- a/packages/ui/src/amicode/solver-toggle.tsx +++ b/packages/ui/src/amicode/solver-toggle.tsx @@ -43,14 +43,21 @@ export function solverConnectionDot(view: ConnectionView | undefined): SolverCon return "attention" } -/** What an HP-segment click does: activate the mode (connected, not yet hp), - * open the connect form (anything not connected — AC7: never activate - * unconnected), or open management (already hp and connected). */ -export type HpClickAction = "activate" | "connect" | "manage" +/** What an HP-radio click does: activate the mode (connected), or open the + * connect form (anything else — AC7: never activate unconnected). Management + * lives behind the always-visible details affordance, not a hidden + * second-click on the radio (Kate's test feedback, 2026-07-22). */ +export type HpClickAction = "activate" | "connect" -export function hpClickAction(mode: SolverMode, dot: SolverConnectionDot): HpClickAction { - if (dot !== "connected") return "connect" - return mode === "hp" ? "manage" : "activate" +export function hpClickAction(dot: SolverConnectionDot): HpClickAction { + return dot === "connected" ? "activate" : "connect" +} + +/** The details (gear) affordance shows whenever a credential exists to + * inspect — connected or in trouble. No key yet → the radio itself is the + * connect entry, and a details button would open an empty card. */ +export function showConnectionDetails(dot: SolverConnectionDot): boolean { + return dot !== "none" } /** AC2: HP flips on only when a credential submit LANDS connected. */ @@ -84,7 +91,7 @@ export function AmicodeSolverToggle() { return (
Date: Wed, 22 Jul 2026 16:32:07 -0400 Subject: [PATCH 05/11] ux(amicode): kebab inside the HP row's border, not a gear beside it (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's review: the details affordance is a three-dot kebab (inline vector, currentColor — the sprite has no ellipsis glyph yet) and it lives WITHIN the radio's border. The row is now one bordered control with two borderless click zones: select (flex) and details (trailing kebab). Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index cb2962b2da..15b490a52b 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -1,7 +1,6 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" import { useModels } from "@/context/models" import { AmicodeDefaultModel } from "./amicode-default-model" -import { Icon } from "@opencode-ai/ui/icon" import { hpAfterConnect, hpClickAction, @@ -258,7 +257,13 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl > Piccolo -
+ {/* One bordered control (Kate, 2026-07-22): the row wears the radio + border; select and details are two borderless click zones inside + it, so the kebab lives WITHIN the component, not beside it. */} +
From 2f39ab1b1ae5c3bff923837c79ee87d94056d4a8 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 16:45:35 -0400 Subject: [PATCH 06/11] ux(amicode): vertical kebab on the HP row (amicode#200) Co-Authored-By: Claude Fable 5 --- packages/app/src/components/amicode-defaults-capsule.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 15b490a52b..7e1a39e359 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -349,9 +349,9 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl {/* kebab — no ellipsis glyph in the sprite yet; inline vector, currentColor so it themes with the row states */} From e7ad09b8d1f783a1d7f8925213fa36a6ba92f653 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 16:53:00 -0400 Subject: [PATCH 07/11] =?UTF-8?q?ux(amicode):=20HP=20row=20is=20one=20acco?= =?UTF-8?q?rdion=20=E2=80=94=20the=20API-key=20card=20expands=20inside=20i?= =?UTF-8?q?ts=20border=20(amicode#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's review: the row and the card read as two separate components. The control is now a single bordered accordion: header (select + kebab zones) on top, the connection card expanding below a hairline divider, all inside one border. Active tint stays on the header only so the card area stays readable. Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 74 ++++++++++++------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 7e1a39e359..a35f03677f 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -257,13 +257,32 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl > Piccolo - {/* One bordered control (Kate, 2026-07-22): the row wears the radio - border; select and details are two borderless click zones inside - it, so the kebab lives WITHIN the component, not beside it. */} + {/* One bordered control (Kate, 2026-07-22): a single accordion — + header row (select + kebab click zones) and the API-key card + expand/collapse INSIDE the same border, split by a hairline. */}
+
-
- - {/* aria-live: the card's state copy (validating → connected / - error) announces to screen readers; focus lands here on open - so keyboard users reach the form without tabbing blind. - The dot on the radio is color-only — its aria-label carries - the state in text (WCAG color-not-only). */} -
setTimeout(() => el.focus(), 0)} - style={{ - "margin-top": "4px", - border: "1px solid var(--v2-border-border-base)", - "border-radius": "7px", - padding: "4px 2px", - outline: "none", - }} - > +
+ + {/* aria-live: the card's state copy (validating → connected / + error) announces to screen readers; focus lands here on open + so keyboard users reach the form without tabbing blind. + The dot on the radio is color-only — its aria-label carries + the state in text (WCAG color-not-only). */} +
setTimeout(() => el.focus(), 0)} + style={{ + "border-top": "1px solid var(--v2-border-border-base)", + padding: "4px 2px", + outline: "none", + }} + > } @@ -390,9 +407,10 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl onRevalidate={props.compute!.onRevalidate} /> )} - -
-
+
+
+ +
From 0cee64c31b5ac1f7b8a7dce9ba30a46db7a41e41 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 17:00:00 -0400 Subject: [PATCH 08/11] ux(amicode): accordion loses the divider; status copy is the card's only heading (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's review: no hairline between the header row and the expanded card, and no 'Solver API key' title inside it — the accordion header already names the thing; the connection STATUS is the first and only text. The shared card grows a hideHeader prop (capsule mount only; the Connections tab keeps its titled cards). Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 4 +- packages/ui/src/amicode/connections-tab.tsx | 51 +++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index a35f03677f..0999ba7c7d 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -388,8 +388,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl tabIndex={-1} ref={(el) => setTimeout(() => el.focus(), 0)} style={{ - "border-top": "1px solid var(--v2-border-border-base)", - padding: "4px 2px", + padding: "0 2px 4px", outline: "none", }} > @@ -405,6 +404,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl onSubmit={submitCredential} onDisconnect={disconnectCompute} onRevalidate={props.compute!.onRevalidate} + hideHeader /> )} diff --git a/packages/ui/src/amicode/connections-tab.tsx b/packages/ui/src/amicode/connections-tab.tsx index 60cdf3219c..ac65e670e2 100644 --- a/packages/ui/src/amicode/connections-tab.tsx +++ b/packages/ui/src/amicode/connections-tab.tsx @@ -150,6 +150,10 @@ export function ConnectionCard(props: { 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) @@ -206,28 +210,33 @@ export function ConnectionCard(props: { return (
-
- } - > - - - {connectionTitle(props.conn.id)} - - - {props.conn.rawState} - - -
- - - {props.conn.validatedAt} - - -
+ +
+ } + > + + + {connectionTitle(props.conn.id)} + + + {props.conn.rawState} + + +
+ + + {props.conn.validatedAt} + + +
+ -
+
{stateCopy(props.conn, props.labels.states)} · {props.conn.identity} From 6310808755d4ebef1599ef018102fd557ef0f59f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 17:04:39 -0400 Subject: [PATCH 09/11] ux(amicode): status dot accompanies the status text, not the solver name (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's review: the color indicator belongs to the connection status line — same idiom as the Connections tab, where the dot leads the card's first line. The accordion header drops its dot (state still announced via aria-label/title, still queryable via data-dot); the headerless card's status line gains the dot/spinner. Co-Authored-By: Claude Fable 5 --- .../app/src/components/amicode-defaults-capsule.tsx | 10 ++++------ packages/ui/src/amicode/connections-tab.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 0999ba7c7d..252bae57e2 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -49,11 +49,10 @@ export type AmicodeComputeControl = { refetch: () => void } -const DOT_CLASS: Record, string> = { - connected: "bg-icon-success-base", - attention: "bg-icon-warning-base", - none: "bg-border-weak-base", -} +// The status dot lives on the connection card's STATUS line (Kate: the dot +// accompanies "not connected…", never the solver name — same idiom as the +// Connections tab, where the dot leads the card's first line). The header +// row signals state non-visually via aria-label/title and data-dot. // amicode#200 AC6: the Connect Cloud palette command deep-links here. The // extension posts an "open-compute-connect" envelope through the webview @@ -321,7 +320,6 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl : "Runs in the cloud — click to add your API key" } > - Piccolissimo + Altissimo diff --git a/packages/ui/src/amicode/connections-tab.tsx b/packages/ui/src/amicode/connections-tab.tsx index ac65e670e2..53136f1b17 100644 --- a/packages/ui/src/amicode/connections-tab.tsx +++ b/packages/ui/src/amicode/connections-tab.tsx @@ -234,9 +234,17 @@ export function ConnectionCard(props: {
+ + } + > + + + {stateCopy(props.conn, props.labels.states)} · {props.conn.identity} From 52d168e694601d7a03c4412313480ea7efb23a27 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 17:10:23 -0400 Subject: [PATCH 10/11] ux(amicode): trim needs-key copy in the capsule; accent border marks the in-flight switch (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capsule mount overrides the needs-key state label to bare 'Not connected' — the form directly below IS the guidance; the Connections tab keeps the full copy. - while the connect/manage flow is open the accordion wears the accent border: 'the choice is moving here'. Selection truth is unchanged — Piccolo stays active until the credential lands, then the flip is the success feedback (HP cannot be selected keyless by construction). Co-Authored-By: Claude Fable 5 --- .../app/src/components/amicode-defaults-capsule.tsx | 12 +++++++++--- packages/app/src/pages/home.tsx | 8 +++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 252bae57e2..5d5702a0e9 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -262,9 +262,15 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
true) const computeControl: AmicodeComputeControl = { view: () => chromeConnections.connectionsView()?.connections.find((c) => c.id === COMPANY_COMPUTE_ID), - labels: chromeConnections.connectionsLabels, + // capsule mount: the connect form sits directly under the status line, so + // the "— enter a key to connect" guidance is redundant there (Kate). The + // Connections tab keeps the full copy. + labels: () => { + const base = chromeConnections.connectionsLabels() + return { ...base, states: { ...base.states, "needs-key": "Not connected" } } + }, actionError: chromeConnections.connectionsActionError, onSubmit: chromeConnections.onSubmitCredential, onDisconnect: chromeConnections.onDisconnectConnection, From 23919d506e271d0fdb9cfdda246aae9c1cce92c6 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 17:18:33 -0400 Subject: [PATCH 11/11] ux(amicode): accordion dismisses like a disclosure; kebab is the permanent handle (amicode#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's review: clicking Piccolo or anywhere in the popover outside the HP accordion collapses the card, and the kebab renders in ALL states — the one predictable open/close control (radios choose, kebab discloses, clicking away dismisses). The credential-gated showConnectionDetails contract is retired: the keyless card is the connect form, never empty. Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 18 ++++++++++++++---- packages/ui/src/amicode/solver-toggle.test.ts | 13 +------------ packages/ui/src/amicode/solver-toggle.tsx | 8 -------- .../src/components/amicode-solver-toggle.tsx | 1 - 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 5d5702a0e9..eae964f9eb 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -7,7 +7,6 @@ import { loadSolverMode, modeAfterDisconnect, saveSolverMode, - showConnectionDetails, solverConnectionDot, type SolverMode, } from "@opencode-ai/ui/amicode-solver-toggle" @@ -120,12 +119,19 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl const hp = () => mode() === "hp" let root: HTMLDivElement | undefined + let hpRow: HTMLDivElement | undefined const close = () => { setOpen(false) setComputeOpen(false) } const onDocPointer = (e: PointerEvent) => { - if (root && !root.contains(e.target as Node)) close() + if (root && !root.contains(e.target as Node)) { + close() + return + } + // inside the popover but outside the HP accordion → collapse the card + // (Kate: the disclosure dismisses like any other; the kebab reopens it) + if (computeOpen() && hpRow && !hpRow.contains(e.target as Node)) setComputeOpen(false) } const onDocKey = (e: KeyboardEvent) => { if (e.key === "Escape") close() @@ -252,7 +258,10 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl data-slot="amicode-solver-piccolo" aria-pressed={!hp()} style={radio(!hp())} - onClick={() => pick("piccolo")} + onClick={() => { + pick("piccolo") + setComputeOpen(false) + }} > Piccolo @@ -260,6 +269,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl header row (select + kebab click zones) and the API-key card expand/collapse INSIDE the same border, split by a hairline. */}
- +