From e79f1694ceaaf0e367f13056e220d24b2baa9e6c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 24 Sep 2026 17:17:55 -0400 Subject: [PATCH 1/3] feat(app): the harness switcher in the chat box (#1549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer gets a fourth select-slot — the harness — riding the solver-mode trio end to end: - engine: GET/POST /amicode/harness (server/amicode/harness.ts). The id allowlist IS the extension-published registry view (harness-options.json) — the engine never hardcodes harness identity; POST validates against it, no-ops when already current, and writes {harness, status:"switching"} to the ops dir (same loopback guard + fixed value-free refusals as the solver-mode flip). - extension: harness_switch.ts (the handshake reader/writer + the watcher, the solver watcher's poll+busy-latch discipline) and the registry layer (harness.ts): harnessMenu — the ONE serialization both fronts render — and decideHarnessSwitch — the ONE gate both consult (unknown/needs-setup/unentitled blocked with their reason; the harness.telaio read-side gate lives here, never as a spawn-time failure). The palette command now renders from the same menu and gates through the same decision; the watcher persists the setting, republishes the menu, restarts, and settles ready — a refused switch settles ready at the STILL-CURRENT harness, never a lie. - composer: the harness select (disabled-with-reason entries carrying the disclosure "switching harnesses switches session history" at the point of switching), harnessDegraded dimming of agent/model/variant under a non-opencode harness (Harness Contract v1 accepts none), and the harness switch banner narrating switching → restarting → ready. Under opencode, byte-for-byte unchanged when untouched: no published registry → the control renders nothing. --- packages/app-bundle/manifest.json | 32 +++- .../src/components/harness-switch-banner.tsx | 91 +++++++++ .../app/src/components/harness-switch.test.ts | 44 +++++ .../app/src/components/harness-switch.ts | 48 +++++ .../app/src/components/prompt-input-v2.tsx | 9 + .../prompt-input/harness-control.tsx | 125 ++++++++++++ .../components/prompt-input/harness.test.ts | 79 ++++++++ .../src/components/prompt-input/harness.ts | 80 ++++++++ .../packages/app/src/pages/layout-new.tsx | 5 + .../opencode/src/server/amicode/harness.ts | 158 +++++++++++++++ .../server/routes/instance/httpapi/server.ts | 18 ++ .../test/server/amicode-harness.test.ts | 180 ++++++++++++++++++ .../src/v2/components/prompt-input/index.tsx | 81 +++++--- packages/extension/src/extension.ts | 153 ++++++++++++--- packages/extension/src/harness.ts | 93 +++++++++ packages/extension/src/harness_switch.ts | 96 ++++++++++ packages/extension/test/harness.test.ts | 120 +++++++++++- .../extension/test/harness_switch.test.ts | 172 +++++++++++++++++ 18 files changed, 1518 insertions(+), 66 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/harness-switch-banner.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/harness-switch.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/harness-switch.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts create mode 100644 packages/app-bundle/overlay/packages/opencode/src/server/amicode/harness.ts create mode 100644 packages/app-bundle/overlay/packages/opencode/test/server/amicode-harness.test.ts create mode 100644 packages/extension/src/harness_switch.ts create mode 100644 packages/extension/test/harness_switch.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..b26f2b0d9 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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 }, @@ -891,7 +899,7 @@ "D": 0 }, "packages/opencode": { - "A": 69, + "A": 71, "M": 81, "D": 0 }, @@ -922,7 +930,7 @@ } }, "counts": { - "overlay_total": 851, + "overlay_total": 859, "deletions": 7, "server_coupled": 0 }, @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/packages/app-bundle/overlay/packages/app/src/components/harness-switch-banner.tsx b/packages/app-bundle/overlay/packages/app/src/components/harness-switch-banner.tsx new file mode 100644 index 000000000..f9e419f84 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/harness-switch-banner.tsx @@ -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() +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 ( + + {(text) => ( +
+