diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 6a1bc0c167..bbe3ee737f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,6 @@ import "@/index.css" import * as Sentry from "@sentry/solid" +import { requestComputeConnect } from "@/components/amicode-defaults-capsule" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -235,7 +236,14 @@ function AmicodeThemeBridge() { const theme = useTheme() const onMsg = (e: MessageEvent) => { const d = e.data as { source?: string; kind?: string; colorScheme?: string } | undefined - if (d?.source !== "amicode" || d.kind !== "theme") return + if (d?.source !== "amicode") return + // amicode#200 AC6: the Connect Cloud palette command deep-links into the + // defaults capsule's compute-connect flow (consumed when home is showing). + if (d.kind === "open-compute-connect") { + requestComputeConnect() + return + } + if (d.kind !== "theme") return if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) } window.addEventListener("message", onMsg) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index 271d509d56..eae964f9eb 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, createEffect, 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,89 @@ 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 +} + +// 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 +// bridge; the app-shell listener calls requestComputeConnect() and the next +// mounted capsule opens straight into the connect flow. Timestamped so a +// request fired while another page was showing can't pop the popover minutes +// later (15s freshness window). +const [connectRequestAt, setConnectRequestAt] = createSignal(0) +export function requestComputeConnect() { + setConnectRequestAt(Date.now()) +} +const CONNECT_REQUEST_TTL_MS = 15_000 + +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 + } + if (hpClickAction(dot()) === "activate") { + pick("hp") + return + } + // 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") + 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,22 +119,49 @@ export function AmicodeDefaultsCapsule() { 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)) setOpen(false) + 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") setOpen(false) + if (e.key === "Escape") close() + } + const openPopover = () => { + if (open()) return + setOpen(true) + document.addEventListener("pointerdown", onDocPointer, true) + document.addEventListener("keydown", onDocKey, true) } const toggle = () => { - const next = !open() - setOpen(next) - if (next) { - document.addEventListener("pointerdown", onDocPointer, true) - document.addEventListener("keydown", onDocKey, true) - } else { + if (open()) { + close() teardown() + } else { + openPopover() } } + + // amicode#200 AC6: consume a pending Connect Cloud deep-link — open the + // popover directly into the compute connect flow. + createEffect(() => { + const at = connectRequestAt() + if (!at || !props.compute) return + setConnectRequestAt(0) + if (Date.now() - at > CONNECT_REQUEST_TTL_MS) return + openPopover() + setComputeOpen(true) + props.compute.refetch() + }) const teardown = () => { document.removeEventListener("pointerdown", onDocPointer, true) document.removeEventListener("keydown", onDocKey, true) @@ -141,34 +256,175 @@ export function AmicodeDefaultsCapsule() { - + + + + + + + {/* 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={{ + padding: "0 2px 4px", + outline: "none", + }} + > + } + > + {(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..61fafbe184 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,26 @@ 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), + // 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, + onRevalidate: chromeConnections.onRevalidateConnection, + refetch: chromeConnections.refetchConnections, + } let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", @@ -816,7 +838,7 @@ function HomeDesign() { vertical dropdown (amicode.css). Rendered ONCE, so each control keeps its own popover state across the reflow. */}
- +