diff --git a/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc b/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc index ec2460759..257508321 100644 Binary files a/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc and b/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc differ diff --git a/ops/hub/__pycache__/jev_placement.cpython-310.pyc b/ops/hub/__pycache__/jev_placement.cpython-310.pyc index abfaa70fd..aabdac10f 100644 Binary files a/ops/hub/__pycache__/jev_placement.cpython-310.pyc and b/ops/hub/__pycache__/jev_placement.cpython-310.pyc differ diff --git a/ops/hub/hub-frontdoor.py b/ops/hub/hub-frontdoor.py index 7fb01512c..440a00ea4 100644 --- a/ops/hub/hub-frontdoor.py +++ b/ops/hub/hub-frontdoor.py @@ -1606,10 +1606,17 @@ def _dechunk(buf, state): buf = buf[take:] state["size"] -= take if state["size"] == 0: - state["mode"] = "size" - if buf.startswith(b"\r\n"): buf = buf[2:] + # the CRLF closing a chunk can straddle reads: consume it + # in its own state, never as an (empty → unparseable) + # size line — that wedged the stream for good + state["mode"] = "crlf" continue break + if state["mode"] == "crlf": + if len(buf) < 2: break + if buf.startswith(b"\r\n"): buf = buf[2:] + state["mode"] = "size" + continue if state["mode"] == "done": break return out, buf @@ -1649,8 +1656,18 @@ def _resume_loop(self, c, shard_id, addr, cursor): # Byte-level edits (gap frames, synthetic heartbeats, swallowed # frames) are only valid on a stream we fully own. _head, _, _body = pre.partition(b"\r\n\r\n") - _dcst = {"mode": "size", "size": 0} - _clean, _rest = self._dechunk(_body, _dcst) + # de-chunk ONLY a chunked body (an identity-encoded engine body — + # HTTP/1.0, or a future non-chunked transport — parses as-is; the + # unconditional de-chunk dropped every frame of such a body). The + # unconsumed tail (a chunk-size line split across reads) carries + # into the next read instead of being discarded. + _dcst = {"mode": "size", "size": 0, + "chunked": b"transfer-encoding: chunked" in _head.lower()} + _pend = b"" + if _dcst["chunked"]: + _clean, _pend = self._dechunk(_body, _dcst) + else: + _clean = _body reasm += _clean frames, reasm = _parse_frames(reasm) with self.lock: @@ -1725,7 +1742,10 @@ def _resume_loop(self, c, shard_id, addr, cursor): if chunk is None: continue _hb_ticks = 0 - _dc_out, _rest = self._dechunk(chunk, _dcst) + if _dcst["chunked"]: + _dc_out, _pend = self._dechunk(_pend + chunk, _dcst) + else: + _dc_out = chunk reasm += _dc_out frames, reasm = _parse_frames(reasm) if b"server.cursor.unservable" in reasm or any(b"server.cursor.unservable" in f for f in frames): diff --git a/ops/hub/test_frontdoor.py b/ops/hub/test_frontdoor.py index 2b32b95f7..be4a6cdd2 100644 --- a/ops/hub/test_frontdoor.py +++ b/ops/hub/test_frontdoor.py @@ -2345,5 +2345,52 @@ def test_stalled_member_drop_does_not_wedge_the_group(self): "the surviving member must keep receiving (no wedged lock)") + +class TestResumeDechunk(unittest.TestCase): + """The resume path de-chunks the engine's chunked SSE body across + arbitrary read boundaries (83cd8afc follow-up). Group._dechunk is pulled + from the module source by AST — importing the module starts a server.""" + + @classmethod + def setUpClass(cls): + import ast, textwrap + src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "hub-frontdoor.py")).read() + for node in ast.walk(ast.parse(src)): + if isinstance(node, ast.FunctionDef) and node.name == "_dechunk" and node.args.args and node.args.args[0].arg == "buf": + ns = {} + exec(textwrap.dedent(ast.get_source_segment(src, node)), ns) + cls.dechunk = staticmethod(ns["_dechunk"]) + return + raise AssertionError("Group._dechunk not found") + + @staticmethod + def chunked(*payloads): + return b"".join(b"%x\r\n%s\r\n" % (len(p), p) for p in payloads) + + def feed(self, wire, cut): + st = {"mode": "size", "size": 0} + out, pend = b"", b"" + for i in range(0, len(wire), cut): + o, pend = self.dechunk(pend + wire[i:i + cut], st) + out += o + return out, pend + + def test_every_read_boundary_yields_the_exact_body(self): + frames = [b"id: 1\ndata: {\"a\":1}\n\n", b"id: 2\ndata: " + b"x" * 300 + b"\n\n", b": hb\n\n"] + wire = self.chunked(*frames) + for cut in range(1, len(wire) + 1): + out, _ = self.feed(wire, cut) + self.assertEqual(out, b"".join(frames), "read size %d" % cut) + + def test_a_split_size_line_is_carried_not_dropped(self): + wire = self.chunked(b"data: one\n\n", b"data: two\n\n") + cut = wire.index(b"\r\n", 5) + 3 # inside the second chunk's size line + st = {"mode": "size", "size": 0} + a, pend = self.dechunk(wire[:cut], st) + self.assertEqual(a, b"data: one\n\n") + self.assertTrue(pend, "the partial size line must come back as the pending tail") + b, _ = self.dechunk(pend + wire[cut:], st) + self.assertEqual(b, b"data: two\n\n") + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 542167f12..1900d95d6 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -8,7 +8,7 @@ "upstream_base_sha": "3104c1428ec91f809e5ab86631300de41eb6952e", "promoted_at": "2026-09-13T22:15:25.839Z", "files": { - "packages/app/CONTEXT.md": "69e73ecec62f730398e8f25071e736a58b2f7a1d9059b60788e9936051051f14", + "packages/app/CONTEXT.md": "ce2c2fa79b95f053055fa92889dfa4e5ad583416d5008dd7203fe160ab155ade", "packages/app/index.html": "fa38026be3b7e7ec38958a558d7b4d059140b2aff998c3ac862601543702584d", "packages/app/package.json": "3c9bf3d487052d4834b7fd3ce93fcf6b7fc358de92b8be4b0e84fc424a971f74", "packages/app/playwright.config.ts": "b7dae0b39248b4ddf7cba829af96ac7fe3cc919add9711ecb5292d9bd52e1805", @@ -25,7 +25,7 @@ "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", "packages/app/src/app-desktop-commands.test.ts": "1d2057f238dc14e4407ff5c4e53afe091784790bc0fd0dbfde0eacd91e2bd968", "packages/app/src/app.tsx": "6fe2c1136330b0b76beb10b15c0ca9df2c62877c98ba3eb4cb91844125916a82", - "packages/app/src/design-polish.css": "6dceafb232f7132be588debc33c913fa6773966d54d6bbf6a1496b7622adeacc", + "packages/app/src/design-polish.css": "e84c1bf92265a91daa573069422a7491bbc2175bda28be66eba90ae26963bfc2", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", @@ -52,7 +52,9 @@ "packages/core/test/session-runner.test.ts": "4cf0d4e3989a32b0c693473d4f94bf787bd964e79b312a7ee0032305503edb20", "packages/core/test/tool-question.test.ts": "4df41393f15104a1d3a9c3d06e30cef515108490b41fcf4d9a7d9399fd4ed945", "packages/llm/src/cache-policy.ts": "04c1fb5d3d061cfa95d04e54df0a1c2cdaafac921224242446eaa2968bff7857", + "packages/llm/src/provider-error.ts": "65b9124905f0755c1e368df5150aa211bea3cc29afb66845b5ceaccba10042dd", "packages/llm/test/cache-policy.test.ts": "e921095c4301440c786105063439e49b2fedbaf6d46f30457e0a96388d268f4b", + "packages/llm/test/provider-error.test.ts": "ac3bf099319acdc27ff47ea79fd74357d55e12ab95131a07a72a5a2a9bc0c08f", "packages/opencode/script/build.ts": "876e7d569aa71ce060c1d4b24c96e9bf13245cbf4ca743828d645560a95aa641", "packages/opencode/script/httpapi-exercise.ts": "772bd2cf5748ca90c5d5790c2d044237518cc8ede0411fc4ba28b9d7440ba28c", "packages/opencode/test/amicode-release-workflow.test.ts": "f6dfcb3ed9c572ca8e8b21ff82e038872b635b25c68f9580f6d6f51ee2813189", @@ -107,7 +109,7 @@ "packages/app/src/components/amicode-default-model.tsx": "16a2de1feb12e07416fe5fbb25879391f912ee0909b553c868c1b531f2ed1f2d", "packages/app/src/components/amicode-defaults-capsule.tsx": "94dc081bc47abb7e50566fcd8e9e53124f9d7632d61f93eff4f1627e0c2efb2d", "packages/app/src/components/amicode-file-ref-bridge.tsx": "11c17d7fdb29d16530e188b13ee8af728f37d9adfbb518084106ce50cc0c527a", - "packages/app/src/components/connection-banner.tsx": "c793966e1f9d6f307af697032b235c2ff65afaa04f6cc232e40ce45e5745ded4", + "packages/app/src/components/connection-banner.tsx": "82194291181165ed7b1b1b3a6801a1bdcd2c2b7cdd733c28486bd69f86477a24", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", "packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts": "7c16914afe3e77ad09691bbfa8223981eda0c2faf3616ed6db2e4e5fa9ce9fdd", @@ -115,7 +117,7 @@ "packages/app/src/components/dialog-connect-provider.tsx": "6eb784f06171e3c22d934fd170199b6b1020295a365b669aa8581c86b464b77d", "packages/app/src/components/dialog-edit-project-v2.tsx": "f34aaed28604facd9d8a46a4934e2b331421d5f60902a2b160ddcec11af03bd9", "packages/app/src/components/dialog-edit-project.tsx": "5371da3de3a643d4347629af55c0adc2476bffe7b2f061535a92fd67e756e562", - "packages/app/src/components/dialog-new-project.tsx": "102fc33e8eeaf3c76ddd1b953b7b97995d30c02a3579f8b7036e47448a1582a8", + "packages/app/src/components/dialog-new-project.tsx": "acc359b435e1bec55b9bb0c4926551b9aabf1ab3150c6b018ddd1d3ba6465f34", "packages/app/src/components/dialog-release-notes.tsx": "0ea5b049786787643a7f6b54d4ccf84c4f8ffa4e8272426c0d32f60d445b6890", "packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637", "packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03", @@ -149,12 +151,12 @@ "packages/app/src/components/settings-providers.tsx": "c86c9bf4a6f6e198dc7b0995a69d5b76e851144987bbe601802b1b0120ee4ac4", "packages/app/src/components/solver-switch-banner.tsx": "ec7edeec6dc05a4a06b39241b4f3a8e7534da97722dd8ec6f26ff782025802e9", "packages/app/src/components/split-frame.tsx": "44c53737f72027b461097c03df48a4f411d04a82d02b1fce4901813c73dfcc72", - "packages/app/src/components/status-popover-body.tsx": "c7f5cce9066a6388de8531156a300503ead6f596cad2ea4269d64c421a97d0c5", - "packages/app/src/components/status-popover-indicator.test.ts": "9afc7b49d9021a94d74f3f777178230ae13bd26e5749da0cee4d3378eacacacd", - "packages/app/src/components/status-popover-indicator.ts": "887b3e659ff49fd0d2a8715fad6562dc7e9402b80d06442058502306bdb108f5", + "packages/app/src/components/status-popover-body.tsx": "2b6aa39a4358bbce7b6836b6a719be8eb24db96cd603bc0b4fb9fb0365413fed", + "packages/app/src/components/status-popover-indicator.test.ts": "e3530caa3a46b822603a8d0a753550605951c2cc5e95c60c46636935f2398a7a", + "packages/app/src/components/status-popover-indicator.ts": "d705c258e3404567fa5ae6d8ce9c27010832f12fdd614cef21f0297154e7faae", "packages/app/src/components/status-popover-model.test.ts": "10634035bab0f31de24564e0d6c8413a0f7434fb2c08ac3ef722981a69dd08e7", "packages/app/src/components/status-popover-model.ts": "ec5a5a3caf9344fe617c7bc668af81d96ad4290378c34186cd4cd959821f1c85", - "packages/app/src/components/status-popover.tsx": "520c9e33b072cca1470508531eba194c690f0be86028f1b5abcbad0fe591bd5e", + "packages/app/src/components/status-popover.tsx": "f712c9b54b0ce63e2020a22bdb55bcc104e49ef41d4d5aea216b22900a3392ef", "packages/app/src/components/terminal.tsx": "2ce242034ae129c1f475c7a24fdec3facdd9bb1f49ba8c6b503643b07ae241e5", "packages/app/src/components/titlebar-channel.test.ts": "184238871c46f586dcc21081f710e37a77914c983fccf250665c7317212e41d8", "packages/app/src/components/titlebar-channel.ts": "385ace91d3c69c92f5b442bf15d2040e35df855f1d4ece47312c1ff8e1b01780", @@ -200,8 +202,8 @@ "packages/app/src/context/server-sdk.tsx": "9dc721c54074a323fb879aedf76cf29c1888088ab6b9b0c68aae683ca4f563f1", "packages/app/src/context/server-session-child-active.test.ts": "de8c467f67f88b6cce351c1805ec90728cf238eb1ef32f39ffdbbf3de4dcec22", "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", - "packages/app/src/context/server-session.test.ts": "31b14c85a843eb172899f456566ad7ab7d07a22eaf8b31d29a22e255e95cb96b", - "packages/app/src/context/server-session.ts": "9fbb312e7183ca0f1266ee13b59fb8545a64a8e9539645790c8969e7bdf4053b", + "packages/app/src/context/server-session.test.ts": "b332098635885211bccaec0d7da8ca6b60696b04bd3f89ba3fcd97cd4c881856", + "packages/app/src/context/server-session.ts": "ad486fc5d36f93f5f53d51b70fdf191a3008b67f2b280dfc58b64e6d8d20982d", "packages/app/src/context/server-sync.tsx": "55ac830560ddd947b5d58a9c496a4e94ffc63fd7241c74ba9b843aea724b74ce", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/session-mirror.test.ts": "65e6d6474e4e67f2da194047140c8c8aac04fe0cc9926441468e53fabc48db96", @@ -355,7 +357,7 @@ "packages/opencode/src/project/instance-context.ts": "65a35792e50e96a50ea14cd9f928045c0e2fc15eb49506f5bbceee38c4af1457", "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", - "packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f", + "packages/opencode/src/provider/error.ts": "d72310be26cfae5c2943f9939a6461e848532a5646ff405d628e9a50fc1ebe91", "packages/opencode/src/provider/provider.ts": "b89bc0ca0a12ea0cfaf5c956a4b731a0397d4c407e787a16cdd66fb7f54ee476", "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", "packages/opencode/src/question/index.ts": "8317348c2ca27dd9e4f7d18fdc126a4d355c20495c1b86668430ad477a68e9b3", @@ -423,7 +425,7 @@ "packages/opencode/test/server/amicode-vault-browser.test.ts": "03347a6a9b6ba250b7a1150449cbe44b8817dbc0ebc433c89a703f4818492a5a", "packages/opencode/test/server/amicode-vaults.test.ts": "3f7c8ecfdc250ff360c0ed66d9cc6354d9069e66001281b3f6e30981b9f71c35", "packages/opencode/test/server/amicode-warrants.test.ts": "a7ad74da87f77f4488f334debf8c126671d563839891eb3a19b3d1a134dbc504", - "packages/opencode/test/server/amicode-widget-frame.test.ts": "5246d63240eae3390c8ba7f14950c41d546d6936cbe64df35da503febec72a1f", + "packages/opencode/test/server/amicode-widget-frame.test.ts": "a0374ab77ea5b39d8a7cbe9985537045506fd233299cfae081920714ca184e5f", "packages/opencode/test/server/amicode-widget-manifest.test.ts": "ac404d1737bdde8ea3464cf0b5fa12f0a6f9e630a0698ca5fdec38449cb66d0f", "packages/opencode/test/server/amicode-widgets.test.ts": "9e7ad16922e59583c337ff9383e57988b8abdfc0836ec3280655196cd192e8e4", "packages/opencode/test/server/auth-accept-set.test.ts": "8fe698812ed9a809d21ace1a9751bd3f1976ab1e58ad5bda65dbfff58df8e3bc", @@ -448,6 +450,7 @@ "packages/opencode/test/server/session-diff-missing-patch.test.ts": "70526ca7fb86741843fd167f8f2f9c6f6d7e5e415a20a5448d17d24229bdd65b", "packages/opencode/test/server/session-diff-scoped.test.ts": "20ef80b92ad872573b371540575f028be8f1ad3c9f34d72f814b621e14e7a59e", "packages/opencode/test/server/sse-tick-telemetry.test.ts": "95738379df27d2da236f968a3d197cf27bcce17e8ec8ce1d288d4d2c1184d8ee", + "packages/opencode/test/server/stdout-channel.test.ts": "812080bb29d09008dacf29741e8f6f06b4ac9fe4623e6992a046313597c3dd1c", "packages/opencode/test/server/summary-diff-wire.test.ts": "080236ce0a7985195a8f385ae65311d63dc18826439a9729c7c87ce4f55efd55", "packages/opencode/test/server/tcp-keepalive.test.ts": "e5c26de10fe44fd6485e0f75f667b2662f69896135f014b0b86772a6ca48fff5", "packages/opencode/test/server/workspace-routing.test.ts": "2574731ac379c540f450edc15f8750ac5360e8c33b4a0dad7c61e8eb686137bb", @@ -455,7 +458,7 @@ "packages/opencode/test/session/business-record.test.ts": "7857094479408bcfd6ea8d9f5b425c6384bcf8490f930674bc1c203e82782d75", "packages/opencode/test/session/instruction.test.ts": "06e1b371a08c7d07ed7688b997eb43ca9957ccd07936f601797d7f04a2bf0ab7", "packages/opencode/test/session/llm-native-recorded.test.ts": "7139b58569af0172862af521e8f8d7a78132f40f06dcceb4baf8ca3a2f07d3bd", - "packages/opencode/test/session/message-v2.test.ts": "3a45b7b884a33ee48daf533ac84bf5c0b34d01845b8c1c4cc7e68de88b063ed1", + "packages/opencode/test/session/message-v2.test.ts": "90fbd916cea08ddb1de066dccf2677490b547afc5805aa36e55683b28fbb079f", "packages/opencode/test/session/mutation.test.ts": "ccb82b6858ac097d2af09a94497d05f6a0ff843b928a69e763fdb510905a10cd", "packages/opencode/test/session/processor-effect.test.ts": "51ebe1cb9b7b0e1d249472367491a1306b5add24ca8b04d5dc15b2b2fa1f1b56", "packages/opencode/test/session/prompt.test.ts": "e3d0748edb833640ef284857ae725e4b974843480d4128f56239e3fd1e6d481f", @@ -521,13 +524,16 @@ "packages/session-ui/src/context/data.tsx": "7b840b5bf6929445d21da2cbd8e8006316c6950a09d559ea77e8c397e728cd99", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", - "packages/ui/src/amicode/amico-presence.css": "b3f345b607e318dcfbefcdc677fe21cc7d387b9946b0f9d625b0a842fe7e31b9", - "packages/ui/src/amicode/amico-presence.stories.tsx": "6012506c946a18460f8a6105c5860a050e47e7708818b0408b516370f50b7094", + "packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6", + "packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2", + "packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf", + "packages/ui/src/amicode/amico-presence.css": "1effe22b91a512c46ba0b4f4231da4b7665fc00c2f3bd8e1e9e76c6b7532e44c", + "packages/ui/src/amicode/amico-presence.stories.tsx": "e5d95248b32f2aa2a3879c3695721d6bdca77d90f3229ce83d42e030631763bb", "packages/ui/src/amicode/amico-presence.test.ts": "c7ea8c840004d06e231a7368b3c10e11b7dd7efcbcaca4867b0f30167bd2e2bb", "packages/ui/src/amicode/amico-presence.ts": "b4708117445d3fa5cf9be8c9b2f51bfb1f6b01a76e2fac114cde93107a075e26", "packages/ui/src/amicode/amico-wave.stories.tsx": "fed064c5202bd772308e2c6165c9d63182661fe6ce590c75cac1fd0e9deadf94", "packages/ui/src/amicode/amico-wave.tsx": "478cd8b19a87679016d9dbb0aa494ba7d1f1ef67b3da1d09fb35d28e33de9017", - "packages/ui/src/amicode/amicode.css": "a7b8e410dad9ee66190cc31d4a4782efa44e8f759d89d701c7525e67002b98c8", + "packages/ui/src/amicode/amicode.css": "5ac35d2f3dd9d64f904e5da89ee361bc8f81a319308bd2686fcc71f2d175a561", "packages/ui/src/amicode/approval-bridge.test.ts": "5b5e5a41c827b7a3cdaf455dbf488cfa7b8d339f46e64f5a35a0ba9226eb0ebf", "packages/ui/src/amicode/approval-bridge.ts": "ceaa6024916794ece1bc80a004b79c36406d2bc512f82e003c25d919c9050e48", "packages/ui/src/amicode/approval-card.tsx": "f55bd13bfded569020ad0ce0d385da3018aee8e833ec573cb45447114658a11b", @@ -544,10 +550,10 @@ "packages/ui/src/amicode/bridge.ts": "e7b93a15060a0cb1ab0be2b367f73e926d74b990a8af917a9966b451ee303b5b", "packages/ui/src/amicode/card.stories.tsx": "b1591abb4af426b0eb08588ea1585336a392d755b5d308cb31f17cb65acf5a3e", "packages/ui/src/amicode/card.tsx": "1f462017d9113082bb2e802e3d812b25cf5ee9b1bd848559e0533de4d382517f", - "packages/ui/src/amicode/connection-icon.tsx": "2b36c05209a3b5091c6407d59add58fb51ec34724ae6411af85c3dcc797a9987", + "packages/ui/src/amicode/connection-icon.tsx": "f0ffda2ebae57a2945a60cd278b5aa3986273b0cabe97ed46f417c037d167879", "packages/ui/src/amicode/connection-picker.tsx": "73293d1e159dab94a6d1600844fa1e47810ce0558bda6ba4e4209f7e368e3aff", "packages/ui/src/amicode/connections-paths.test.ts": "07c86e8e04700cdbd55bff8db342a8400d85114d76bc73fee8205e9162953f2a", - "packages/ui/src/amicode/connections-tab.tsx": "06489a47bbd6d482ba4d2dc2cf4e9e8e9c648be774f78cd76e2abc87451116e5", + "packages/ui/src/amicode/connections-tab.tsx": "da11957bee28ff988427f9001be81716254bea218057d6f1ff5e7a2fce374446", "packages/ui/src/amicode/connections.stories.tsx": "fda54a41c4b46f5cab5278278f92c01d1fea70610f7b8641fa5855be6bd9d486", "packages/ui/src/amicode/connections.test.ts": "a718b1b698178087fdac5294c1661ca2eb200491e2a076465a09ab52c70b8910", "packages/ui/src/amicode/connections.ts": "89b6c99e991e1aa594768a6555f7f555782dd615c21132e6a688155431cc0dfd", @@ -590,7 +596,7 @@ "packages/ui/src/amicode/run-plot.ts": "b4bc50f279cb864f7720e56ba69b993c7a4e599a71047162173b1b93a63598ac", "packages/ui/src/amicode/run-series.test.ts": "2e7aec7cdf7eb056efbe52d180ff316482e3010e2442611daaf4bda444c82705", "packages/ui/src/amicode/run-series.ts": "c94496068ae05f4600b9dd9fab4247b678dae7aaba31ef48e4389b0e7fefc52c", - "packages/ui/src/amicode/run-window.tsx": "e8343faa72bf6e7545f48e47d2576cd781f038b3ccb448e25130a52df17df5f0", + "packages/ui/src/amicode/run-window.tsx": "60c71ddaf9161ceaeeba10c1838003731a288dc6d85473d2f8d17e34c771c289", "packages/ui/src/amicode/shell-row.test.ts": "48ec6f0786edf81f163e350904bc2a93b1a55375b8115e688237ce8c2f1a7e64", "packages/ui/src/amicode/shell-row.ts": "b39dc05d2b77ba6d44456248782b31f6219c891d56700203186a65ccb48319ad", "packages/ui/src/amicode/solver-switch.test.ts": "c9cc10d657d7adf0c388e56cb6058affed5a255bdeb9b8f2fea5c02f4daff1fe", @@ -608,7 +614,7 @@ "packages/ui/src/amicode/thinking.ts": "ce2529285621620df168eaaa845cd2957b4fe5e61729fdfa736a8702ccf059b7", "packages/ui/src/amicode/ui-bridge.ts": "352f862a7a29245b635257485c377e6ac240e32a9def21173365177fb544a93f", "packages/ui/src/amicode/upload.ts": "9a3a59541ba056a5069f5145cc10bbcc8d44caeb7cfce7a59d97415de2e4a149", - "packages/ui/src/amicode/vaults-tab.tsx": "9e93b7424f0d885250cf999a115b1801d5973f3fdb9095a5c29d74e7093af7e6", + "packages/ui/src/amicode/vaults-tab.tsx": "28be62b5776501c6ef283f2905913ab033977c1661b479339877869dea8cad46", "packages/ui/src/amicode/vaults.test.ts": "24826e6b8b083b0a7478e469f3d4fd7319c973da281cb569e3d885d83d3d46d4", "packages/ui/src/amicode/vaults.ts": "6250b08a8d6493bd42c5407bfcb581a300757b273a587247e3666ddbe25c00af", "packages/ui/src/amicode/wave-geometry.test.ts": "3a89a89310b8c9ce15b402ced6dec83d691bc4ccf50448f2a89e4c0ff1c80cc7", @@ -627,6 +633,7 @@ "packages/ui/src/amicode/widget-tokens.test.ts": "426064638b322a421f794bedf5dbf41ad75fc30b3da6e2d02dcf6ed2f85d5f45", "packages/ui/src/amicode/widget-tokens.ts": "397edcfa18882b2b6b6abef0a8783d72323f793e974d0bd440ca240a10b2f737", "packages/ui/src/components/amico-spinner.tsx": "75114d38aac924a37fee563e35375ebfd5e96cf3771690d01ad484f91df1c563", + "packages/ui/src/components/amicode-activity.tsx": "2924852f0dc23fe3c9b0c5962fbbd6218532d7f5d8bfd4f4ef1e4aa31b6d07dc", "packages/ui/src/components/amicode-approval-bridge.tsx": "ec853afaf3f7bad097532e4887d9d125e88326945a47229fbb0831670c7b2fad", "packages/ui/src/components/amicode-ask-bridge.tsx": "2ddd8ad2663d965e6c42f11cde28bc516e9e881c60dcc7e85443f8bea6de92ef", "packages/ui/src/components/amicode-ask.tsx": "096496c187b7e4def0230c59df2e83757d1e3ebf567d946095c3e81f6ce341f0", @@ -656,6 +663,8 @@ "packages/ui/src/components/amicode-widget-grid.tsx": "df589de093bca703e9fdbbffc6a13020e96ee0ef43e545718f91277ec0b7cd24", "packages/ui/src/components/brain-engine.tsx": "955723ad42c6abfb785313f62277143142f1248987c45da30eadaf28d5137072", "packages/ui/src/components/brain-ref.tsx": "c97a0ac0dad470c9b9e194d6ba94d1f1cfbb774bdbea3f798bece4a2a9968a60", + "packages/ui/src/components/card.css": "0f87ac777181fb1eebbdc9d4f4c658aeff83eb0bd1171b9ef909c64504b11332", + "packages/ui/src/components/card.tsx": "f7cc63c3a3ba8a2f31e06584fedcf42b9f2bf69c04aa1c830ed2d6a7dc6ef837", "packages/ui/src/components/context-menu.css": "7c701902c852f205a178ded4fe8c6181ba43cb4d1c42c6ad9a39ae368738aa1a", "packages/ui/src/components/context-menu.stories.tsx": "0542961bd9db8e191e122b901519452217a410ea5339e8d3ee09a73258ba59a9", "packages/ui/src/components/context-tree-data.tsx": "ca261e7a5eeed09130af14bb755fc2258833d2ac9efd995418cb32927de05d86", @@ -725,7 +734,8 @@ "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6", "packages/app/src/components/prompt-input/submit.ts": "cdb01cf2f2485fbbdb7ddc77dcdea99197d8c03bd302b8fe032bee8c15f525c3", - "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", + "packages/app/src/components/server/server-row.tsx": "caf3179cf0b34b00fcab6ed57a324ff1f90f571f604216c49164dc54bd9f12ab", + "packages/app/src/components/session/context-warning-banner.tsx": "c4ceb3f9589ebd21cc66e21cef209552fc5fcff6cd825da5069c2259c8d33e50", "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd", @@ -761,7 +771,7 @@ "packages/app/src/components/settings-v2/developer-tools-rebuild-flags.ts": "561a75fa456b832d4af6f782c7ecf5269ce768fc2adc59c15a79197a2dde84c0", "packages/app/src/components/settings-v2/developer-tools-request-state.test.ts": "29711b3bc17fddbb8d046e104bc78e775a9ec7c5aa0ee2e181fbcd8b2b5e2135", "packages/app/src/components/settings-v2/developer-tools-request-state.ts": "e9678ed32e7c2562b18143f39a51c0a94dbf0a0884d056653c6c77e4cd07a9bd", - "packages/app/src/components/settings-v2/developer-tools.tsx": "0e3a739a1ed3cb6311f93174080a3bce71219cee9bcaccd0719d3bf9e6932988", + "packages/app/src/components/settings-v2/developer-tools.tsx": "351b2696211fc67e31e1b7265c809de01c54312fb90c0eef50f95896a07684b2", "packages/app/src/components/settings-v2/dialog-settings-v2.tsx": "a1e1fb7eea7bbad7708b97a721b54a4897c8cb6f582b71d6bb7c8ca7f44930db", "packages/app/src/components/settings-v2/general.tsx": "2abf6c9c7b4b9bf020ed224dbc7fff4574db47711956fc08e1b8e84e781d395a", "packages/app/src/components/settings-v2/permission-scope-relocation.test.ts": "39539c4fc4b7433f08b913100f48a7fa75d6ab47615ba697aa9ae8eee09d316b", @@ -780,6 +790,7 @@ "packages/app/src/context/global-sync/types.ts": "da1408b5665534588a0f437972730b810b0d1271a98bb8d0c442f3d4625bbf7f", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c", + "packages/app/src/pages/home/legacy-home.tsx": "43c48bfadf63c56660c8ae4dce15ef2526e87aca36b375f863510dd76e64000e", "packages/app/src/pages/layout/helpers.test.ts": "3a77cfe018ce2867cccded2c726f556e53a9895a77baa944a4878714b9b2b59e", "packages/app/src/pages/layout/helpers.ts": "b9b9bd551833390d9dcb7e2bc56de77abdef770d7d786f732930ed2bb6b8f4d6", "packages/app/src/pages/layout/notification-toasts.test.ts": "6a95bd3dccb03be894b5d2adb4bc1f7531a9ec704e4d0eae1eba4ec89bbc9846", @@ -789,10 +800,10 @@ "packages/app/src/pages/layout/session-list-chip.tsx": "62373670362fd78e944753b2839820411b5c67dea369dec6a39c5d03df827b16", "packages/app/src/pages/layout/session-list-chip.wiring.test.tsx": "3f19399ea11aabbd6c11624ebc0597301e05cfc033da543c8231a21839880997", "packages/app/src/pages/layout/session-tab-avatar.tsx": "89e83e239d513f91b31f16473be531eaf82768cefe637ea5f10d752cea4ef887", - "packages/app/src/pages/layout/session-tab-status.test.ts": "d6e62365948450c0acda628bc5d03233e5e6ead69649c2d2fb7e20c906007e74", - "packages/app/src/pages/layout/session-tab-status.tsx": "0960d24e581e5cdd38d1bea5e745039542e5ab5268f5540cf8fa87d84717922b", - "packages/app/src/pages/layout/sidebar-items.tsx": "daf940fc66f34e5f83410a8580f7bb1790bedc599ab4d64fcda2301537b24865", - "packages/app/src/pages/layout/sidebar-workspace.tsx": "a0f8e3fbd16d09e783db2a7a09c686d3aa993436d882425d3436a59ec4adf94b", + "packages/app/src/pages/layout/session-tab-status.test.ts": "4f123d40f8cc817d82476daf0b79355783fc101ce8384efbed3896b9f8cbe3eb", + "packages/app/src/pages/layout/session-tab-status.tsx": "61bca75aee87050878a03a9455329478a380193afb52208d2f0000509a08b308", + "packages/app/src/pages/layout/sidebar-items.tsx": "a3e62d5b1fd9ee9f021d466c058c640cf43553a7ad9b69f810a4c12b6f9e46c4", + "packages/app/src/pages/layout/sidebar-workspace.tsx": "19a9daf1b8bf2a4d7b3ec1439a3cc4ce2472f71d93f8d0b0f9e27727397eca45", "packages/app/src/pages/layout/worktree-auto-rename.test.ts": "fb6fac363b8bee1225bbcff8098019c02f19b095b5cd96c29f7e673acb1e4e89", "packages/app/src/pages/layout/worktree-auto-rename.ts": "2ef3e506b96b972cb55799a89aa54ca6453fc31c80bc022aa16db6ecfe9cdced", "packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81", @@ -854,7 +865,7 @@ "packages/opencode/src/server/amicode/vault-browser.ts": "8a8252fb0b46583c75e9475d7c89e68c092f5255b21476f3c3d0b9d379ea2d48", "packages/opencode/src/server/amicode/vaults.ts": "e48c90c64de27abe24a510f188def6b56297d9bb1fa1ae08cbcc94888d42044a", "packages/opencode/src/server/amicode/warrants.ts": "13ef47a676245d577617f6e5af2c8a2beb3102346d2dbb43076159f3f43c01a7", - "packages/opencode/src/server/amicode/widget-frame-html.ts": "3df10eeeccbb81d2b163195ceaa1d5abc24e90506471ff6d6b2a5fc40e52db52", + "packages/opencode/src/server/amicode/widget-frame-html.ts": "c5395910b66c921a4b6f3f3fd214d19f98808dbe41754ecd541553494884acdf", "packages/opencode/src/server/amicode/widget-manifest.ts": "5753daf16ef4c1e74c97d4a515b30d123f7afbfee29bb07381c6f10ecc14cebf", "packages/opencode/src/server/amicode/widget-runtime.ts": "a5fe95eba46df87c4b4c54bc07bb91587b73e88eb90db889bc87358ece4b3f27", "packages/opencode/src/server/amicode/widgets.ts": "4315aa80eca9e0941d1198d253fd9ad37f57b7600ef3d7f116a6b8a9ee73095b", @@ -895,7 +906,7 @@ "packages/session-ui/src/v2/components/preview-nav-state.test.ts": "cec09a8b3c08ab16581d3d5b6ff704584b3c87d81c25e8af20f6f00272f3593f", "packages/session-ui/src/v2/components/preview-nav-state.ts": "ccaeb94d1ab39271f5dcd49c935c8fc7b54d4ffbd66e1352e4d797c9205553fc", "packages/session-ui/src/v2/components/preview-view-state.ts": "4ec961c7720417166dcccd45f0d4702afec664dfe7f4d0dd8668bb9b1d8af5e6", - "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "fc06f46ed3c92c8066744da1f6ac97db7d8b7d2aeec5ef776d743dd93b317936", + "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "d4936b1512a334468c48d3c0f496e9b266927af20bdfbc84172c14139f78fc4f", "packages/session-ui/src/v2/components/session-review-v2.css": "bb306f226fda8f8ba6cf0ae706843ba9f2b48f3a3446433515e8a8dd1f9dc626", "packages/session-ui/src/v2/components/session-review-v2.tsx": "4151286efb6e0e148246ee3eebae24a2e5d4eac2603fd5ea8546d0839a694161", "packages/session-ui/src/v2/components/shiki-highlight-decorations.test.ts": "f50e8156b708f3e0ddbc509abf71751778a74923dac9d7e65385b178f6346718", @@ -966,7 +977,7 @@ "packages/app/src/pages/session/v2/accumulate-diffs.test.ts": "289ef80e26c9e85d3121d3705cefd467d147d4872410fce7309d9b7a382020fa", "packages/app/src/pages/session/v2/accumulate-diffs.ts": "a4d5ee94b31db8def04d4b1e5efc909a8c5e0e14368af0e37414e98d4d819936", "packages/app/src/pages/session/v2/adversarial-ui-e2e.test.ts": "0767c40885b0dec6a55ec25600061b76ef06aeddc9f10b20ad256de407acb9f0", - "packages/app/src/pages/session/v2/lineage-ledger-panel.css": "d928c053a4da2aded4df8bd0d32e8133e46c17a3592b0ccdb4811a68e4a523e3", + "packages/app/src/pages/session/v2/lineage-ledger-panel.css": "04ec96e285912075ca83c412f2d699500c9756b8414b9c87a028546bb662fc8f", "packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx": "f621617f3ee0bb27923a0337049f54cbfc37cb2a7895a98ae79521a0b633e955", "packages/app/src/pages/session/v2/lineage-ledger-panel.tsx": "f572b8aca1e413d9b4a2394e6ba0ea63b2eecbd7f21b79907cede82ff2ae4d1b", "packages/app/src/pages/session/v2/lineage-ledger-view.test.ts": "0b3ac457706875994ff5abf94704201a1f8b45c63d8241d5ecaa5b21ca9bcc50", @@ -992,7 +1003,7 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts": "88c7bae32fa01ab213e95cb4e4891fd3ccb961782e80a416e321c5daf90fb487", "packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts": "69ca3dd48d95157fc822e1f76eb42ac873df50f22616b85194619ca658613867", "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9", - "packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts": "a31e4d0abe4f76e667167c5017aca5559b53938db63bdc1d656a154e1aaf5fea", + "packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts": "040554475b6ba22ad666dfeaca2576658854c46cd18efe3b2d68959f9eaaed80", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "7da08d58765cdf2b6ce4648fc70de371402949f5bb7b75696f674d43f2d5365b", "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "8baf16cad0992c5351b61b0c0fb27903bb25b17e1b25fc7c5d77f807c24413a6", "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f", @@ -1013,11 +1024,11 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-23T13:24:11.640Z", + "extracted_at": "2026-10-25T15:07:55.879Z", "per_package": { "packages/app": { "A": 254, - "M": 162, + "M": 164, "D": 0 }, "packages/core": { @@ -1027,11 +1038,11 @@ }, "packages/llm": { "A": 1, - "M": 5, + "M": 7, "D": 0 }, "packages/opencode": { - "A": 98, + "A": 99, "M": 100, "D": 0 }, @@ -1056,13 +1067,13 @@ "D": 0 }, "packages/ui": { - "A": 144, - "M": 64, + "A": 148, + "M": 66, "D": 0 } }, "counts": { - "overlay_total": 991, + "overlay_total": 1002, "deletions": 7, "server_coupled": 0 }, @@ -1111,7 +1122,9 @@ "packages/core/test/session-runner.test.ts": "M", "packages/core/test/tool-question.test.ts": "M", "packages/llm/src/cache-policy.ts": "M", + "packages/llm/src/provider-error.ts": "M", "packages/llm/test/cache-policy.test.ts": "M", + "packages/llm/test/provider-error.test.ts": "M", "packages/opencode/script/build.ts": "M", "packages/opencode/script/httpapi-exercise.ts": "M", "packages/opencode/test/amicode-release-workflow.test.ts": "A", @@ -1507,6 +1520,7 @@ "packages/opencode/test/server/session-diff-missing-patch.test.ts": "M", "packages/opencode/test/server/session-diff-scoped.test.ts": "A", "packages/opencode/test/server/sse-tick-telemetry.test.ts": "A", + "packages/opencode/test/server/stdout-channel.test.ts": "A", "packages/opencode/test/server/summary-diff-wire.test.ts": "A", "packages/opencode/test/server/tcp-keepalive.test.ts": "A", "packages/opencode/test/server/workspace-routing.test.ts": "M", @@ -1580,6 +1594,9 @@ "packages/session-ui/src/context/data.tsx": "M", "packages/session-ui/src/pierre/worker.ts": "M", "packages/session-ui/src/util/clipboard.ts": "A", + "packages/ui/src/amicode/activity-dot.tsx": "A", + "packages/ui/src/amicode/activity.test.ts": "A", + "packages/ui/src/amicode/activity.ts": "A", "packages/ui/src/amicode/amico-presence.css": "A", "packages/ui/src/amicode/amico-presence.stories.tsx": "A", "packages/ui/src/amicode/amico-presence.test.ts": "A", @@ -1686,6 +1703,7 @@ "packages/ui/src/amicode/widget-tokens.test.ts": "A", "packages/ui/src/amicode/widget-tokens.ts": "A", "packages/ui/src/components/amico-spinner.tsx": "A", + "packages/ui/src/components/amicode-activity.tsx": "A", "packages/ui/src/components/amicode-approval-bridge.tsx": "A", "packages/ui/src/components/amicode-ask-bridge.tsx": "A", "packages/ui/src/components/amicode-ask.tsx": "A", @@ -1715,6 +1733,8 @@ "packages/ui/src/components/amicode-widget-grid.tsx": "A", "packages/ui/src/components/brain-engine.tsx": "A", "packages/ui/src/components/brain-ref.tsx": "A", + "packages/ui/src/components/card.css": "M", + "packages/ui/src/components/card.tsx": "M", "packages/ui/src/components/context-menu.css": "M", "packages/ui/src/components/context-menu.stories.tsx": "M", "packages/ui/src/components/context-tree-data.tsx": "A", @@ -1784,6 +1804,7 @@ "packages/app/src/components/prompt-input/slash-popover.tsx": "M", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "A", "packages/app/src/components/prompt-input/submit.ts": "M", + "packages/app/src/components/server/server-row.tsx": "M", "packages/app/src/components/session/context-warning-banner.tsx": "A", "packages/app/src/components/session/index.ts": "M", "packages/app/src/components/session/panel-menu.tsx": "A", @@ -1839,6 +1860,7 @@ "packages/app/src/context/global-sync/types.ts": "M", "packages/app/src/pages/home/home-projects-view.tsx": "M", "packages/app/src/pages/home/home-sessions-view.tsx": "M", + "packages/app/src/pages/home/legacy-home.tsx": "M", "packages/app/src/pages/layout/helpers.test.ts": "M", "packages/app/src/pages/layout/helpers.ts": "M", "packages/app/src/pages/layout/notification-toasts.test.ts": "A", diff --git a/packages/app-bundle/overlay/packages/app/CONTEXT.md b/packages/app-bundle/overlay/packages/app/CONTEXT.md index bd0ba7993..0afaa05c9 100644 --- a/packages/app-bundle/overlay/packages/app/CONTEXT.md +++ b/packages/app-bundle/overlay/packages/app/CONTEXT.md @@ -106,6 +106,34 @@ It persists until onboarding is complete and then disappears; it never gates the _Avoid_: Onboarding modal, wizard (the wizard is the conversational interview; the nudge only surfaces its state), setup wall +### Activity vocabulary + +**Activity**: +The one semantic state set every status surface speaks — session dots (tab strip, +sessions dropdown, sidebar, home), run status (run window, run view), health dots +(connection, MCP, LSP, server, vault), build status, and status banners. Five roles, +each a `--activity-*` token in `src/design-polish.css` (solid dot colour plus a `-soft` +fill) aliasing the theme's v2 state tokens so light and dark move together: + +| Role | Colour | Means | +| --- | --- | --- | +| `running` | blue | in progress — working, solving, pending, validating | +| `attention` | amber | waiting on you — permission, question, needs auth, expired, drift, stalled | +| `success` | green | done-unread, healthy, finished | +| `error` | red | failed, unreachable | +| `idle` | grey | done-seen, aborted, stopped, disabled | + +A session's tab status ranks **error > attention > running > done-unread > idle**. Running +dots share one reduced-motion-aware pulse (`.amc-activity-pulse`). Colour is never the +only signal: every status dot carries an accessible label or sits beside text that +names the state. + +**Yellow never encodes a state.** Yellow is brand and selection only (the `--accent` +family). Amico's presence — the thought-rail harmonic dot, the working lane, the +thinking wave, the mark — is brand, not status, and keeps the accent. +_Avoid_: yellow "running", gold/amber warning edges from the theme as an attention +colour, the agent identity tint on a status spinner, raw hex status colours + ## Relationships - The **Landing** is a **Chat**; the window never opens on anything else. diff --git a/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx b/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx index be54a988d..8be6b59fe 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx @@ -1,6 +1,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" +import { ActivityDot } from "@opencode-ai/ui/amicode-activity" // Amicode webview: a dead server (e.g. an opencode:build restart) used to read // as an endless "thinking" wave — the event loop retries silently every 250ms. @@ -64,12 +65,18 @@ export function ConnectionBanner() { "border-radius": "var(--radius-full)", "font-size": "12px", "font-weight": 600, - border: "1px solid var(--v2-border-border-base, #3c3c3c)", - background: "var(--v2-background-bg-layer-01, #1e1e1e)", - "box-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)", - color: down() ? "var(--v2-state-fg-warning, #d29922)" : "var(--v2-state-fg-success, #3fb950)", + display: "flex", + "align-items": "center", + gap: "var(--space-2)", + border: "1px solid var(--v2-border-border-base)", + background: "var(--v2-background-bg-layer-01)", + "box-shadow": "var(--elev-float)", + color: "var(--v2-text-text-base)", }} > + {/* activity dot: attention while the link is down, success once back. + The words carry the state; the dot is the at-a-glance colour. */} + {down() ? language.t("app.server.connectionLost") : language.t("app.server.reconnected")} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx index e75364a8a..a7d761544 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx @@ -76,7 +76,7 @@ export function DialogNewProject(props: { - + {error()} diff --git a/packages/app-bundle/overlay/packages/app/src/components/server/server-row.tsx b/packages/app-bundle/overlay/packages/app/src/components/server/server-row.tsx new file mode 100644 index 000000000..6efd7b3c3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,135 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { + children, + createEffect, + createMemo, + createSignal, + type JSXElement, + onMount, + type ParentProps, + Show, +} from "solid-js" +import { useLanguage } from "@/context/language" +import { type ServerConnection, serverName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" +import { ActivityDot, type Activity } from "@opencode-ai/ui/amicode-activity" + +interface ServerRowProps extends ParentProps { + conn: ServerConnection.Any + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement + showCredentials?: boolean +} + +export function ServerRow(props: ServerRowProps) { + const language = useLanguage() + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + const name = createMemo(() => serverName(props.conn)) + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + name() + props.conn.http.url + props.status?.version + queueMicrotask(check) + }) + + onMount(() => { + if (typeof ResizeObserver !== "function") return + createResizeObserver([nameRef, versionRef], check) + check() + }) + + const tooltipValue = () => ( + + {serverName(props.conn, true)} + + v{props.status?.version} + + + ) + + const badge = children(() => props.badge) + + return ( + + + + + + {name()} + + + + v{props.status?.version} + + + } + > + {(badge) => badge()} + + + + {(conn) => ( + + + {conn().http.username ? ( + {conn().http.username} + ) : ( + {language.t("server.row.noUsername")} + )} + + {conn().http.password && ••••••••} + + )} + + + {props.children} + + + ) +} + +export function ServerHealthIndicator(props: { health?: ServerHealth }) { + const activity = () => serverHealthActivity(props.health?.healthy) + return +} + +/** healthy → success, unreachable → error, not yet probed → running (pending). */ +export function serverHealthActivity(healthy: boolean | undefined): Activity { + if (healthy === true) return "success" + if (healthy === false) return "error" + return "running" +} + +export const SERVER_HEALTH_LABEL: Record = { + idle: "Server status unknown", + running: "Checking server", + attention: "Server needs attention", + success: "Server healthy", + error: "Server unreachable", +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx index 8535cfac2..89cb894fa 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx @@ -32,7 +32,7 @@ export function ContextWarningBanner() { return ( <> - + {language.t("context.warning.banner.warn", { percent: warning.usage() })} {language.t("command.session.compact")} @@ -41,7 +41,7 @@ export function ContextWarningBanner() { - + {language.t("context.warning.banner.critical", { percent: warning.usage() })} {language.t("command.session.compact")} diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx index 99546c289..f93068c66 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools.tsx @@ -17,20 +17,20 @@ const RebuildStatusBadge: Component<{ controller: DeveloperToolsController }> = return ( - - + + {language.t("settings.general.row.devTools.rebuilding")} - - + + {language.t("settings.general.row.devTools.rebuilt")} - - + + Failed @@ -47,7 +47,7 @@ const RebuildErrorPanel: Component<{ controller: DeveloperToolsController }> = ( - + {err()!.message} 0}> @@ -118,14 +118,14 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> - - + + VSIX built: {props.controller.vsixPath() ?? ""} - - + + {props.controller.vsixBuildError()} diff --git a/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx b/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx index 4f145eac3..3d3fa5362 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/status-popover-body.tsx @@ -51,6 +51,7 @@ import { useServerProtocol } from "@/context/server-sdk" import { beginSolverSwitch } from "@/components/solver-switch-banner" import { sendEngineCommand, engineDotClass } from "./engine-toggle-utils" import { effectiveEngineState, latchStopRequested, installEngineStateListener } from "./engine-state-signal" +import { ActivityDot, lspActivity, mcpActivity } from "@opencode-ai/ui/amicode-activity" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -510,7 +511,11 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( onClick={handleToggle} disabled={locked()} > - + {isEngine ? ( + + ) : ( + + )} {name} @@ -558,13 +563,7 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( {(item) => ( - + {item.name || item.id} )} @@ -585,7 +584,7 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( {(plugin) => ( - + {plugin} )} diff --git a/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.test.ts b/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.test.ts index a5ce09f71..1a8259987 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.test.ts @@ -2,34 +2,43 @@ import { describe, expect, test } from "bun:test" import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, - serverStatusDotClass, + serverStatusActivity, + serverStatusLabel, } from "./status-popover-indicator" -describe("serverStatusDotClass", () => { - test("uses the success token while the server and services are healthy", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") +describe("serverStatusActivity", () => { + test("success while the server and services are healthy", () => { + expect(serverStatusActivity({ ready: true, serverHealth: true, issue: false })).toBe("success") }) - test("uses the session attention token when a service needs attention", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( - // a bare yellow fill is 1.07:1 on the light ground, so the ink ring is what - // makes the highest-urgency state visible at all - "bg-v2-background-bg-accent ring-1 ring-[var(--accent-edge-ink)]", - ) + test("attention (amber, never yellow) when a service needs the user", () => { + expect(serverStatusActivity({ ready: true, serverHealth: true, attention: true, issue: true })).toBe("attention") + }) + + test("attention for non-blocking service issues while the server is online", () => { + expect(serverStatusActivity({ ready: true, serverHealth: true, issue: true })).toBe("attention") }) - test("uses the warning token for non-blocking issues while the server is online", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") + test("error only after the server connection drops", () => { + expect(serverStatusActivity({ ready: true, serverHealth: false, issue: false })).toBe("error") + expect(serverStatusActivity({ ready: true, serverHealth: false, issue: true })).toBe("error") }) - test("uses the critical token only after the server connection drops", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base") - expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base") + test("running (pending) before status is ready", () => { + expect(serverStatusActivity({ ready: false, serverHealth: true, issue: false })).toBe("running") + expect(serverStatusActivity({ ready: false, serverHealth: undefined, issue: false })).toBe("running") }) +}) - test("stays neutral before status is ready", () => { - expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base") - expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base") +describe("serverStatusLabel", () => { + test("every activity carries a distinct accessible label", () => { + const labels = (["idle", "running", "attention", "success", "error"] as const).map((activity) => + serverStatusLabel({ activity, attention: false }), + ) + expect(new Set(labels).size).toBe(labels.length) + expect(serverStatusLabel({ activity: "attention", attention: true })).not.toBe( + serverStatusLabel({ activity: "attention", attention: false }), + ) }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.ts b/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.ts index 2e1fa12b4..3c62aa8d6 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/status-popover-indicator.ts @@ -1,5 +1,6 @@ import type { LspStatus } from "@opencode-ai/sdk/v2/client" import type { McpServer } from "@opencode-ai/client/promise" +import type { Activity } from "@opencode-ai/ui/amicode-activity" export function hasServiceNeedingAttention(input: { mcp: Array }) { return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration") @@ -15,18 +16,30 @@ export function hasNonBlockingServiceIssue(input: { ) } -export function serverStatusDotClass(input: { +export function serverStatusActivity(input: { ready: boolean serverHealth: boolean | undefined attention?: boolean issue: boolean -}) { - if (input.serverHealth === false) return "bg-icon-critical-base" - if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" - // A bare yellow fill is 1.07:1 on the light ground, so the most urgent state - // was the only invisible one. The ink ring is what makes it readable. - if (input.attention) return "bg-v2-background-bg-accent ring-1 ring-[var(--accent-edge-ink)]" - if (input.issue) return "bg-icon-warning-base" - if (input.serverHealth === true) return "bg-icon-success-base" - return "bg-border-weak-base" +}): Activity { + if (input.serverHealth === false) return "error" + // still checking — the pending state of the health probe + if (!input.ready || input.serverHealth === undefined) return "running" + // waiting on the user (sign-in) or a non-blocking service issue: amber. + // Yellow never encodes a state. + if (input.attention || input.issue) return "attention" + return "success" +} + +const SERVER_STATUS_LABEL: Record = { + idle: "Server status unknown", + running: "Checking server status", + attention: "A service has an issue", + success: "Server and services healthy", + error: "Server unreachable", +} + +export function serverStatusLabel(input: { activity: Activity; attention?: boolean }) { + if (input.activity === "attention" && input.attention) return "A service needs you to sign in" + return SERVER_STATUS_LABEL[input.activity] } diff --git a/packages/app-bundle/overlay/packages/app/src/components/status-popover.tsx b/packages/app-bundle/overlay/packages/app/src/components/status-popover.tsx index bf1485a29..e0e41b952 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/status-popover.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/status-popover.tsx @@ -25,8 +25,10 @@ import { useGlobal } from "@/context/global" import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, - serverStatusDotClass, + serverStatusActivity, + serverStatusLabel, } from "./status-popover-indicator" +import { ActivityDot } from "@opencode-ai/ui/amicode-activity" const Body = lazy(() => retryImport(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))), @@ -66,7 +68,14 @@ export function StatusPopover(props: { healthDot?: boolean }) { triggerProps={{ variant: "ghost", class: "titlebar-icon w-8 h-6 p-0 box-border", - "aria-label": language.t("status.popover.trigger"), + "aria-label": (props.healthDot ?? true) + ? `${language.t("status.popover.trigger")} — ${statusLabel({ + ready: ready(), + serverHealth: serverHealth(), + attention: attention(), + issue: issue(), + })}` + : language.t("status.popover.trigger"), style: { scale: 1 }, }} trigger={ @@ -77,14 +86,14 @@ export function StatusPopover(props: { healthDot?: boolean }) { {/* amicode (#174): the dot is the only part of the trigger the show-status setting governs — see statusTriggerVisibility. */} - + + + } @@ -172,6 +181,22 @@ function ServerStatusPopover() { return } +type ServerStatusInput = { + ready: boolean + serverHealth: boolean | undefined + attention: boolean + issue: boolean +} + +function statusLabel(input: ServerStatusInput) { + return serverStatusLabel({ activity: serverStatusActivity(input), attention: input.attention }) +} + +function ServerStatusDot(props: ServerStatusInput & { class?: string }) { + const activity = () => serverStatusActivity(props) + return +} + type StatusPopoverState = { shown: boolean ready: boolean @@ -214,15 +239,15 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { size: "large", class: "!w-9 shrink-0", state: props.state.shown ? "pressed" : undefined, - "aria-label": props.state.label, + "aria-label": props.state.dotVisible ? `${props.state.label} — ${statusLabel(props.state)}` : props.state.label, }} trigger={ - + + + } diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts index f2d18766e..404986919 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session.test.ts @@ -742,3 +742,50 @@ describe("amicode#1787 — reconcile semantics at the chip (busy/idle/omitted)", expect(chipFor(session, "o5")).toBeUndefined() // back to honest omission }) }) + +// #1754: a dead turn's cards retire. A pending question/permission blocks its +// turn, so an authoritative turn end (idle status, session.error) means the +// engine forgot the request — a card left up only ever toasts "not found". +describe("stale question/permission cards retire with their turn (#1754)", () => { + const ask = (session: ReturnType, sid: string) => { + session.apply({ type: "session.created", properties: { info: sessionInfo(sid) } }) + session.apply({ type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } }) + session.apply({ + type: "question.asked", + properties: { id: "que_dead", sessionID: sid, questions: [{ header: "h", question: "q?", options: [] }] }, + }) + session.apply({ + type: "permission.asked", + properties: { id: "per_dead", sessionID: sid, permission: "edit", patterns: ["**"] }, + }) + } + + test("a still-busy turn keeps its cards (retry included)", () => { + const session = createSession() + ask(session, "s_live") + session.apply({ + type: "session.status", + properties: { sessionID: "s_live", status: { type: "retry", attempt: 1, message: "429", next: 0 } }, + }) + expect(session.data.question.s_live?.length).toBe(1) + expect(session.data.permission.s_live?.length).toBe(1) + }) + + test("an idle status retires the turn's question and permission cards", () => { + const session = createSession() + ask(session, "s_idle") + session.apply({ type: "session.status", properties: { sessionID: "s_idle", status: { type: "idle" } } }) + expect(session.data.question.s_idle?.length ?? 0).toBe(0) + expect(session.data.permission.s_idle?.length ?? 0).toBe(0) + }) + + test("session.error retires them too, and other sessions keep theirs", () => { + const session = createSession() + ask(session, "s_err") + ask(session, "s_other") + session.apply({ type: "session.error", properties: { sessionID: "s_err", error: { name: "UnknownError" } } }) + expect(session.data.question.s_err?.length ?? 0).toBe(0) + expect(session.data.permission.s_err?.length ?? 0).toBe(0) + expect(session.data.question.s_other?.length).toBe(1) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-session.ts b/packages/app-bundle/overlay/packages/app/src/context/server-session.ts index 92336303f..99d545a40 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-session.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-session.ts @@ -343,6 +343,16 @@ export function createServerSession( turnActive.delete(sessionID) clearTurnFloorTimer(sessionID) } + // #1754: a pending question/permission blocks its turn, so an AUTHORITATIVE + // turn end (the server's idle status, a terminal execution frame, or + // session.error) means every card still up for that session belongs to a + // dead turn — the engine has forgotten the request and any reply would + // toast "request not found". Retire them. Only server frames call this — + // never the local turn-floor timeout (a slow human answer is still live). + const retirePending = (sessionID: string) => { + if (data.question[sessionID]?.length) setData("question", sessionID, []) + if (data.permission[sessionID]?.length) setData("permission", sessionID, []) + } const markTurnActive = (sessionID: string, timeoutMs = TURN_FLOOR_TIMEOUT_MS) => { turnActive.add(sessionID) clearTurnFloorTimer(sessionID) @@ -1402,7 +1412,10 @@ export function createServerSession( if (!stale) { stampStatusMutation(sessionID) setData("session_status", sessionID, reconcile(props.status)) - if (props.status.type === "idle") cancelReconcileTimer(sessionID) + if (props.status.type === "idle") { + cancelReconcileTimer(sessionID) + retirePending(sessionID) + } // #1649 — the ancestor floor: a parent blocked on a foreground // subagent re-derives its child-active count from session_status, // so the parent's rail follows child status edges reactively — @@ -1445,6 +1458,7 @@ export function createServerSession( event.type === "session.execution.interrupted" ) { cancelReconcileTimer(sessionID) + retirePending(sessionID) setData("session_status", sessionID, { type: "idle" }) } if (event.type === "session.retry.scheduled") @@ -1534,7 +1548,10 @@ export function createServerSession( // (The mid-turn floor is held by an active part stream / a fresh execution // bracket — a lone idle frame does not race those; it settles a turn that // has otherwise gone quiet.) - if ((props.status?.type ?? "idle") === "idle") clearTurnActive(props.sessionID) + if ((props.status?.type ?? "idle") === "idle") { + clearTurnActive(props.sessionID) + retirePending(props.sessionID) + } stampStatusMutation(props.sessionID) setData("session_status", props.sessionID, reconcile(props.status)) // #1649 — if this session is a spawned child, propagate its new @@ -1562,6 +1579,7 @@ export function createServerSession( if (!id) return cancelReconcileTimer(id) clearTurnActive(id) + retirePending(id) stampStatusMutation(id) setData("session_status", id, { type: "idle" }) // #1649 — a child turn's terminal lowers its ancestor floor. @@ -1578,6 +1596,7 @@ export function createServerSession( const id = props?.sessionID ?? eventID if (!id) return clearTurnActive(id) + retirePending(id) if ((data.session_status[id]?.type ?? "idle") !== "idle") setData("session_status", id, { type: "idle" }) // #1649 — a child error settles it idle; lower its ancestor floor. refreshChildActive(id) diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index ac0b4e015..f877abae9 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -335,29 +335,104 @@ body[data-new-layout] inset: 0; } -/* ── session status dots ── - Four states: green (done, unread), grey (done, seen), yellow (running), - red (error). Colour is never the only signal: each dot carries - role="img" + aria-label. */ +/* ── activity vocabulary ── + One semantic set for every status surface (session dots, run status, health + dots, banners). Canonical associations: + running = blue (in progress) + attention = amber (waiting on you: permission, question, auth, expired, + drift, stalled) + success = green (done-unread, healthy, finished) + error = red (failed) + idle = grey (done-seen, aborted, stopped) + YELLOW NEVER ENCODES A STATE — it is brand and selection only (see the + accent tokens below). Amico presence (thought-rail, lane, wave, mark) is + brand, not status, and keeps the accent. + Each role aliases the theme's v2 state tokens so light and dark move + together: a solid dot takes the state's BORDER shade on light and its FG + shade on dark (the dark border shades are too deep to read as a dot). Amber + has its own clean value: the theme's warning edges read as the retired gold. + `-soft` is the matching quiet fill for badges / banners. Colour is never the + only signal — every dot carries an aria-label or sits beside text. */ :root, [data-color-scheme="light"] { - --status-idle: #a1a1aa; /* grey — done and seen */ - --status-running: #ca8a04; /* yellow-600 — working */ - --status-done: #16a34a; /* green-600 — done, unread */ - --status-error: #dc2626; /* red-600 */ + --activity-idle: var(--v2-icon-icon-muted); + /* a bright, saturated blue: the theme's border-info (#3250df) is deep enough + to read as black at dot size on white */ + --activity-running: #0a84ff; + --activity-attention: #d97706; + --activity-success: var(--v2-state-border-success); + --activity-error: var(--v2-state-border-danger); } [data-color-scheme="dark"] { - --status-idle: #71717a; /* grey */ - --status-running: #eab308; /* yellow-500 */ - --status-done: #4ade80; /* green-400 */ - --status-error: #ef4444; /* red-500 */ + --activity-idle: var(--v2-icon-icon-muted); + --activity-running: #4da3ff; + --activity-attention: #fbbf24; + --activity-success: var(--v2-state-fg-success); + --activity-error: var(--v2-state-fg-danger); } @media (prefers-color-scheme: dark) { :root:not([data-color-scheme="light"]) { - --status-idle: #71717a; - --status-running: #eab308; - --status-done: #4ade80; - --status-error: #ef4444; + --activity-idle: var(--v2-icon-icon-muted); + --activity-running: #4da3ff; + --activity-attention: #fbbf24; + --activity-success: var(--v2-state-fg-success); + --activity-error: var(--v2-state-fg-danger); + } +} +:root { + --activity-idle-soft: color-mix(in srgb, var(--activity-idle) 14%, transparent); + --activity-running-soft: var(--v2-state-bg-info); + --activity-attention-soft: color-mix(in srgb, var(--activity-attention) 14%, transparent); + --activity-success-soft: var(--v2-state-bg-success); + --activity-error-soft: var(--v2-state-bg-danger); +} + +/* The one running / pending pulse. Every activity dot that is "in progress" + breathes with this — no per-surface keyframes. Under reduced motion it holds + still at full opacity (the global reset would otherwise spin an infinite + 0.01ms loop, which flickers). */ +/* The dot stays fully coloured (fading to 35% read as grey/black half the + time); the motion is a same-colour ring expanding off the dot. The ring is a + ::after that inherits the dot's background, animated with plain transform + + opacity only (a color-mix(var()) box-shadow keyframe was emptied by the CSS + minifier, so the dot never moved). */ +.amc-activity-pulse { + position: relative; +} +.amc-activity-pulse::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: inherit; + pointer-events: none; + animation: amc-activity-ring 1.4s ease-out infinite; +} +@keyframes amc-activity-ring { + 0% { + transform: scale(1); + opacity: 0.7; + } + 80%, + 100% { + transform: scale(2.6); + opacity: 0; + } +} +/* the neutral-ink breathe (solver switch) */ +@keyframes amc-activity-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + .amc-activity-pulse::after { + animation: none !important; + display: none; } } @@ -675,19 +750,14 @@ span[data-component="tag"][data-variant="accent"] { height: 6px; border-radius: var(--radius-full); background: currentColor; - animation: amc-solver-switch-pulse 1.2s ease-in-out infinite; + animation: amc-activity-pulse 1.2s ease-in-out infinite; } [data-component="amicode-solver-switch"][data-phase="ready"] > i { animation: none; } -/* the global prefers-reduced-motion reset above collapses this duration. */ -@keyframes amc-solver-switch-pulse { - 0%, - 100% { - opacity: 0.35; - } - 50% { - opacity: 1; +@media (prefers-reduced-motion: reduce) { + [data-component="amicode-solver-switch"] > i { + animation: none; } } diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/legacy-home.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home/legacy-home.tsx new file mode 100644 index 000000000..c331821c8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/legacy-home.tsx @@ -0,0 +1,134 @@ +import { DialogSelectServer } from "@/components/dialog-select-server" +import { useDirectoryPicker } from "@/components/directory-picker" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { type ServerConnection, useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Logo } from "@opencode-ai/ui/logo" +import { useNavigate } from "@solidjs/router" +import { DateTime } from "luxon" +import { createMemo, For, Match, Switch } from "solid-js" +import { ActivityDot } from "@opencode-ai/ui/amicode-activity" +import { SERVER_HEALTH_LABEL, serverHealthActivity } from "@/components/server/server-row" + +export function LegacyHome() { + const sync = useServerSync() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const navigate = useNavigate() + const global = useGlobal() + const server = useServer() + const language = useLanguage() + const homedir = createMemo(() => sync().data.path.home) + const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) + const recent = createMemo(() => { + return sync() + .data.project.slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5) + }) + + const serverActivity = createMemo(() => serverHealthActivity(global.servers.health[server.key]?.healthy)) + + function openProject(conn: ServerConnection.Any, directory: string) { + const serverCtx = global.ensureServerCtx(conn) + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + navigate(`/${base64Encode(directory)}`) + } + + function chooseProject() { + if (serverUnreachable()) return + const conn = server.current + if (!conn) return + + const resolve = (result: string | string[] | null) => { + if (Array.isArray(result)) { + result.forEach((directory) => openProject(conn, directory)) + return + } + if (result) openProject(conn, result) + } + + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: resolve, + }) + } + + return ( + + + dialog.show(() => )} + > + + {server.name} + + + 0}> + + + {language.t("home.recentProjects")} + + {language.t("command.project.open")} + + + + + {(project) => ( + openProject(server.current!, project.worktree)} + > + {project.worktree.replace(homedir(), "~")} + + {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()} + + + )} + + + + + + + {language.t("common.loading")} + + {language.t("command.project.open")} + + + + + + + + {language.t("home.empty.title")} + {language.t("home.empty.description")} + + + {language.t("command.project.open")} + + + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.test.ts index 4e4e89ba9..28158efca 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.test.ts @@ -1,20 +1,41 @@ import { describe, expect, test } from "bun:test" -import { sessionTabStatus } from "./session-tab-status" +import { SESSION_TAB_TONE, sessionTabStatus } from "./session-tab-status" describe("sessionTabStatus precedence", () => { - test("error is loudest — red even when running", () => { + test("error is loudest — red even when running and waiting on the user", () => { expect(sessionTabStatus({ loading: true, needsAttention: true, unread: true, hasError: true })).toBe("error") }) - test("running when no error — yellow", () => { + test("attention outranks running — amber when the agent waits on the user", () => { + expect(sessionTabStatus({ loading: true, needsAttention: true, unread: false, hasError: false })).toBe("attention") + }) + test("attention outranks unread", () => { + expect(sessionTabStatus({ loading: false, needsAttention: true, unread: true, hasError: false })).toBe("attention") + }) + test("attention when waiting on the user, not done", () => { + expect(sessionTabStatus({ loading: false, needsAttention: true, unread: false, hasError: false })).toBe("attention") + }) + test("running when no error or attention — blue", () => { expect(sessionTabStatus({ loading: true, needsAttention: false, unread: false, hasError: false })).toBe("running") }) + test("running outranks unread", () => { + expect(sessionTabStatus({ loading: true, needsAttention: false, unread: true, hasError: false })).toBe("running") + }) test("done when unread — green", () => { expect(sessionTabStatus({ loading: false, needsAttention: false, unread: true, hasError: false })).toBe("done") }) - test("done when needs attention — green", () => { - expect(sessionTabStatus({ loading: false, needsAttention: true, unread: false, hasError: false })).toBe("done") - }) test("idle when done and seen — grey", () => { expect(sessionTabStatus({ loading: false, needsAttention: false, unread: false, hasError: false })).toBe("idle") }) }) + +describe("sessionTabStatus tones", () => { + test("every status maps to an activity token and a distinct label", () => { + expect(SESSION_TAB_TONE.running.color).toBe("var(--activity-running)") + expect(SESSION_TAB_TONE.attention.color).toBe("var(--activity-attention)") + expect(SESSION_TAB_TONE.done.color).toBe("var(--activity-success)") + expect(SESSION_TAB_TONE.error.color).toBe("var(--activity-error)") + expect(SESSION_TAB_TONE.idle.color).toBe("var(--activity-idle)") + const labels = Object.values(SESSION_TAB_TONE).map((tone) => tone.label) + expect(new Set(labels).size).toBe(labels.length) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.tsx index 8e2bc988c..79699f669 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-tab-status.tsx @@ -1,9 +1,10 @@ // AMICODE: the per-session tab status dot. // -// Four states: green (done, unread), grey (done, seen), yellow (running), -// red (error). +// Five states on the activity vocabulary (design-polish.css `--activity-*`): +// red (error), amber (attention — waiting on the user), blue (running), +// green (done, unread), grey (done, seen). Yellow never encodes a state. -export type SessionTabStatus = "idle" | "running" | "done" | "error" +export type SessionTabStatus = "idle" | "running" | "attention" | "done" | "error" export function sessionTabStatus(input: { loading: boolean @@ -13,25 +14,35 @@ export function sessionTabStatus(input: { }): SessionTabStatus { // Error is loudest — red dot demands action. if (input.hasError) return "error" - // Running means the agent is working — yellow. + // Waiting on the user (permission / question) — amber, above running: the + // agent is blocked until the user answers. + if (input.needsAttention) return "attention" + // Running means the agent is working — blue. if (input.loading) return "running" - // Done + unread (or needs attention) — green. - if (input.needsAttention || input.unread) return "done" + // Done + unread — green. + if (input.unread) return "done" // Done + already seen — grey. return "idle" } -const TONE: Record = { - idle: { color: "var(--status-idle)", label: "Done" }, - running: { color: "var(--status-running)", label: "Working" }, - done: { color: "var(--status-done)", label: "Done — unread" }, - error: { color: "var(--status-error)", label: "Error" }, +export const SESSION_TAB_TONE: Record = { + idle: { color: "var(--activity-idle)", label: "Done" }, + running: { color: "var(--activity-running)", label: "Working" }, + attention: { color: "var(--activity-attention)", label: "Needs your attention" }, + done: { color: "var(--activity-success)", label: "Done — unread" }, + error: { color: "var(--activity-error)", label: "Error" }, } /** The dot itself. Exported so every surface that shows session state — the tab * strip, the sessions dropdown — renders the SAME mark rather than each rolling * its own green. If the treatment changes, it changes in one place. */ -export function StatusDot(props: { color: string; label: string; status?: string; boxed?: boolean }) { +export function StatusDot(props: { + color: string + label: string + status?: string + boxed?: boolean + pulse?: boolean +}) { return ( @@ -55,7 +67,15 @@ export function StatusDot(props: { color: string; label: string; status?: string } export function SessionTabStatusDot(props: { status: SessionTabStatus }) { - const tone = () => TONE[props.status] + const tone = () => SESSION_TAB_TONE[props.status] // boxed: the tab strip reserves a 16px slot so rows stay aligned - return + return ( + + ) } diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-items.tsx index 6c6f83c11..d78665ef8 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-items.tsx @@ -7,16 +7,16 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" import { A, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" +import { type Accessor, createMemo, For, type JSX, Show } from "solid-js" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers" +import { SESSION_TAB_TONE, StatusDot, sessionTabStatus } from "./session-tab-status" export const ProjectIcon = (props: { project: LocalProject @@ -41,6 +41,17 @@ export const ProjectIcon = (props: { }), ) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) + const notifyTone = createMemo( + () => + SESSION_TAB_TONE[ + sessionTabStatus({ + loading: false, + needsAttention: hasPermissions(), + hasError: hasError(), + unread: unseenCount() > 0, + }) + ], + ) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) return ( @@ -56,16 +67,20 @@ export const ProjectIcon = (props: { - + @@ -94,7 +109,6 @@ const SessionRow = (props: { slug: string mobile?: boolean dense?: boolean - tint: Accessor isWorking: Accessor hasPermissions: Accessor hasError: Accessor @@ -105,6 +119,16 @@ const SessionRow = (props: { warmFocus: () => void }): JSX.Element => { const title = () => sessionTitle(props.session.title) + // Same derivation + precedence as the tab strip: error > attention > running > unread. + const status = createMemo(() => + sessionTabStatus({ + loading: props.isWorking(), + needsAttention: props.hasPermissions(), + hasError: props.hasError(), + unread: props.unseenCount() > 0, + }), + ) + const tone = () => SESSION_TAB_TONE[status()] return ( - 0}> - - - + + + } + > + {/* Status context: the working spinner takes the running colour, not + the agent identity tint (which includes diff red/green). */} + - - - - - - - - 0}> - - - + + {title()} @@ -168,9 +191,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return serverSync().session.data.session_working(props.session.id) }) - const tint = createMemo(() => - messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent), - ) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) const currentChild = createMemo(() => { if (!props.showChild) return @@ -203,7 +223,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { slug={props.slug} mobile={props.mobile} dense={props.dense} - tint={tint} isWorking={isWorking} hasPermissions={hasPermissions} hasError={hasError} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-workspace.tsx index 25cd8e5aa..db9381acb 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -101,7 +101,9 @@ const WorkspaceHeader = (props: { }> - + + + diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/lineage-ledger-panel.css b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/lineage-ledger-panel.css index c887b1eee..342d7595f 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/lineage-ledger-panel.css +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/lineage-ledger-panel.css @@ -20,7 +20,7 @@ [data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="legacy"], [data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="partial"] { - border-color: var(--v2-border-border-warning); + border-color: var(--activity-attention); } [data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"] { @@ -99,7 +99,7 @@ flex-direction: column; gap: var(--space-1, 4px); padding: var(--space-2, 8px); - border: 1px solid var(--v2-border-border-warning); + border: 1px solid var(--activity-attention); border-radius: var(--radius-lg, 12px); } diff --git a/packages/app-bundle/overlay/packages/llm/src/provider-error.ts b/packages/app-bundle/overlay/packages/llm/src/provider-error.ts new file mode 100644 index 000000000..29c81cbc2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/llm/src/provider-error.ts @@ -0,0 +1,55 @@ +import { Schema } from "effect" +import { LLMError, ProviderErrorEvent } from "./schema" + +const patterns = [ + /prompt is too long/i, + /request_too_large/i, + /input is too long for requested model/i, + /exceeds the context window/i, + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, + /input token count.*exceeds the maximum/i, + /tokens in request more than max tokens allowed/i, + /maximum prompt length is \d+/i, + /reduce the length of the messages/i, + /maximum context length is \d+ tokens/i, + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, + // Tightened: require a context/input/prompt noun so throughput/quota limits + // like "exceeds the limit of 100000 tokens per minute" don't false-positive. + /(?:input|context|prompt|message).*exceeds the limit of \d+/i, + /exceeds the available context size/i, + /greater than the context length/i, + /context window exceeds limit/i, + /exceeded model token limit/i, + /context[_ ]length[_ ]exceeded/i, + /request entity too large/i, + /context length is only \d+ tokens/i, + /input length.*exceeds.*context length/i, + /prompt too long; exceeded (?:max )?context length/i, + /too large for model with \d+ maximum context length/i, + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, + /model_context_window_exceeded/i, + /^too many tokens$/i, + /^token limit exceeded$/i, +] + +const exclusions = [ + /^(throttling error|service unavailable):/i, + /rate limit/i, + /too many requests/i, + // Bedrock ThrottlingException messages that match broad patterns above but + // are throughput/quota limits, not context-window overflow. + /throughput/i, + /per\s+(minute|second|hour|day)/i, + /invocation.*(limit|quota|exceeded)/i, + /quota/i, +] + +export const isContextOverflow = (message: string) => + !exclusions.some((pattern) => pattern.test(message)) && + (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) + +export const isContextOverflowFailure = (failure: unknown) => + failure instanceof LLMError + ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" + : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" diff --git a/packages/app-bundle/overlay/packages/llm/test/provider-error.test.ts b/packages/app-bundle/overlay/packages/llm/test/provider-error.test.ts new file mode 100644 index 000000000..13066525a --- /dev/null +++ b/packages/app-bundle/overlay/packages/llm/test/provider-error.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { isContextOverflow } from "../src" + +describe("provider error classification", () => { + test("classifies provider token limit messages as context overflow", () => { + const messages = [ + "tokens in request more than max tokens allowed", + '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', + "Requested token count exceeds the model's maximum context length of 131072 tokens.", + "Input length (265330) exceeds model's maximum context length (262144).", + "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", + "The input (516368 tokens) is longer than the model's context length (262144 tokens).", + "Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens", + "Too many tokens", + "Token limit exceeded", + ] + + expect(messages.every(isContextOverflow)).toBe(true) + }) + + test("does not classify rate limits as context overflow", () => { + const messages = [ + "Throttling error: Too many tokens, please wait before trying again.", + "Rate limit exceeded, please retry after 30 seconds.", + "Too many requests. Please slow down.", + "Monthly token limit exceeded", + ] + + expect(messages.some(isContextOverflow)).toBe(false) + }) + + test("does not classify Bedrock throttling/quota messages as context overflow", () => { + const messages = [ + // ThrottlingException messages that match broad patterns (too many tokens, + // token limit exceeded, exceeds the limit) but are throughput limits. + "Token throughput limit exceeded", + "Too many tokens per minute for model anthropic.claude-opus-4-8-20250808-v1:0", + "You have exceeded the model invocation throughput limit of 100000 tokens per minute", + "Model invocation throughput limit exceeded", + "Insufficient quota for this request", + "Request quota exceeded, please try again later", + "Token throughput quota exceeded: too many tokens per second", + ] + + expect(messages.some(isContextOverflow)).toBe(false) + }) + + test("still classifies real Bedrock context overflow as context overflow", () => { + const messages = [ + "Input is too long for requested model. Max input token count: 200000, actual token count: 250000", + "Input is too long for requested model", + "Input length 265330 exceeds the context window of 200000 tokens", + ] + + expect(messages.every(isContextOverflow)).toBe(true) + }) +}) diff --git a/packages/app-bundle/overlay/packages/opencode/src/provider/error.ts b/packages/app-bundle/overlay/packages/opencode/src/provider/error.ts index 4c72e3750..592590277 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/provider/error.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/provider/error.ts @@ -176,7 +176,15 @@ export type ParsedAPICallError = export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError { const m = message(input.providerID, input.error) const body = json(input.error.responseBody) - if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { + // Status-code guard: 429 (rate limit) and 5xx (server error) are never + // context overflow, even when the error message text happens to match the + // heuristic patterns (e.g. Bedrock ThrottlingException "token throughput + // limit exceeded" matches /token limit exceeded/i but is a rate limit). + // The `undefined` status case (stream errors without HTTP metadata) still + // runs the heuristic — only known non-overflow statuses are excluded. + const status = input.error.statusCode + const overflowEligible = status === undefined || (status >= 400 && status < 500 && status !== 429) + if (overflowEligible && (isContextOverflow(m) || status === 413 || body?.error?.code === "context_length_exceeded")) { return { type: "context_overflow", message: m, diff --git a/packages/app-bundle/overlay/packages/opencode/src/server/amicode/widget-frame-html.ts b/packages/app-bundle/overlay/packages/opencode/src/server/amicode/widget-frame-html.ts index 927eef1a6..d10c31f3f 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/server/amicode/widget-frame-html.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/server/amicode/widget-frame-html.ts @@ -22,8 +22,8 @@ const BAKED_TOKENS: Record = { "--amc-text": "#E9ECF4", "--amc-text-muted": "#8B94A9", "--amc-text-faint": "#6A7286", - "--amc-accent": "#FFF676", - "--amc-accent-fill": "#FFF676", + "--amc-accent": "#FFE614", + "--amc-accent-fill": "#FFE614", "--amc-accent-ink": "#111214", "--amc-success": "#5BC873", "--amc-warning": "#E5B454", diff --git a/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index 797bfa741..75dfe16bc 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -131,7 +131,6 @@ function eventResponse() { Stream.drop(1), Stream.mapEffect(() => Effect.map(Clock.currentTimeMillis, (time) => { const seq = GlobalEventReplay.current - console.log("[hb] tick fired, offering beat"); Queue.offerUnsafe(queue, controlEvent(seq, "server.heartbeat", { time, seq })) })), Stream.tap(() => diff --git a/packages/app-bundle/overlay/packages/opencode/test/server/amicode-widget-frame.test.ts b/packages/app-bundle/overlay/packages/opencode/test/server/amicode-widget-frame.test.ts index fc5585b6d..158cc7f70 100644 --- a/packages/app-bundle/overlay/packages/opencode/test/server/amicode-widget-frame.test.ts +++ b/packages/app-bundle/overlay/packages/opencode/test/server/amicode-widget-frame.test.ts @@ -28,6 +28,12 @@ describe("buildFrameHtml", () => { expect(doc).toContain("__amcWidgetCode") }) + test("accent defaults to the current brand yellow, never the retired #FFF676", () => { + expect(doc).toContain("--amc-accent: #FFE614;") + expect(doc).toContain("--amc-accent-fill: #FFE614;") + expect(doc.toUpperCase()).not.toContain("#FFF676") + }) + test(" in widget code cannot break out", () => { const hostile = buildFrameHtml('var x = "') diff --git a/packages/app-bundle/overlay/packages/opencode/test/server/stdout-channel.test.ts b/packages/app-bundle/overlay/packages/opencode/test/server/stdout-channel.test.ts new file mode 100644 index 000000000..a618df23d --- /dev/null +++ b/packages/app-bundle/overlay/packages/opencode/test/server/stdout-channel.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { readdirSync, readFileSync, statSync } from "node:fs" +import path from "node:path" + +// #1834: stdout is the CLI data channel (`opencode run --format json`). +// Request/stream handlers run inside CLI processes too, so they must never +// write to stdout — engine telemetry goes to stderr / the logger. +test("server route handlers never write to stdout", () => { + const root = path.join(import.meta.dir, "../../src/server/routes") + const offenders: string[] = [] + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const file = path.join(dir, name) + if (statSync(file).isDirectory()) walk(file) + else if (/\.tsx?$/.test(name)) { + readFileSync(file, "utf8") + .split("\n") + .forEach((line, i) => { + if (/\bconsole\.(log|info)\(/.test(line)) offenders.push(`${path.relative(root, file)}:${i + 1}`) + }) + } + } + } + walk(root) + expect(offenders).toEqual([]) +}) diff --git a/packages/app-bundle/overlay/packages/opencode/test/session/message-v2.test.ts b/packages/app-bundle/overlay/packages/opencode/test/session/message-v2.test.ts index 66020d81e..0da894814 100644 --- a/packages/app-bundle/overlay/packages/opencode/test/session/message-v2.test.ts +++ b/packages/app-bundle/overlay/packages/opencode/test/session/message-v2.test.ts @@ -1706,6 +1706,56 @@ describe("session.message-v2.fromError", () => { expect(SessionV1.APIError.isInstance(result)).toBe(true) }) + test("does not classify Bedrock 429 throttling with overflow-matching body as context overflow", () => { + // Bedrock's @ai-sdk/amazon-bedrock adapter coerces undefined to "undefined", + // stripping the SDK's "429 Too Many Requests" prefix that would otherwise + // trigger the "too many requests" exclusion in isContextOverflow. The status + // code guard in parseAPICallError must prevent the raw Bedrock message + // (which matches /token limit exceeded/i) from being misclassified. + const bedrockMessages = [ + "Token throughput limit exceeded", + "Too many tokens per minute for model anthropic.claude-opus-4-8-20250808-v1:0", + "You have exceeded the model invocation throughput limit of 100000 tokens per minute", + ] + + for (const bodyMessage of bedrockMessages) { + const result = MessageV2.fromError( + new APICallError({ + message: "undefined", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-opus-4-8-20250808-v1%3A0/converse-stream", + requestBodyValues: {}, + statusCode: 429, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ message: bodyMessage }), + isRetryable: true, + }), + { providerID: ProviderV2.ID.make("amazon-bedrock") }, + ) + expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(false) + expect(SessionV1.APIError.isInstance(result)).toBe(true) + } + }) + + test("does not classify Bedrock 400 throttling with overflow-matching body as context overflow", () => { + // Bedrock ValidationException (HTTP 400) can carry throughput messages that + // match context-overflow patterns. The broadened exclusions in + // isContextOverflow must prevent these from being misclassified. + const result = MessageV2.fromError( + new APICallError({ + message: "undefined", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-opus-4-8-20250808-v1%3A0/converse-stream", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ message: "Token throughput limit exceeded" }), + isRetryable: false, + }), + { providerID: ProviderV2.ID.make("amazon-bedrock") }, + ) + expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(false) + expect(SessionV1.APIError.isInstance(result)).toBe(true) + }) + test("serializes unknown inputs", () => { const result = MessageV2.fromError(123, { providerID }) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index d51d78aaa..e7128a9fc 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -644,8 +644,12 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop "align-items": "center", "justify-content": "space-between", padding: "6px 12px", - "background-color": "var(--amc-warning, #ffc107)", - color: "var(--amc-bg, #000)", + // attention (amber) — the app's activity token, falling back to + // the theme's v2 warning state where design-polish isn't loaded + // (share pages). --amc-* only exist inside widget iframes. + "background-color": "var(--activity-attention-soft, var(--v2-state-bg-warning))", + border: "1px solid var(--activity-attention, var(--v2-state-border-warning))", + color: "var(--v2-text-text-base)", "font-size": "12px", "border-radius": "4px", margin: "4px 0", @@ -793,11 +797,11 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop border: "none", background: "none", cursor: "pointer", - color: "var(--amc-text-muted, var(--icon-base))", + color: "var(--v2-text-text-muted)", "flex-shrink": "0", }} - onMouseEnter={(e) => (e.currentTarget.style.color = "var(--amc-danger, #f44336)")} - onMouseLeave={(e) => (e.currentTarget.style.color = "var(--amc-text-muted, var(--icon-base))")} + onMouseEnter={(e) => (e.currentTarget.style.color = "var(--v2-state-fg-danger)")} + onMouseLeave={(e) => (e.currentTarget.style.color = "var(--v2-text-text-muted)")} > @@ -812,7 +816,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop "font-size": "11px", "margin-left": "8px", "white-space": "nowrap", - color: "var(--amc-danger, #f44336)", + color: "var(--v2-state-fg-danger)", }} > Save failed diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/activity-dot.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/activity-dot.tsx new file mode 100644 index 000000000..553277909 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/activity-dot.tsx @@ -0,0 +1,22 @@ +import { ACTIVITY_COLOR, type Activity } from "./activity" + +// AMICODE: the one activity dot. Colour is never the only signal — the dot is +// role="img" with an aria-label (and a hover title). Omit `label` only when +// adjacent text already names the state; the dot is then aria-hidden. Running +// dots breathe with the shared reduced-motion-aware `.amc-activity-pulse` +// (design-polish.css). +export function ActivityDot(props: { activity: Activity; label?: string; class?: string; pulse?: boolean }) { + return ( + + ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/activity.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/activity.test.ts new file mode 100644 index 000000000..29fe830c0 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/activity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { ACTIVITY_COLOR, ACTIVITY_SOFT, connectionActivity, lspActivity, mcpActivity, runActivity } from "./activity" + +describe("activity tokens", () => { + test("every role resolves to its --activity-* token, never a hex", () => { + expect(ACTIVITY_COLOR).toEqual({ + idle: "var(--activity-idle)", + running: "var(--activity-running)", + attention: "var(--activity-attention)", + success: "var(--activity-success)", + error: "var(--activity-error)", + }) + expect(ACTIVITY_SOFT.running).toBe("var(--activity-running-soft)") + expect(ACTIVITY_SOFT.attention).toBe("var(--activity-attention-soft)") + }) +}) + +describe("runActivity", () => { + test("solving is running (blue)", () => expect(runActivity("solving")).toBe("running")) + test("stalled is attention (amber)", () => expect(runActivity("stalled")).toBe("attention")) + test("aborted is idle (grey) — a user-chosen stop", () => expect(runActivity("aborted")).toBe("idle")) + test("stopped is idle", () => expect(runActivity("stopped")).toBe("idle")) + test("finished is success", () => expect(runActivity("finished")).toBe("success")) + test("failed is error", () => expect(runActivity("failed")).toBe("error")) + test("recorded / unknown stays idle", () => { + expect(runActivity("recorded")).toBe("idle") + expect(runActivity("whatever")).toBe("idle") + }) +}) + +describe("connectionActivity", () => { + test("pending → running", () => expect(connectionActivity({ tone: "pending", showDrift: false })).toBe("running")) + test("expired / unreachable (warning) → attention", () => + expect(connectionActivity({ tone: "warning", showDrift: false })).toBe("attention")) + test("connected → success", () => expect(connectionActivity({ tone: "success", showDrift: false })).toBe("success")) + test("connected with submitter drift → attention", () => + expect(connectionActivity({ tone: "success", showDrift: true })).toBe("attention")) + test("invalid / unentitled (critical) → error", () => + expect(connectionActivity({ tone: "critical", showDrift: false })).toBe("error")) + test("needs-key (neutral) → idle", () => + expect(connectionActivity({ tone: "neutral", showDrift: false })).toBe("idle")) +}) + +describe("mcpActivity / lspActivity", () => { + test("mcp states", () => { + expect(mcpActivity("pending")).toBe("running") + expect(mcpActivity("needs_auth")).toBe("attention") + expect(mcpActivity("needs_client_registration")).toBe("attention") + expect(mcpActivity("connected")).toBe("success") + expect(mcpActivity("failed")).toBe("error") + expect(mcpActivity("disabled")).toBe("idle") + }) + test("lsp states", () => { + expect(lspActivity("connected")).toBe("success") + expect(lspActivity("error")).toBe("error") + }) +}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/activity.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/activity.ts new file mode 100644 index 000000000..57d817458 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/activity.ts @@ -0,0 +1,76 @@ +// AMICODE: the activity vocabulary — one semantic role per state, shared by +// every status surface (run status, connection / MCP / LSP / server / vault +// health dots). Colours live in the app's design-polish.css as `--activity-*` +// (aliasing the theme's v2 state tokens); this module only names the role. +// +// running = blue in progress (solving, pending, validating) +// attention = amber waiting on you (needs auth, expired, drift, stalled) +// success = green done / healthy +// error = red failed +// idle = grey idle, aborted, stopped, disabled +// +// Yellow never encodes a state — it is brand and selection only. + +export type Activity = "idle" | "running" | "attention" | "success" | "error" + +export const ACTIVITY_COLOR: Record = { + idle: "var(--activity-idle)", + running: "var(--activity-running)", + attention: "var(--activity-attention)", + success: "var(--activity-success)", + error: "var(--activity-error)", +} + +export const ACTIVITY_SOFT: Record = { + idle: "var(--activity-idle-soft)", + running: "var(--activity-running-soft)", + attention: "var(--activity-attention-soft)", + success: "var(--activity-success-soft)", + error: "var(--activity-error-soft)", +} + +const RUN_ACTIVITY: Record = { + solving: "running", + // wedged (OOM?) — must not read as live + stalled: "attention", + // a user-chosen stop: calm, not alarming, not live + aborted: "idle", + stopped: "idle", + finished: "success", + failed: "error", +} + +export function runActivity(status: string): Activity { + return RUN_ACTIVITY[status] ?? "idle" +} + +const CONNECTION_ACTIVITY: Record<"success" | "critical" | "warning" | "pending" | "neutral", Activity> = { + pending: "running", + warning: "attention", + success: "success", + critical: "error", + neutral: "idle", +} + +export function connectionActivity(model: { + tone: "success" | "critical" | "warning" | "pending" | "neutral" + showDrift: boolean +}): Activity { + // connected, but the submitter identity drifted — the user must look + if (model.showDrift) return "attention" + return CONNECTION_ACTIVITY[model.tone] +} + +export function mcpActivity(status: string): Activity { + if (status === "pending") return "running" + if (status === "needs_auth" || status === "needs_client_registration") return "attention" + if (status === "connected") return "success" + if (status === "failed") return "error" + return "idle" +} + +export function lspActivity(status: string): Activity { + if (status === "connected") return "success" + if (status === "error") return "error" + return "idle" +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css index f6a2ead8d..5140ddf8d 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.css @@ -41,16 +41,10 @@ /* ---- rail wake / settle -------------------------------------------------- */ /* The rail is Amico's body; it wakes when Amico steps in and quiets on settle. * Visibility (dormant => absent) is owned by the rail's own show gate; these - * classes only carry the motion + the live/quiet accent treatment. */ + * classes only carry the motion. */ .amc-rail.is-waking { will-change: opacity, transform; } -.amc-rail.is-live .amc-livedot { - background: var(--v2-icon-icon-accent); -} -.amc-rail.is-quiet .amc-livedot { - background: color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); -} -/* entity chips: pending (—), live (●, pulsing), settled (✓, steady) */ +/* entity chips: pending (—), live, settled — text weight carries the state */ .amc-entity { display: inline-flex; align-items: center; @@ -60,16 +54,6 @@ } .amc-entity[data-status="pending"] { color: var(--v2-text-text-faint); } .amc-entity[data-status="settled"] { color: var(--v2-text-text-base); } -.amc-entity-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--v2-border-border-base); -} -.amc-entity[data-status="live"] .amc-entity-dot { background: var(--v2-icon-icon-accent); } -.amc-entity[data-status="settled"] .amc-entity-dot { - background: color-mix(in srgb, var(--v2-icon-icon-accent) 70%, transparent); -} /* ---- motion (gated) ------------------------------------------------------ */ @keyframes amc-lane-in { @@ -84,10 +68,6 @@ from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } -@keyframes amc-livedot-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); } - 50% { opacity: 0.5; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 10%, transparent); } -} @media (prefers-reduced-motion: no-preference) { .amc-lane.is-stepping-in { animation: amc-lane-in 260ms ease-out both; } @@ -97,8 +77,4 @@ targeting it directly plays the wake exactly once, on entry, with no edit to entity-rail.tsx (keeps that hot file out of this change). */ [data-component="amicode-entity-rail"] { animation: amc-rail-wake 260ms ease-out both; } - .amc-rail.is-live .amc-livedot, - .amc-entity[data-status="live"] .amc-entity-dot { - animation: amc-livedot-pulse 1.4s ease-in-out infinite; - } } diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx index 174733424..837de222a 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amico-presence.stories.tsx @@ -52,12 +52,10 @@ function MockRail(props: { state: PresenceState }) { > AMICO - x-gate-transmon-warm ▾ {(e) => ( - {e.name}{e.val ? ` ${e.val}` : " —"} )} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css b/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css index e002332b0..d8b055bcd 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/amicode.css @@ -1365,29 +1365,57 @@ background: var(--v2-background-bg-layer-02); color: var(--v2-text-text-muted); } -/* verdict tones (shared across run status + device/calibration pills) */ -[data-component="amicode-run-view"] .amc-run-status[data-tone="ok"], -[data-component="amicode-device-view"] .amc-vpill[data-tone="ok"], -[data-component="amicode-calibration-view"] .amc-vpill[data-tone="ok"] { - background: var(--v2-state-bg-success); - color: var(--v2-state-fg-success); +/* verdict activity (shared across run status + device/calibration pills) — + the activity vocabulary (design-polish.css --activity-*): a soft fill, a + leading dot in the solid role colour, and neutral ink so 10px caps keep + body contrast in both schemes. Running breathes with the shared pulse. */ +[data-component="amicode-run-view"] .amc-run-status[data-activity], +[data-component="amicode-device-view"] .amc-vpill[data-activity], +[data-component="amicode-calibration-view"] .amc-vpill[data-activity] { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--v2-text-text-base); } -[data-component="amicode-run-view"] .amc-run-status[data-tone="warn"], -[data-component="amicode-device-view"] .amc-vpill[data-tone="warn"], -[data-component="amicode-calibration-view"] .amc-vpill[data-tone="warn"] { - background: var(--v2-state-bg-warning); - color: var(--v2-state-fg-warning); +[data-component="amicode-run-view"] .amc-run-status[data-activity]::before, +[data-component="amicode-device-view"] .amc-vpill[data-activity]::before, +[data-component="amicode-calibration-view"] .amc-vpill[data-activity]::before { + content: ""; + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--activity-dot); } -[data-component="amicode-run-view"] .amc-run-status[data-tone="critical"], -[data-component="amicode-device-view"] .amc-vpill[data-tone="critical"] { - background: var(--v2-state-bg-danger); - color: var(--v2-state-fg-danger); +[data-component="amicode-run-view"] .amc-run-status[data-activity="running"]::before { + animation: amc-activity-pulse 1.2s ease-in-out infinite; } -[data-component="amicode-run-view"] .amc-run-status[data-tone="active"] { - /* solving/in-flight → the PR#52 accent state fill (yellow warmth + neutral - ink), never a yellow foreground on a light surface. */ - background: var(--accent-fill-soft); - color: var(--v2-text-text-base); +@media (prefers-reduced-motion: reduce) { + [data-component="amicode-run-view"] .amc-run-status[data-activity="running"]::before { + animation: none; + } +} +[data-activity="idle"] { --activity-dot: var(--activity-idle); } +[data-activity="running"] { --activity-dot: var(--activity-running); } +[data-activity="attention"] { --activity-dot: var(--activity-attention); } +[data-activity="success"] { --activity-dot: var(--activity-success); } +[data-activity="error"] { --activity-dot: var(--activity-error); } +[data-component="amicode-run-view"] .amc-run-status[data-activity="running"], +[data-component="amicode-device-view"] .amc-vpill[data-activity="running"] { + background: var(--activity-running-soft); +} +[data-component="amicode-run-view"] .amc-run-status[data-activity="attention"], +[data-component="amicode-device-view"] .amc-vpill[data-activity="attention"], +[data-component="amicode-calibration-view"] .amc-vpill[data-activity="attention"] { + background: var(--activity-attention-soft); +} +[data-component="amicode-run-view"] .amc-run-status[data-activity="success"], +[data-component="amicode-device-view"] .amc-vpill[data-activity="success"], +[data-component="amicode-calibration-view"] .amc-vpill[data-activity="success"] { + background: var(--activity-success-soft); +} +[data-component="amicode-run-view"] .amc-run-status[data-activity="error"], +[data-component="amicode-device-view"] .amc-vpill[data-activity="error"] { + background: var(--activity-error-soft); } /* Device + Calibration shared pills / meta / key-value rows */ @@ -1488,30 +1516,27 @@ font-weight: 500; font-family: var(--font-mono, monospace); } -.devtools-rebuild-status--building { color: var(--text-warning-base, #ee9d2b); } -.devtools-rebuild-status--done { color: var(--text-on-success-base, #2dba26); } -.devtools-rebuild-status--failed { color: var(--text-danger-base, #e5484d); } +/* activity vocabulary: running blue, success green, error red. Status text + takes the v2 state FG shade (text-safe in both schemes); the dot takes the + solid --activity-* role. Running breathes with the shared pulse. */ +.devtools-rebuild-status--running { color: var(--v2-state-fg-info); } +.devtools-rebuild-status--success { color: var(--v2-state-fg-success); } +.devtools-rebuild-status--error { color: var(--v2-state-fg-danger); } .devtools-status-dot { display: inline-block; width: 8px; height: 8px; - border-radius: 50%; + border-radius: var(--radius-full); } -.devtools-status-dot--orange { - background: var(--surface-warning-strong, #ee9d2b); - animation: devtools-pulse 1.2s ease-in-out infinite; +.devtools-status-dot--running { + background: var(--activity-running); } -.devtools-status-dot--green { - background: var(--surface-success-strong, #12c905); +.devtools-status-dot--success { + background: var(--activity-success); } -.devtools-status-dot--red { - background: var(--surface-danger-strong, #e5484d); -} - -@keyframes devtools-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } +.devtools-status-dot--error { + background: var(--activity-error); } .devtools-rebuild-row { @@ -1525,8 +1550,8 @@ margin: 8px 0 4px; padding: 10px 12px; border-radius: 6px; - background: color-mix(in srgb, var(--surface-danger-strong, #e5484d) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--surface-danger-strong, #e5484d) 20%, transparent); + background: var(--activity-error-soft); + border: 1px solid color-mix(in srgb, var(--activity-error) 20%, transparent); } .devtools-error-message { display: flex; @@ -1534,7 +1559,7 @@ gap: 6px; font-size: 13px; font-weight: 600; - color: var(--text-danger-base, #e5484d); + color: var(--v2-state-fg-danger); line-height: 1.4; } .devtools-error-message .devtools-status-dot { @@ -1546,7 +1571,7 @@ padding-left: 22px; font-size: 12px; line-height: 1.6; - color: var(--text-secondary-base, #8b8b8b); + color: var(--v2-text-text-muted); list-style: decimal; } .devtools-error-fix-steps li { @@ -1558,21 +1583,21 @@ border: none; background: none; font-size: 11px; - color: var(--text-secondary-base, #8b8b8b); + color: var(--v2-text-text-muted); cursor: pointer; font-family: inherit; } .devtools-error-detail-toggle:hover { - color: var(--text-primary-base, #ededed); + color: var(--v2-text-text-base); } .devtools-error-detail { margin-top: 4px; padding: 8px; border-radius: 4px; - background: var(--surface-secondary-base, rgba(0,0,0,0.15)); + background: var(--v2-background-bg-layer-02); font-size: 11px; line-height: 1.5; - color: var(--text-secondary-base, #8b8b8b); + color: var(--v2-text-text-muted); font-family: var(--font-mono, monospace); overflow-x: auto; max-height: 200px; diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx index b695d7950..2d8ba1442 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connection-icon.tsx @@ -1,26 +1,16 @@ -// AMICODE: ConnectionIcon — 18px logo + 6px state-dot badge (issue #327) +// AMICODE: ConnectionIcon — 18px logo + 6px activity-dot badge (issue #327) import { Show } from "solid-js" import { cardModel } from "./connections" import type { ConnectionView } from "./connections" +import { connectionActivity } from "./activity" +import { ActivityDot } from "./activity-dot" -export function ConnectionIcon(props: { conn: ConnectionView }) { +export function ConnectionIcon(props: { conn: ConnectionView; label?: string }) { const model = () => cardModel(props.conn) - const tone = () => model().tone - const dotClass = () => { - switch (tone()) { - case "success": - return "bg-icon-success-base" - case "critical": - return "bg-icon-critical-base" - case "warning": - return "bg-icon-warning-base" - case "pending": - return "bg-icon-warning-base animate-pulse" - default: - return "bg-border-weak-base" - } - } + // pending → running, expired/unreachable/drift → attention, connected → + // success, invalid/unentitled → error, needs-key → idle + const activity = () => connectionActivity(model()) const isSvg = () => Boolean(props.conn.icon && props.conn.icon.trim().startsWith(" { @@ -39,7 +29,9 @@ export function ConnectionIcon(props: { conn: ConnectionView }) { - + + + ) } diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx index 5c80e3d74..c73068444 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/connections-tab.tsx @@ -37,6 +37,8 @@ import { type StartAuthPayload, } from "./connections" import { ConnectionIcon } from "./connection-icon" +import { ACTIVITY_COLOR } from "./activity" +import { ActivityDot } from "./activity-dot" import { ConnectionPicker } from "./connection-picker" export type ConnectionsTabLabels = { @@ -97,7 +99,7 @@ export function AmicodeConnectionsTab(props: { when={view().ok} fallback={ - + {view().error} ["tone"], string> = { - success: "bg-icon-success-base", - critical: "bg-icon-critical-base", - warning: "bg-icon-warning-base", - pending: "bg-icon-warning-base animate-pulse", - neutral: "bg-border-weak-base", -} - /** amicode#200: exported — the solver toggle's popover mounts the SAME card * for Company Compute (connect / connected-manage / expired states). */ export function ConnectionCard(props: { @@ -282,7 +276,7 @@ export function ConnectionCard(props: { - + {connectionDisplayName(props.conn)} @@ -303,7 +297,7 @@ export function ConnectionCard(props: { data-slot="amicode-connection-state-copy" > - + {stateCopy(props.conn, props.labels.states)} @@ -313,7 +307,7 @@ export function ConnectionCard(props: { - + {driftCopy(props.conn, props.labels.driftHint)} @@ -336,7 +330,7 @@ export function ConnectionCard(props: { - + {props.actionError} @@ -413,7 +407,7 @@ export function ConnectionCard(props: { - + {(r) => ( <> - + {/* the dot carries the activity colour (blue solving, amber stalled, + grey aborted/stopped); the word stays neutral ink so small + status text keeps body contrast in both schemes */} + + {r().status} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/vaults-tab.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/vaults-tab.tsx index efe8e969b..d304126bd 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/vaults-tab.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/vaults-tab.tsx @@ -3,6 +3,8 @@ // parsed VaultsView down. `view === undefined` means loading. import { For, Show } from "solid-js" import type { VaultsView } from "./vaults" +import { ACTIVITY_COLOR } from "./activity" +import { ActivityDot } from "./activity-dot" export const AMICODE_MANAGE_VAULTS_PROMPT = "Help me manage my vaults — check status, mount, create, or repair." @@ -23,7 +25,11 @@ export function AmicodeVaultsTab(props: { when={view().ok} fallback={ - + {view().error} ( - 0, - }} + 0 ? "attention" : "success"} + label={mount.warnings.length > 0 ? "Vault has warnings" : "Vault healthy"} /> {mount.id} diff --git a/packages/app-bundle/overlay/packages/ui/src/components/amicode-activity.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-activity.tsx new file mode 100644 index 000000000..05f630af5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-activity.tsx @@ -0,0 +1,12 @@ +// AMICODE: re-export shim so packages/app can import the activity vocabulary +// through the existing `"./*": "./src/components/*.tsx"` export wildcard. +export { + ACTIVITY_COLOR, + ACTIVITY_SOFT, + connectionActivity, + lspActivity, + mcpActivity, + runActivity, +} from "../amicode/activity" +export type { Activity } from "../amicode/activity" +export { ActivityDot } from "../amicode/activity-dot" diff --git a/packages/app-bundle/overlay/packages/ui/src/components/card.css b/packages/app-bundle/overlay/packages/ui/src/components/card.css new file mode 100644 index 000000000..e90bb7340 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/components/card.css @@ -0,0 +1,115 @@ +[data-component="card"] { + --card-pad-y: 10px; + --card-pad-r: 12px; + --card-pad-l: 10px; + + width: 100%; + display: flex; + flex-direction: column; + position: relative; + background: transparent; + border: none; + border-radius: var(--radius-md); + padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-base); + + --card-gap: 8px; + --card-icon: 16px; + --card-indent: 0px; + --card-line-pad: 8px; + + --card-accent: var(--v2-icon-icon-base); + + &:has([data-slot="card-title"]) { + gap: 8px; + } + + &:has([data-slot="card-title-icon"]) { + --card-indent: calc(var(--card-icon) + var(--card-gap)); + } + + &::before { + content: ""; + position: absolute; + left: 0; + top: var(--card-line-pad); + bottom: var(--card-line-pad); + width: 2px; + border-radius: 2px; + background-color: var(--card-accent); + } + + :where([data-card="title"], [data-slot="card-title"]) { + color: var(--v2-text-text-base); + font-weight: var(--font-weight-medium); + } + + :where([data-slot="card-title"]) { + display: flex; + align-items: center; + gap: var(--card-gap); + } + + :where([data-slot="card-title"]) [data-component="icon"] { + color: var(--card-accent); + } + + :where([data-slot="card-title-icon"]) { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--card-icon); + height: var(--card-icon); + flex: 0 0 auto; + } + + :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { + color: var(--v2-text-text-faint); + } + + :where([data-slot="card-title-icon"]) + [data-slot="icon-svg"] + :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] { + stroke-width: 1.5px !important; + } + + :where([data-card="description"], [data-slot="card-description"]) { + color: var(--v2-text-text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + + :where([data-card="actions"], [data-slot="card-actions"]) { + padding-left: var(--card-indent); + } +} + +body:not([data-new-layout]) [data-component="card"] { + color: var(--text-strong); + --card-accent: var(--icon-active); + + :where([data-card="title"], [data-slot="card-title"]) { + color: var(--text-strong); + } + + :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { + color: var(--text-weak); + } + + :where([data-card="description"], [data-slot="card-description"]) { + color: var(--text-base); + } + + &[data-variant="error"] { + --card-accent: var(--activity-error, var(--v2-state-fg-danger)) !important; + } +} diff --git a/packages/app-bundle/overlay/packages/ui/src/components/card.tsx b/packages/app-bundle/overlay/packages/ui/src/components/card.tsx new file mode 100644 index 000000000..7b8cf68e3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/components/card.tsx @@ -0,0 +1,126 @@ +import { type ComponentProps, splitProps } from "solid-js" +import { Icon, type IconProps } from "./icon" + +type Variant = "normal" | "error" | "warning" | "success" | "info" + +export interface CardProps extends ComponentProps<"div"> { + variant?: Variant +} + +export interface CardTitleProps extends ComponentProps<"div"> { + variant?: Variant + + /** + * Optional title icon. + * + * - `undefined`: picks a default icon based on `variant` (error/warning/success/info) + * - `false`/`null`: disables the icon + * - `Icon` name: forces a specific icon + */ + icon?: IconProps["name"] | false | null +} + +function pick(variant: Variant) { + if (variant === "error") return "circle-ban-sign" as const + if (variant === "warning") return "warning" as const + if (variant === "success") return "circle-check" as const + if (variant === "info") return "help" as const + return +} + +function mix(style: ComponentProps<"div">["style"], value?: string) { + if (!value) return style + if (!style) return { "--card-accent": value } + if (typeof style === "string") return `${style};--card-accent:${value};` + return { ...(style as Record), "--card-accent": value } +} + +export function Card(props: CardProps) { + const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"]) + const variant = () => split.variant ?? "normal" + const accent = () => { + const v = variant() + // The app's activity vocabulary (design-polish.css --activity-*): warning is + // amber attention, info is the running blue. Falls back to the theme's v2 + // state tokens where design-polish isn't loaded (share pages, storybook). + if (v === "error") return "var(--activity-error, var(--v2-state-fg-danger))" + if (v === "warning") return "var(--activity-attention, var(--v2-state-fg-warning))" + if (v === "success") return "var(--activity-success, var(--v2-state-fg-success))" + if (v === "info") return "var(--activity-running, var(--v2-state-fg-info))" + return + } + return ( + + {props.children} + + ) +} + +export function CardTitle(props: CardTitleProps) { + const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"]) + const show = () => split.icon !== false && split.icon !== null + const name = () => { + if (split.icon === false || split.icon === null) return + if (typeof split.icon === "string") return split.icon + return pick(split.variant ?? "normal") + } + const placeholder = () => !name() + return ( + + {show() ? ( + + + + ) : null} + {split.children} + + ) +} + +export function CardDescription(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} + +export function CardActions(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 71f157d8c..b527f856d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,7 +2,7 @@ "name": "amicode", "displayName": "Amicode", "description": "Open autonomous research in VS Code \u2014 vaults, fleet, and live solves for quantum control and physical intelligence.", - "version": "0.3.8", + "version": "0.3.9", "publisher": "harmoniqs", "license": "Apache-2.0", "icon": "media/icon.png", diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 90270a00b..1fd62a3e5 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -454,7 +454,15 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean if (!path.isAbsolute(fsPath) || fsPath.length > 4096) return true; const linkResolution = io.linkResolution; if (!linkResolution) { - if (!fs.existsSync(fsPath)) return true; + // #1845: never a silent no-op — without the posture context a path + // that isn't on this disk names itself (on a hub-backed panel the path + // lives on the hub, which is the clue that the context never wired). + if (!fs.existsSync(fsPath)) { + vscode.window.showWarningMessage( + `Amicode: link not opened — ${fsPath} is not on this machine (no link-resolution context for this panel; reload the window if it is a hub session)`, + ); + return true; + } // Route to the Preview companion tab (#935) — the session page's // preview-file listener (from #934) sets previewFile, opens the side // panel, and switches to the Preview tab. @@ -477,8 +485,14 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean }); } else if (resolution.action === "failed") { vscode.window.showErrorMessage(`Amicode: link not opened — ${resolution.reason} (${resolution.detail})`); + } else { + // #1845: the non-hub postures' refusal (stale path) is named too. + vscode.window.showWarningMessage( + `Amicode: link not opened — ${fsPath} is not on this machine (posture: ${linkResolution.posture})`, + ); } - // "silent": the non-hub posture's unchanged refusal. + }, (err: unknown) => { + vscode.window.showErrorMessage(`Amicode: link not opened — ${err instanceof Error ? err.message : String(err)}`); }); return true; } diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 530bc0371..89fc92dfb 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -249,7 +249,7 @@ export class ChatPanel implements LaunchTarget { // #1551: the enable-control act → the host's modal + self-owned mint. onFleetEnableControl: ChatPanel.onFleetEnableControlCallback, previewVisibleChildren: ChatPanel.previewVisibleChildrenCallback, - linkResolution: ChatPanel.linkResolutionContextCallback?.(), + linkResolution: (() => { try { return ChatPanel.linkResolutionContextCallback?.(); } catch (err) { console.error("[amicode] link-resolution context failed", err); return undefined; } })(), explorerIconTheme: resolveExplorerIconTheme, }); if (!handled) console.log("[amicode/chat] webview msg:", msg); diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts index 1cba6f9c6..0d1e255b1 100644 --- a/packages/extension/src/deck_panel.ts +++ b/packages/extension/src/deck_panel.ts @@ -60,7 +60,7 @@ export class DeckPanel { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), ...(serverAuth ? { server: { url: serverUrl, authorization: serverAuth } } : {}), - linkResolution: DeckPanel.linkResolutionContextCallback?.(), + linkResolution: (() => { try { return DeckPanel.linkResolutionContextCallback?.(); } catch (err) { console.error("[amicode] link-resolution context failed", err); return undefined; } })(), // Bug-session lifecycle (#250) — deck panes never carry the // amicode_bug_report boot param, so no dock lives here; wired for // uniformity (the manager drops unknown ids anyway). diff --git a/packages/extension/test/link_resolution_1826.test.ts b/packages/extension/test/link_resolution_1826.test.ts index d5d0c3f37..bd0af0d1f 100644 --- a/packages/extension/test/link_resolution_1826.test.ts +++ b/packages/extension/test/link_resolution_1826.test.ts @@ -257,3 +257,25 @@ describe("the remote affordance — vscode-remote explicit (#1826)", () => { expect(window_.messages[0]).toContain("no-ssh-alias"); }); }); + +// #1845: the open-file handler never dies silently — the last two silent +// branches (no posture context; the non-hub posture's stale-path refusal) +// each name themselves with a toast. +describe("no silent link clicks (#1845)", () => { + const missing = "/home/aaron/harmoniqs/definitely-not-on-this-disk-1845/notes.md"; + + it("without a link-resolution context, a path absent locally toasts instead of doing nothing", () => { + const host = io(); + handleAmicodeBridgeMessage({ source: "amicode", kind: "open-file", url: `file://${missing}` }, host); + expect(host.posted).toEqual([]); + expect(window_.messages.some((m) => m.includes("link not opened") && m.includes(missing))).toBe(true); + }); + + it("a non-hub posture's stale path toasts instead of the old silent refusal", async () => { + const host = io({ posture: "fleet-client" }); + handleAmicodeBridgeMessage({ source: "amicode", kind: "open-file", url: `file://${missing}` }, host); + await flush(); + expect(host.posted).toEqual([]); + expect(window_.messages.some((m) => m.includes("link not opened") && m.includes("fleet-client"))).toBe(true); + }); +});