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
32 changes: 24 additions & 8 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,14 @@
"packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637",
"packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03",
"packages/app/src/components/external-link.tsx": "7130044026ad3ec417de4ba7389cc6059de46f21b370047046269c772aa7a4bb",
"packages/app/src/components/harness-switch-banner.tsx": "aba66bdbcdeb0e674f07e7f22827b509a66fae2a2e5fbc8eb12e1d54baa311a0",
"packages/app/src/components/harness-switch.test.ts": "db263ece42911e756fb8b6e88697a6bbbd0d1317decd1e63b027d9fe8c69b405",
"packages/app/src/components/harness-switch.ts": "b4cb2b2241d71c477be71285fe6041d4b0d604a26d74c30dc5239694211a1f90",
"packages/app/src/components/help-button.tsx": "780d533f776a784660cfed072a131198bc6fcc8e8fe332e8e1a220d516c9684e",
"packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e",
"packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8",
"packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da",
"packages/app/src/components/prompt-input-v2.tsx": "5ab46e41bda4fa11e99f06c37df76c1fd3cce061c6ba7a9f85bee369a965b98a",
"packages/app/src/components/prompt-input-v2.tsx": "7f3677740abfd76c0884c824523ab5f385bab9377b345477794f04276d618f40",
"packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4",
"packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294",
"packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd",
Expand Down Expand Up @@ -210,7 +213,7 @@
"packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850",
"packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5",
"packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa",
"packages/app/src/pages/layout-new.tsx": "3ef477634cb6d7de0c32d3572c5ff699e07efff47e6e75458583456eb1ff8b27",
"packages/app/src/pages/layout-new.tsx": "8e1c643a47182db5c2bb7b0f6248196ca71e38394c5fe02dbe7f133beab32171",
"packages/app/src/pages/layout.tsx": "eaf8bed869b5ec53582db732119fb903b223d749ebda354b1e388de3be02dc52",
"packages/app/src/pages/new-session-landing.test.ts": "95e2780456a653a71e6f66b4302ae546e308a29e6b8ad9e84442938a3a226905",
"packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268",
Expand Down Expand Up @@ -332,6 +335,7 @@
"packages/opencode/test/server/amicode-credentials.test.ts": "ba77d064fd2be799930524510ce6e32c6f6a0d144d3cdf41673a8283dbe95bf6",
"packages/opencode/test/server/amicode-dashboard.test.ts": "e25b284b9eb434da7f07dfa8b72b7919a0cd1ffaed275a6725d9cd44cd1298c0",
"packages/opencode/test/server/amicode-file-resolve.test.ts": "39db84780125c8fd4551137e4f6a0ea6b0a9dce45b04e26ed15f649efe251311",
"packages/opencode/test/server/amicode-harness.test.ts": "96cc4f3e07b80624eb286a405839ab7469ee5244987a0a33235fbc063914b1a2",
"packages/opencode/test/server/amicode-library.test.ts": "87b986c43390e1f94d6bcded0bb5d22be24beaab2c336773bb438ed1fc6a2d95",
"packages/opencode/test/server/amicode-problems.test.ts": "4a8a9011f3796df4d6cde9999b86b507a8126fdc9138cc3ba305f930968c3392",
"packages/opencode/test/server/amicode-profile.test.ts": "5e5dcfd2b3db5cb8d7aba2cf1f74ed2d76716af6e4f761f54b94e77174b314de",
Expand Down Expand Up @@ -617,6 +621,9 @@
"packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914",
"packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65",
"packages/app/src/components/prompt-input/context-items.tsx": "027b02e394ccd14849052bf3fcacdae1e090c71d164fa8955addfe3f7abf1758",
"packages/app/src/components/prompt-input/harness-control.tsx": "c2fb0a672498fe4809cf88ddb3b52c1d04ddcc3d3b710cf40acb6a1dab212f9d",
"packages/app/src/components/prompt-input/harness.test.ts": "35739529b06829dd9fa169209c54ae668e0fc06d6968b00cfce638a221ce1361",
"packages/app/src/components/prompt-input/harness.ts": "8ae6a783c18a906383061cb1254101015f025080b39be3786c27be9bcd0ba9d1",
"packages/app/src/components/prompt-input/image-attachments.tsx": "aa451ed6aa61a68ba0e8dacd8c45137f84c62566797a5242b75a89487ef0e9bf",
"packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3",
"packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d",
Expand Down Expand Up @@ -717,6 +724,7 @@
"packages/opencode/src/server/amicode/credentials.ts": "f3a15c82496f9a3b6e8c1006a6a4ab419a02156e9a93a43c6c2319632c30063f",
"packages/opencode/src/server/amicode/dashboard.ts": "031a4f40bfdc847984a1deecfefbe0fd27f42cbd1caf8823dc43c7eb5bcb9af0",
"packages/opencode/src/server/amicode/file-resolve.ts": "3f0548fc97aad431d3f7707fd0b911e66fb4fd68ac4f643da71a2f00a73beffe",
"packages/opencode/src/server/amicode/harness.ts": "510d2373c377395d509b84795da65005532a32c816b1fa5f7b76fb413be5ed95",
"packages/opencode/src/server/amicode/library.ts": "36013b19f7c57afaa1bab63e5ff34d79f09ec42640e2a8c4a40cb14c97c4b859",
"packages/opencode/src/server/amicode/pasqal-secret.ts": "a0b53498bb7415c96e14b435367ffa93bd3d298fe12f7584cd401e649312fd69",
"packages/opencode/src/server/amicode/problems.ts": "d56513cff90193ab436dcca379f065a4ce963eeb45c22178cf9a0151b49c9487",
Expand Down Expand Up @@ -847,8 +855,8 @@
"packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17",
"packages/sdk/js/src/v2/gen/types.gen.ts": "0c23b262085d1e14b89bd03292a380c9ec8b52e66c4308304a05173362b79339",
"packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea",
"packages/session-ui/src/v2/components/prompt-input/index.tsx": "194b1c6d0c99000579df78b47c7ff72fe34f62a92ecd21b2a9377133d6d27bb9",
"packages/opencode/src/server/routes/instance/httpapi/server.ts": "85c200797fed41c0cd08e7aa699ce13aeb8a3d23aa2b9eddc4d72e72b8b5082c",
"packages/session-ui/src/v2/components/prompt-input/index.tsx": "811ebe3d4f6ba6318d3bf3efbac678d4d0484f83929b8f8ae1b7af1bc29911f4",
"packages/opencode/src/server/routes/instance/httpapi/server.ts": "8a8e47c2242bc61be078c2fb97ecaa37ebe45cb410b95819102731b52f2b3c31",
"packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15",
"packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "ed165131c62eeb29375db39edf89db8f87488e24ef99fc6f2cf19705ba35751a",
"packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts": "048efa7b74d43c4ad4a75d0f8421c808760c8c0dcd593f0051a87044e40487d6",
Expand All @@ -873,10 +881,10 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-09-24T04:46:38.771Z",
"extracted_at": "2026-09-24T21:16:08.091Z",
"per_package": {
"packages/app": {
"A": 186,
"A": 192,
"M": 157,
"D": 0
},
Expand All @@ -891,7 +899,7 @@
"D": 0
},
"packages/opencode": {
"A": 69,
"A": 71,
"M": 81,
"D": 0
},
Expand Down Expand Up @@ -922,7 +930,7 @@
}
},
"counts": {
"overlay_total": 851,
"overlay_total": 859,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -1030,6 +1038,9 @@
"packages/app/src/components/dialog-select-directory-v2.css": "M",
"packages/app/src/components/dialog-select-file.tsx": "M",
"packages/app/src/components/external-link.tsx": "M",
"packages/app/src/components/harness-switch-banner.tsx": "A",
"packages/app/src/components/harness-switch.test.ts": "A",
"packages/app/src/components/harness-switch.ts": "A",
"packages/app/src/components/help-button.tsx": "M",
"packages/app/src/components/pane-bridge.tsx": "A",
"packages/app/src/components/profile-popover.tsx": "A",
Expand Down Expand Up @@ -1251,6 +1262,7 @@
"packages/opencode/test/server/amicode-credentials.test.ts": "A",
"packages/opencode/test/server/amicode-dashboard.test.ts": "A",
"packages/opencode/test/server/amicode-file-resolve.test.ts": "A",
"packages/opencode/test/server/amicode-harness.test.ts": "A",
"packages/opencode/test/server/amicode-library.test.ts": "A",
"packages/opencode/test/server/amicode-problems.test.ts": "A",
"packages/opencode/test/server/amicode-profile.test.ts": "A",
Expand Down Expand Up @@ -1536,6 +1548,9 @@
"packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A",
"packages/app/src/components/prompt-input/clipboard-bridge.ts": "A",
"packages/app/src/components/prompt-input/context-items.tsx": "M",
"packages/app/src/components/prompt-input/harness-control.tsx": "A",
"packages/app/src/components/prompt-input/harness.test.ts": "A",
"packages/app/src/components/prompt-input/harness.ts": "A",
"packages/app/src/components/prompt-input/image-attachments.tsx": "M",
"packages/app/src/components/prompt-input/placeholder.test.ts": "M",
"packages/app/src/components/prompt-input/placeholder.ts": "M",
Expand Down Expand Up @@ -1636,6 +1651,7 @@
"packages/opencode/src/server/amicode/credentials.ts": "A",
"packages/opencode/src/server/amicode/dashboard.ts": "A",
"packages/opencode/src/server/amicode/file-resolve.ts": "A",
"packages/opencode/src/server/amicode/harness.ts": "A",
"packages/opencode/src/server/amicode/library.ts": "A",
"packages/opencode/src/server/amicode/pasqal-secret.ts": "A",
"packages/opencode/src/server/amicode/problems.ts": "A",
Expand Down
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
Loading
Loading