Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
652 changes: 341 additions & 311 deletions packages/app-bundle/manifest.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import {
harnessSwitchExpired,
harnessSwitchLabel,
harnessSwitchPhase,
type HarnessSwitchPhase,
} from "@/components/harness-switch"
import { useServerSDK } from "@/context/server-sdk"

// Amicode: the visible half of a harness switch (amicode#1549) — the
// solver-switch banner's twin for the composer's harness control. The switch
// is real: the extension watcher persists the setting and restarts the server
// underneath the webview; the SSE stream dies for those seconds and this
// banner is the only narration. Scope mirrors the solver twin: it speaks ONLY
// for a switch the app itself requested, so it cannot get stuck on a
// transient blip.

// Module-level: the call site (the composer's harness control) and the one
// banner in the layout live in different component trees.
const [target, setTarget] = createSignal<string | undefined>()
const [startedAt, setStartedAt] = createSignal(0)

/** Announce a switch the app just requested — `displayName` is the registry's
* name for the picked harness, so the narration names what was picked. This
* only mirrors the request; it never causes one. */
export function beginHarnessSwitch(displayName: string) {
setTarget(displayName)
setStartedAt(Date.now())
}

function endHarnessSwitch() {
setTarget(undefined)
setStartedAt(0)
}

export function HarnessSwitchBanner() {
const sdk = useServerSDK()
const [sawDrop, setSawDrop] = createSignal(false)
const [elapsed, setElapsed] = createSignal(0)

// One clock, alive only while a switch is outstanding: it drives the expiry
// check, which has nothing else to react to.
createEffect(() => {
if (!target()) {
setSawDrop(false)
setElapsed(0)
return
}
const timer = setInterval(() => setElapsed(Date.now() - startedAt()), 500)
onCleanup(() => clearInterval(timer))
})

// Latch the drop: once the server has gone down, coming back up is the
// switch completing rather than the request still waiting to be picked up.
createEffect(() => {
if (target() && sdk().event.status() === "disconnected") setSawDrop(true)
})

const phase = (): HarnessSwitchPhase =>
harnessSwitchPhase({
target: target(),
connected: sdk().event.status() === "connected",
sawDrop: sawDrop(),
})

createEffect(() => {
const current = phase()
if (current === "idle") return
// Hold the completed chip briefly — the only confirmation the user gets
// inside the app; the extension's toast lands outside the webview.
if (current === "ready") {
const done = setTimeout(endHarnessSwitch, 3000)
onCleanup(() => clearTimeout(done))
return
}
if (harnessSwitchExpired(current, elapsed())) endHarnessSwitch()
})

const label = () => harnessSwitchLabel(phase(), target())

return (
<Show when={label()}>
{(text) => (
<div data-component="amicode-harness-switch" data-phase={phase()} role="status" aria-live="polite">
<i aria-hidden="true" />
<span>{text()}</span>
</div>
)}
</Show>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import {
harnessSwitchPhase,
harnessSwitchExpired,
harnessSwitchLabel,
HARNESS_SWITCH_STALL_MS,
HARNESS_SWITCH_MAX_MS,
} from "./harness-switch"

// amicode#1549 — the harness banner's decision layer, the solver-switch
// twin's shape: pure helpers over two observable facts (a switch outstanding,
// has the stream dropped), so the phase contract is testable without a DOM.

describe("harnessSwitchPhase", () => {
test("no target → idle", () => {
expect(harnessSwitchPhase({ target: undefined, connected: true, sawDrop: false })).toBe("idle")
})

test("requested until the stream drops, restarting while down, ready on return", () => {
expect(harnessSwitchPhase({ target: "telaio", connected: true, sawDrop: false })).toBe("requested")
expect(harnessSwitchPhase({ target: "telaio", connected: false, sawDrop: false })).toBe("restarting")
expect(harnessSwitchPhase({ target: "telaio", connected: true, sawDrop: true })).toBe("ready")
})
})

describe("harnessSwitchExpired — never trap the user behind theater", () => {
test("requested stalls past the stall window; restarting past the ceiling", () => {
expect(harnessSwitchExpired("requested", HARNESS_SWITCH_STALL_MS + 1)).toBe(true)
expect(harnessSwitchExpired("requested", HARNESS_SWITCH_STALL_MS - 1)).toBe(false)
expect(harnessSwitchExpired("restarting", HARNESS_SWITCH_MAX_MS + 1)).toBe(true)
expect(harnessSwitchExpired("restarting", HARNESS_SWITCH_MAX_MS - 1)).toBe(false)
expect(harnessSwitchExpired("ready", Number.MAX_SAFE_INTEGER)).toBe(false)
})
})

describe("harnessSwitchLabel — honest narration, one name end to end", () => {
test("the labels narrate switching → restarting → ready", () => {
expect(harnessSwitchLabel("requested", "telaio (subscription)")).toBe("Switching to telaio (subscription)…")
expect(harnessSwitchLabel("restarting", "telaio (subscription)")).toBe("Restarting session server…")
expect(harnessSwitchLabel("ready", "telaio (subscription)")).toBe("telaio (subscription) ready")
expect(harnessSwitchLabel("idle", "telaio (subscription)")).toBeUndefined()
expect(harnessSwitchLabel("requested", undefined)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// amicode#1549 — the harness banner's decision layer, the solver-switch twin
// (solver-switch.ts) in shape and discipline: a switch is not instant and it
// is not quiet — the extension watcher sees {status:"switching"}, performs
// the registry-gated setting persist, and RESTARTS the server underneath the
// webview. For those seconds the SSE stream is simply gone; without a signal
// that reads as an upgrade, the drop reads as a fault. Like the solver twin,
// this speaks ONLY for a switch the app itself requested.
//
// Pure helpers so the phase contract is testable without a DOM.

export type HarnessSwitchPhase = "idle" | "requested" | "restarting" | "ready"

/** Phase from the two observable facts: is a switch outstanding, and has the
* stream dropped yet. `sawDrop` is latched by the caller — once the server
* has gone down, coming back up means "ready", not "still waiting to start". */
export function harnessSwitchPhase(input: {
target: string | undefined
connected: boolean
sawDrop: boolean
}): HarnessSwitchPhase {
if (!input.target) return "idle"
if (!input.connected) return "restarting"
return input.sawDrop ? "ready" : "requested"
}

/** The watcher polls harness.json every 1s, so a request that has not taken
* the server down well inside this window is not going to — abandon quietly
* rather than leave a permanent pill on screen (the solver twin's discipline). */
export const HARNESS_SWITCH_STALL_MS = 12_000
/** Total ceiling: never trap the user behind theater, however wedged the
* restart is. */
export const HARNESS_SWITCH_MAX_MS = 90_000

export function harnessSwitchExpired(phase: HarnessSwitchPhase, elapsedMs: number): boolean {
if (phase === "requested") return elapsedMs > HARNESS_SWITCH_STALL_MS
if (phase === "restarting") return elapsedMs > HARNESS_SWITCH_MAX_MS
return false
}

/** One name end to end — the banner narrates the displayName the registry
* published, so the banner and the select never disagree about what the user
* just picked. */
export function harnessSwitchLabel(phase: HarnessSwitchPhase, target: string | undefined): string | undefined {
if (!target || phase === "idle") return undefined
if (phase === "requested") return `Switching to ${target}…`
if (phase === "restarting") return "Restarting session server…"
return `${target} ready`
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
import { HarnessComposerControl } from "@/components/prompt-input/harness-control"
import { harnessDegraded, liveHarnessState } from "@/components/prompt-input/harness"

export type PromptInputV2ComposerProps = {
class?: string
Expand All @@ -64,6 +66,13 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
// amicode#1549: the harness switcher — a fourth select-slot riding the
// solver-mode trio. The control self-hides when no registry is
// published (stock-opencode behavior, unchanged); the degradation dim
// follows the serving harness's contract (telaio accepts no
// agent/model/variant).
harnessControl={<HarnessComposerControl />}
harnessDegraded={harnessDegraded(liveHarnessState())}
// amicode/opencode#116: report-a-bug, right-anchored immediately left
// of send. The boot-param gate lives HERE (not inside the button) so a
// gated-off button passes `undefined` and the row's layout never shifts.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { For, Show, createEffect } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { amicodeGet, amicodePost } from "@/utils/amicode-fetch"
import { showToast } from "@/utils/toast"
import { adoptHarnessState, liveHarnessState, parseHarnessState } from "@/components/prompt-input/harness"
import { beginHarnessSwitch } from "@/components/harness-switch-banner"

// amicode#1549 — the harness switcher in the chat box. A fourth composer
// select-slot riding the solver-mode trio: the state comes from the engine's
// GET /amicode/harness (the extension-published registry view — ONE registry,
// the same serialization the palette command renders); the write path is
// POST /amicode/harness, whose switching file the extension watcher performs.
// An unresolvable or unentitled harness renders disabled WITH its reason —
// never selectable-then-failing. No published registry (stock opencode, or
// the extension not yet booted) renders nothing at all.

/** The palette's disclosure, carried verbatim to the point of switching. */
const DISCLOSURE = "Sessions are harness-local — switching harnesses switches session history."

async function refreshHarnessState(server: ReturnType<typeof useServer>): Promise<void> {
try {
adoptHarnessState(parseHarnessState(await amicodeGet(server.current, "/amicode/harness")))
} catch {
// No answer (no extension yet, route absent) → the control stays hidden.
adoptHarnessState(undefined)
}
}

export function HarnessComposerControl() {
const server = useServer()
const sdk = useServerSDK()

// Refetch on server change and on every (re)connect — a harness switch
// restarts the server, so the post-restart reconnect is when the new
// current lands here.
createEffect(() => {
if (server.current) void refreshHarnessState(server)
})
createEffect(() => {
if (sdk().event.status() === "connected") void refreshHarnessState(server)
})

const onPick = async (id: string) => {
const state = liveHarnessState()
if (!state || id === state.harness) return
const conn = server.current
if (!conn) return
try {
const res = (await amicodePost(conn, "/amicode/harness", { harness: id })) as {
ok?: unknown
noop?: unknown
harness?: unknown
error?: unknown
}
if (res.ok !== true || typeof res.harness !== "string") {
showToast({
variant: "error",
title: "Amicode: harness switch failed",
description: typeof res.error === "string" ? res.error : "The switch could not be requested.",
})
return
}
if (res.noop === true) return
// Narrate the restart this is about to trigger — the extension watcher
// is the durable half; the banner carries the webview through the gap.
const picked = state.options.find((option) => option.id === res.harness)
beginHarnessSwitch(picked?.displayName ?? res.harness)
} catch (e) {
showToast({
variant: "error",
title: "Amicode: harness switch failed",
description: e instanceof Error ? e.message : String(e),
})
}
}

return (
<Show when={liveHarnessState()}>
{(state) => (
<TooltipV2 placement="top" gutter={4} value="Choose harness">
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={ButtonV2}
variant="ghost-muted"
size="normal"
class="max-w-[220px] justify-start ![font-weight:440]"
aria-label="Choose harness"
data-action="prompt-harness"
>
<span class="truncate leading-5">
{state().options.find((option) => option.id === state().harness)?.displayName ?? state().harness}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<IconV2 name="chevron-down" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup value={state().harness} onChange={(id: string) => void onPick(id)}>
<For each={state().options}>
{(option) => (
<MenuV2.RadioItem value={option.id} disabled={option.disabled} closeOnSelect>
{/* Live-test layout ruling: single-line entries, matching every other
composer select. The two-line flex-col entry fought the menu item's
own inline content slot (span[data-slot=menu-v2-item-content]) and
the texts overlapped; the disabled reason + the disclosure ride the
item's title tooltip instead of a second line. */}
<span class="truncate" title={option.disabled ? (option.reason ?? "Unavailable") : DISCLOSURE}>
{option.displayName}
<Show when={option.disabled}>
<span class="text-v2-text-text-faint"> — {option.reason ?? "Unavailable"}</span>
</Show>
</span>
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
)}
</Show>
)
}
Loading
Loading