From ae7ace2f9e212b78771720f2c1ab168fedb99346 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 4 Sep 2026 11:20:38 -0400 Subject: [PATCH] chore(vendor): bump fork pin to v1.18.10-amicode.21 (#789) --- packages/app-bundle/manifest.json | 51 +- .../src/components/session/session-header.tsx | 20 +- .../packages/app/src/context/server-sync.tsx | 764 ++++++++++++++++++ .../overlay/packages/app/src/pages/home.tsx | 7 +- .../app/src/pages/layout/helpers.test.ts | 348 ++++++++ .../packages/app/src/pages/layout/helpers.ts | 172 ++++ .../packages/app/src/pages/session.tsx | 31 +- .../session/timeline/rows-current.test.ts | 52 ++ .../app/src/pages/session/timeline/rows.ts | 14 +- .../pages/session/v2/accumulate-diffs.test.ts | 20 + .../src/pages/session/v2/review-panel-v2.tsx | 2 + .../packages/core/src/filesystem/watcher.ts | 140 ++++ .../overlay/packages/core/src/flag/flag.ts | 3 - .../core/test/filesystem/watcher.test.ts | 297 +++++++ .../session-review-file-preview-v2.tsx | 1 + packages/extension/opencode.lock.json | 10 +- .../test/fixtures/amicode/golden.json | 36 +- 17 files changed, 1905 insertions(+), 63 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts create mode 100644 packages/app-bundle/overlay/packages/core/src/filesystem/watcher.ts create mode 100644 packages/app-bundle/overlay/packages/core/test/filesystem/watcher.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 41f141e0..378da74b 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -2,20 +2,20 @@ "schema": 4, "slices": "b (complete app-graph delta)", "scope": "complete fork-vs-base delta of packages/{app,ui,session-ui,schema,core,sdk}", - "fork_tag": "v1.18.10-amicode.19", - "fork_sha": "d5123f6dbd4a306c42361a2e65faf8424752d6bf", + "fork_tag": "v1.18.10-amicode.21", + "fork_sha": "d161eb0cfc6d03a53311e083b590005b4161c13a", "upstream_base": "v1.18.12", "upstream_base_sha": "0dd6950d1b06958fbcdcadf0ad56258257ab7fdb", - "extracted_at": "2026-09-03T00:04:42.357Z", + "extracted_at": "2026-09-04T15:18:05.348Z", "per_package": { "packages/app": { "A": 124, - "M": 146, + "M": 149, "D": 1 }, "packages/core": { "A": 5, - "M": 30, + "M": 32, "D": 0 }, "packages/schema": { @@ -40,9 +40,9 @@ } }, "counts": { - "overlay_total": 570, + "overlay_total": 575, "deletions": 7, - "server_coupled": 45 + "server_coupled": 47 }, "server_coupled_port_inventory": [ "packages/core/schema.json", @@ -53,6 +53,7 @@ "packages/core/src/database/migration/20260820000001_add_session_directories.ts", "packages/core/src/database/migration/20260828201050_normal_stryfe.ts", "packages/core/src/database/schema.gen.ts", + "packages/core/src/filesystem/watcher.ts", "packages/core/src/flag/flag.ts", "packages/core/src/location-mutation.ts", "packages/core/src/location.ts", @@ -76,6 +77,7 @@ "packages/core/src/tool/websearch.ts", "packages/core/src/tool/write.ts", "packages/core/src/v1/session.ts", + "packages/core/test/filesystem/watcher.test.ts", "packages/core/test/fixture/location.ts", "packages/core/test/location-mutation.test.ts", "packages/core/test/system-context/builtins.test.ts", @@ -166,6 +168,7 @@ "packages/app/src/context/server-sdk.test.ts", "packages/app/src/context/server-sdk.tsx", "packages/app/src/context/server-session.ts", + "packages/app/src/context/server-sync.tsx", "packages/app/src/context/server.tsx", "packages/app/src/context/settings.tsx", "packages/app/src/context/tabs.tsx", @@ -198,6 +201,8 @@ "packages/app/src/pages/home/home-sessions-view.tsx", "packages/app/src/pages/layout-new.tsx", "packages/app/src/pages/layout.tsx", + "packages/app/src/pages/layout/helpers.test.ts", + "packages/app/src/pages/layout/helpers.ts", "packages/app/src/pages/layout/project-avatar-state.ts", "packages/app/src/pages/layout/session-tab-avatar.tsx", "packages/app/src/pages/layout/sidebar-items.tsx", @@ -475,6 +480,7 @@ "packages/app/src/context/server-sdk.test.ts": "M", "packages/app/src/context/server-sdk.tsx": "M", "packages/app/src/context/server-session.ts": "M", + "packages/app/src/context/server-sync.tsx": "M", "packages/app/src/context/server.tsx": "M", "packages/app/src/context/settings.tsx": "M", "packages/app/src/context/split.tsx": "A", @@ -514,6 +520,8 @@ "packages/app/src/pages/home/home-sessions-view.tsx": "M", "packages/app/src/pages/layout-new.tsx": "M", "packages/app/src/pages/layout.tsx": "M", + "packages/app/src/pages/layout/helpers.test.ts": "M", + "packages/app/src/pages/layout/helpers.ts": "M", "packages/app/src/pages/layout/project-avatar-state.ts": "M", "packages/app/src/pages/layout/session-tab-avatar.tsx": "M", "packages/app/src/pages/layout/session-tab-status.test.ts": "A", @@ -617,6 +625,7 @@ "packages/core/src/database/migration/20260820000001_add_session_directories.ts": "A", "packages/core/src/database/migration/20260828201050_normal_stryfe.ts": "A", "packages/core/src/database/schema.gen.ts": "M", + "packages/core/src/filesystem/watcher.ts": "M", "packages/core/src/flag/flag.ts": "M", "packages/core/src/location-mutation.ts": "M", "packages/core/src/location.ts": "M", @@ -640,6 +649,7 @@ "packages/core/src/tool/websearch.ts": "M", "packages/core/src/tool/write.ts": "M", "packages/core/src/v1/session.ts": "M", + "packages/core/test/filesystem/watcher.test.ts": "M", "packages/core/test/fixture/location.ts": "M", "packages/core/test/location-mutation.test.ts": "M", "packages/core/test/system-context/builtins.test.ts": "M", @@ -982,7 +992,7 @@ "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-header.tsx": "2069731592c2268580b32731d61b118d4eebfec092a49a407ec15fa29ae0cfae", + "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "b6b4b8fe6f751499e0d3005e1f12224351df4f28f221bba10fc2f91c0f09e777", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", @@ -1047,6 +1057,7 @@ "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816", "packages/app/src/context/server-sdk.tsx": "b3450d9bdafde6ed1a95d7dc970fc9f33bef81531b05245c36b789878d09d186", "packages/app/src/context/server-session.ts": "009fcdb6cd374c0f2cac53d37952afaa611f65371b43ff84f3654cffa8c56b18", + "packages/app/src/context/server-sync.tsx": "43154ffe6227203d778378d190af3c46d7f49098619721fea5e6263e8794dc84", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/settings.tsx": "050f1831173d9f337bb00d54c034670553cd22bb457c7b536e4d799eb35de8a3", "packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0", @@ -1081,11 +1092,13 @@ "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", "packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850", "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", - "packages/app/src/pages/home.tsx": "bec06ca3ee4a26d8bc3b1ecff6e7cd1f1de1d251d94888c149e1449837fcf16b", + "packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c", "packages/app/src/pages/layout-new.tsx": "86e910dcf29536232562ea2652062edcc63be48e7507db85d50d1fd7b0b6ea1d", "packages/app/src/pages/layout.tsx": "a6e6574054f6d85c0d91c1236c9db99674b8d51876643896b0c34f77a28bc85a", + "packages/app/src/pages/layout/helpers.test.ts": "f1ba4e1513dbcdeacab05cf98fa73dc561a18e9d3faa975afe1dda926fb8bba6", + "packages/app/src/pages/layout/helpers.ts": "fad59b18da9bf2cbde720627865e1b02d60168aba9f81ea7c07e9402b2fc8717", "packages/app/src/pages/layout/project-avatar-state.ts": "7aadb235d27dc45799411c724f0fd1282af82d091cbcefb0344deb7c5d0659d4", "packages/app/src/pages/layout/session-tab-avatar.tsx": "89e83e239d513f91b31f16473be531eaf82768cefe637ea5f10d752cea4ef887", "packages/app/src/pages/layout/session-tab-status.test.ts": "d6e62365948450c0acda628bc5d03233e5e6ead69649c2d2fb7e20c906007e74", @@ -1098,7 +1111,7 @@ "packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81", "packages/app/src/pages/new-session/new-session-view.test.ts": "615110d1a956bb2321ca77c998d6f5da434756d166ee61cf71ca232cc9efb0c4", "packages/app/src/pages/new-session/new-session-view.tsx": "30b0b048f88ce741215e642b0760758638ad36696d2eb8770bc8e6b8b8f3fc29", - "packages/app/src/pages/session.tsx": "feec17e648e5b970b7bbc4b98921caa5597e2085a4ce2468250cdc785cc3f093", + "packages/app/src/pages/session.tsx": "adc8a5313867a68f4e2e954143fe1098fe1b66274442e5a5c30e85bbfcaacc77", "packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "fe3d68f2f2bb41b04ee5567cd95a9c01671a98fd21585788be9d4cbb01a4bc22", "packages/app/src/pages/session/composer/bug-dock-controller.ts": "ab2c72a761e1992f300b6a0cecc9d0351b00a56e4be08567bcb22d3fcece78f7", "packages/app/src/pages/session/composer/bug-dock.ts": "11eb1a1c911df8764162d47f07e5f135d94fcb622f8a2ad9b41a23233131ba58", @@ -1132,8 +1145,8 @@ "packages/app/src/pages/session/timeline/model.ts": "76cbb00ef94133db33b69851ae8eb5cc974b5f2414551cb48ba7b61f3cfa025e", "packages/app/src/pages/session/timeline/projection.test.ts": "68655f29c382640fc281780166aa528ea26d28d953f3376e1e062be7a0e13749", "packages/app/src/pages/session/timeline/projection.ts": "36ab006523de7b695b33d26a0a03b59db9e148b7e979c36f63970cbcb8bcaca2", - "packages/app/src/pages/session/timeline/rows-current.test.ts": "59a29debcd7c322d4b4bb50fd9ba99e136e3e51faf730075fa7939e917bc1756", - "packages/app/src/pages/session/timeline/rows.ts": "19d2abcc9cc12ce14fce91cd900a8e0b6ae0c0b252a9387ec52144c93469cb6d", + "packages/app/src/pages/session/timeline/rows-current.test.ts": "b6a2d9671cfba970990b36d9ef0d1f107efcee59f0a9863f1280526116299095", + "packages/app/src/pages/session/timeline/rows.ts": "68581190ff5e51d2dc398298c705fbb5dfdefb7eb4cecf98f1da6ae0bd171f18", "packages/app/src/pages/session/timeline/smooth-scroll.test.ts": "79d3bba42ad83c3ab80df907e0acf08665a38c4809468e6c12ac9fcf5e94682f", "packages/app/src/pages/session/timeline/smooth-scroll.ts": "a7c16860aa68917b74f5c5c46d239e9a5d7a9a952233407e2ed856b534059828", "packages/app/src/pages/session/timeline/thought-rail-pure.ts": "886c10b0de61b0ab64baf8a284758dab8154f279fd9cd509b7b1d8ecc155ad02", @@ -1144,11 +1157,11 @@ "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", "packages/app/src/pages/session/use-session-commands.tsx": "7af9a112ae60415572ab7c9b5d4e73b2f8ed9115858c695be675ffdf07c3df2b", - "packages/app/src/pages/session/v2/accumulate-diffs.test.ts": "e22895e339821ea468f8aeed53deec5244ceb9bd4371c815926ec19203258d07", + "packages/app/src/pages/session/v2/accumulate-diffs.test.ts": "da0c3f950c0870257d5866793c4f2e42ea4416c2a318bc755c9845fca1a77cd7", "packages/app/src/pages/session/v2/accumulate-diffs.ts": "f10a1e097a86f746f3fc2b861454f3b8f509e6895c54b7e3bd0317dbf3d14acf", "packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "ac0cf1939c164948d9a0c54cc15076b30413612d872237e0fbd0912804c1ced0", "packages/app/src/pages/session/v2/review-panel-v2-state.ts": "e27c3aa236d2862e3cbd87128c4e03e6d810974faaaadb12a7e04cdd3b531e1c", - "packages/app/src/pages/session/v2/review-panel-v2.tsx": "618a68fcb9316bb87d306c69d1ae0bed8926c1273904fdc2425f28e06c52384a", + "packages/app/src/pages/session/v2/review-panel-v2.tsx": "b573dd2f3cf227d1cf85e715668f78d71b58f237d65750016028f17aaf716727", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", @@ -1189,7 +1202,8 @@ "packages/core/src/database/migration/20260820000001_add_session_directories.ts": "bd8ab86f36281b8ccd6d02a6971b81a0a1e877544fac68f969a9a16b559f3f91", "packages/core/src/database/migration/20260828201050_normal_stryfe.ts": "cbf63b27785b76d64d83e4c7fea341278c736985a6e21e4bd6cc816360a84154", "packages/core/src/database/schema.gen.ts": "784fadc53281588f15edebef7a6362d025ed3fa15f7b86b6451f1cf27292f0f0", - "packages/core/src/flag/flag.ts": "55ffe731e35f8e045934463529e63bcd1e5f81df9fd69ebe585af85635ca72b0", + "packages/core/src/filesystem/watcher.ts": "8600768eb5250fd9e26e4404e4f18c2b823c452bf967da8e4c5085347c918c65", + "packages/core/src/flag/flag.ts": "3cefd0a4d803268910d265a27bf24ed8ca354ab28449380c6f1cbd7708a8d4b5", "packages/core/src/location-mutation.ts": "5fa852c48e98cec513346f848b422c2d5d6d4b753b39da851c73be073a03511a", "packages/core/src/location.ts": "16325cfecf6c83bd4e653a0f1313d78172a31fb93cd28672f180053ae4719aef", "packages/core/src/models-dev.ts": "dbbbe33f470e219d5fdaa6ec8e4998be95356e80756f29c18e3656562523923c", @@ -1212,6 +1226,7 @@ "packages/core/src/tool/websearch.ts": "bd0502eeb9d6deeea975a21a592040b5b48abd6f2613e39dd6cfbd1a959c950a", "packages/core/src/tool/write.ts": "4759d89e4b6cd5fa7d7de4ba091d2bf859d830cb15038828a15751c80fd9b0ec", "packages/core/src/v1/session.ts": "53714b7754a3adecbed4e28d511f5c1ef97ae2fa379e3a07cc68714e1de8469e", + "packages/core/test/filesystem/watcher.test.ts": "ade83f34270bc33c0a8cfd91766d5400a29927818f9b7f33e364bc26132ef7d9", "packages/core/test/fixture/location.ts": "8e93d87c20305911dc4583ab1717766b1f70eed3e687dac040ad2c646c25ceea", "packages/core/test/location-mutation.test.ts": "2c4c8faa220cc8fabfe12d57bd74ec8dd8aac8485d0ecafd2761cdee1806d6b0", "packages/core/test/system-context/builtins.test.ts": "2331a35ab10b3e69f5f5d2dd462ec56000246a747bccec77db783144a91ebbea", @@ -1264,8 +1279,8 @@ "packages/session-ui/src/v2/components/file-name-picker.test.tsx": "9fb252f376227adb076c188baaafb4d8b563b7fec20eb5944679f665b510dbee", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea", "packages/session-ui/src/v2/components/prompt-input/index.tsx": "6cbd3a1e3a4fd789284526e5981c6fd2d4952f3926c5a1be0609bb6832453997", - "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "2f4c68264d48b6cd8cf7223b044ca4e319ecac6c3fdc37d82038469abd9204f9", - "packages/session-ui/src/v2/components/session-review-v2.css": "42a531def38524d00bc45daab9b30dfdf81762009224c327adbe2d2b41983f4e", + "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "b83a8484f88ad4f1b6dc876c3b43fed8f2d9306c535c7723c7278c9ac2eb4683", + "packages/session-ui/src/v2/components/session-review-v2.css": "9cc0a7418e5923ac3915298410af5c02e9c170bf3b415fe56b9037f3cabb8c7e", "packages/session-ui/src/v2/components/session-review-v2.tsx": "a683eb509c3ee927a7ed04ceffef3fd65129f812ac55f5b04c7e628bf0611831", "packages/session-ui/src/v2/components/tool-error-card-v2.css": "9eb0bf8b583a18159f3855465118ff6a853d0193ea783037d5c90a868fdb3ff1", "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", @@ -1471,7 +1486,7 @@ "packages/ui/src/v2/components/badge-v2.css": "32135a9df5bad856a10138e00a2cb3ede7a6fc0cc2398797ad6bed9335d1f845", "packages/ui/src/v2/components/dialog-v2.css": "ae6f7b1de755929ba529b9dbb0e837c80f620f00a77188b9d2c59f10b0f28bbd", "packages/ui/src/v2/components/icon-button-v2.tsx": "966e535e3efe503f054045be4476fc20d2fad36092410b52f75e4cf8de6fce6f", - "packages/ui/src/v2/components/icon.tsx": "9a84a0bac6265b9ab7ddcd81bfed450790198caf6b4e8b54ba02806a3b9b040b", + "packages/ui/src/v2/components/icon.tsx": "243cbd4f869bac4d67cd2ef3c9b60e926eb79ba8e01afffd7b3ffdcc3f9e0ad1", "packages/ui/src/v2/components/menu-v2.css": "2f262a6c456a8e3cb9027f0b8b3bb3c4327794bd80cadd3cf8c16d08e6125d4d", "packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559", "packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx index 37ffe426..a57c3042 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -38,7 +38,7 @@ import { statusTriggerVisibility } from "../status-popover-model" import { useServerSync } from "@/context/server-sync" import { useGlobal } from "@/context/global" import { base64Encode } from "@opencode-ai/core/util/encode" -import { sortedRootSessions } from "@/pages/layout/helpers" +import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers" import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" @@ -727,8 +727,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) if (!conn) return [] const ctx = globalCtx.ensureServerCtx(conn) if (!ctx) return [] - const projects = ctx.projects.list() - const directories = projects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]) + const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? []) const seen = new Set() const sessions: Session[] = [] for (const dir of directories) { @@ -745,6 +744,21 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } }) + // Fresh clients have no bootstrapped child stores for the fallback + // directories (the dropdown reads with bootstrap: false) — kick the loads + // once per open. Converges: re-runs find the stores populated and skip. + createEffect(() => { + if (!open()) return + const conn = server.current + if (!conn) return + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return + for (const dir of sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])) { + const [store] = ctx.sync.child(dir, { bootstrap: false }) + if ((store.session?.length ?? 0) === 0) ctx.sync.project.loadSessions(dir, { limit: 50 }) + } + }) + // Sort: open-tab sessions first const sortedActiveSessions = createMemo(() => { if (!open()) return [] diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx new file mode 100644 index 00000000..b9140d78 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx @@ -0,0 +1,764 @@ +import type { + Config, + OpencodeClient, + Path, + Project, + ProviderAuthResponse, + SessionStatus, +} from "@opencode-ai/sdk/v2/client" +import { showToast } from "@/utils/toast" +import { getFilename } from "@opencode-ai/core/util/path" +import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useLanguage } from "@/context/language" +import type { InitError } from "../pages/error" +import { ServerSDK } from "./server-sdk" +import { + bootstrapDirectory, + bootstrapGlobal, + clearProviderRev, + loadAgentsQuery, + loadCommands, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, + loadReferencesQuery, +} from "./global-sync/bootstrap" +import { createChildStoreManager } from "./global-sync/child-store" +import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" +import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" +import { trimSessions } from "./global-sync/session-trim" +import type { ProjectMeta } from "./global-sync/types" +import { SESSION_RECENT_LIMIT } from "./global-sync/types" +import { formatServerError } from "@/utils/server-errors" +import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import type { SolidQueryOptions } from "@tanstack/solid-query" +import { createRefreshQueue } from "./global-sync/queue" +import { directoryKey } from "./global-sync/utils" +import { PathKey } from "@/utils/path-key" +import { createDirSyncContext } from "./directory-sync" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import { createRefCountMap } from "@/utils/refcount" +import { useGlobal } from "./global" +import { ServerConnection, useServer } from "./server" +import { retry } from "@opencode-ai/core/util/retry" +import type { ServerScope } from "@/utils/server-scope" +import { createHomeSessionIndexCache } from "./global-sync/home-session-index" +import { persisted } from "@/utils/persist" +import type { ServerApi } from "@/utils/server" +import type { + McpListInput, + McpListOutput, + McpResource, + McpResourceCatalogInput, + McpResourceCatalogOutput, + McpServer, + SessionActiveOutput, +} from "@opencode-ai/client/promise" +import { toggleMcp } from "./global-sync/mcp" +import { createServerSession, type ServerSession } from "./server-session" + +type GlobalStore = { + ready: boolean + error?: InitError + path: Path + project: Project[] + provider: NormalizedProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" +} + +type McpListApi = { + readonly list: (input?: McpListInput) => Promise +} + +type McpResourceApi = { + readonly resource: { + readonly catalog: (input?: McpResourceCatalogInput) => Promise + } +} + +type ApiQueryOptions = SolidQueryOptions & { + initialData?: undefined + queryKey: K +} + +type SessionActiveApi = { + readonly active: () => Promise +} + +export const loadMcpQuery = ( + scope: ServerScope, + directory: string, + api: McpListApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcp"] + >({ + queryKey: [scope, directory, "mcp"] as const, + queryFn: async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} + return api + .list({ location: { directory } }) + .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) + }, + }) + +export const loadMcpResourcesQuery = ( + scope: ServerScope, + directory: string, + api: McpResourceApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcpResources"] + >({ + queryKey: [scope, directory, "mcpResources"] as const, + queryFn: async () => { + if ((await protocol) === "v1" && legacy) { + return Object.fromEntries( + Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ + key, + { ...resource, server: resource.client }, + ]), + ) + } + return api.resource + .catalog({ location: { directory } }) + .then((result) => + Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), + ) + }, + placeholderData: {}, + }) + +export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions({ + queryKey: [scope, directory, "lsp"] as const, + queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), + }) + +export const loadActiveSessionsQuery = ( + scope: ServerScope, + api: SessionActiveApi, +): ApiQueryOptions => + queryOptions({ + queryKey: [scope, "activeSessions"] as const, + queryFn: () => api.active(), + enabled: true, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + +export function seedActiveSessionStatuses( + session: Pick, + active: SessionActiveOutput | Record, +) { + for (const sessionID of Object.keys(active)) { + if (session.data.session_status[sessionID] !== undefined) continue + const status = active[sessionID] + session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) + } +} + +function makeQueryOptionsApi( + scope: ServerScope, + serverSDK: () => OpencodeClient, + serverAPI: ServerApi, + sdkFor: (dir: PathKey) => OpencodeClient, + protocol: Promise<"v1" | "v2">, +) { + return { + globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol), + projects: () => loadProjectsQuery(scope, serverAPI.project), + providers: (directory: PathKey | null) => + loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), + path: (directory: PathKey | null) => + loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), + references: (directory: PathKey) => + loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + mcpResources: (directory: PathKey) => + loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), + sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), + } +} +export type QueryOptionsApi = ReturnType + +export function createServerSyncContextInner(serverSDK: ServerSDK) { + const language = useLanguage() + const owner = getOwner() + if (!owner) throw new Error("ServerSync must be created within owner") + + const sdkCache = new Map() + const booting = new Map>() + const sessionLoads = new Map>() + const sessionMeta = new Map() + + const sdkFor = (directory: string) => { + const key = directoryKey(directory) + const cached = sdkCache.get(key) + if (cached) return cached + const sdk = serverSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(key, sdk) + return sdk + } + + const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { + protocol: serverSDK.protocol, + }) + const queryOptionsApi = makeQueryOptionsApi( + serverSDK.scope, + () => serverSDK.client, + serverSDK.api, + sdkFor, + serverSDK.protocol, + ) + + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ + queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], + })) + const activeSessionsQuery = useQuery(() => + loadActiveSessionsQuery(serverSDK.scope, { + active: async () => { + if ((await serverSDK.protocol) === "v1") { + const statuses = (await serverSDK.client.session.status()).data ?? {} + seedActiveSessionStatuses(session, statuses) + for (const sessionID of Object.keys(statuses)) { + void session.resolve(sessionID).catch(() => undefined) + } + return Object.fromEntries( + Object.entries(statuses).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + } + const active = await serverSDK.api.session.active() + seedActiveSessionStatuses(session, active) + for (const sessionID of Object.keys(active)) { + void session.resolve(sessionID).catch(() => undefined) + } + return active + }, + }), + ) + + const [globalStore, setGlobalStore] = createStore({ + get ready() { + return !bootstrap.isPending + }, + project: [], + provider_auth: {}, + get path() { + const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } + if (pathQuery.isLoading) return EMPTY + return pathQuery.data ?? EMPTY + }, + get provider() { + const EMPTY = { all: new Map(), connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + return providerQuery.data ?? EMPTY + }, + get config() { + if (configQuery.isLoading) return {} + return configQuery.data ?? {} + }, + get reload() { + return updateConfigMutation.isPending ? "pending" : undefined + }, + }) + + const queryClient = useQueryClient() + const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) + const refreshProviders = () => + queryClient.refetchQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) + + let bootedAt = 0 + let bootingRoot = false + let eventFrame: number | undefined + let eventTimer: ReturnType | undefined + + onCleanup(() => { + if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) + if (eventTimer !== undefined) clearTimeout(eventTimer) + }) + + const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { + setGlobalStore("project", next) + } + + const setBootStore = ((...input: unknown[]) => { + if (input[0] === "project" && Array.isArray(input[1])) { + setProjects(input[1] as Project[]) + return input[1] + } + return (setGlobalStore as (...args: unknown[]) => unknown)(...input) + }) as typeof setGlobalStore + + const bootstrap = useQuery(() => ({ + queryKey: [serverSDK.scope, "bootstrap"], + queryFn: async () => { + await bootstrapGlobal({ + serverSDK: serverSDK.client, + serverAPI: serverSDK.api, + protocol: serverSDK.protocol, + scope: serverSDK.scope, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + queryClient, + }) + bootedAt = Date.now() + return bootedAt + }, + })) + + const set = ((...input: unknown[]) => { + if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { + setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) + return input[1] + } + return (setGlobalStore as (...args: unknown[]) => unknown)(...input) + }) as typeof setGlobalStore + + const paused = () => untrack(() => globalStore.reload) !== undefined + + const queue = createRefreshQueue({ + paused, + key: directoryKey, + bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }), + bootstrapInstance, + }) + + const children = createChildStoreManager({ + owner, + scope: serverSDK.scope, + persist: persisted, + isBooting: (directory) => booting.has(directory), + isLoadingSessions: (directory) => sessionLoads.has(directory), + onBootstrap: (directory) => { + void bootstrapInstance(directory) + }, + onMcp: (directory, setStore) => { + void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) + .then((commands) => setStore("command", commands)) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) + }) + }, + onDispose: (directory) => { + const key = directoryKey(directory) + queue.clear(key) + sessionMeta.delete(key) + sdkCache.delete(key) + clearProviderRev(serverSDK.scope, key) + }, + translate: language.t, + queryOptions: queryOptionsApi, + global: { + provider: globalStore.provider, + }, + }) + + async function loadSessions(directory: string, options?: { limit?: number }) { + const key = directoryKey(directory) + const pending = sessionLoads.get(key) + if (pending) { + await pending + return loadSessions(directory, options) + } + + children.pin(key) + const [store, setStore] = children.child(directory, { bootstrap: false }) + const meta = sessionMeta.get(key) + const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) + if (meta && meta.limit >= retainedLimit) { + const next = trimSessions(store.session, { + limit: retainedLimit, + permission: session.data.permission, + }) + if (next.length !== store.session.length) { + setStore("session", reconcile(next, { key: "id" })) + } + children.unpin(key) + return + } + + const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) + const promise = queryClient + .fetchQuery({ + ...queryOptionsApi.sessions(key), + queryFn: () => + serverSDK.protocol + .then((protocol) => + protocol === "v1" + ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) + : loadRootSessions({ api: serverSDK.api.session, directory, limit }), + ) + .then((x) => { + const nonArchived = (x.data ?? []) + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + // amicode#288: the fetch limit must be the floor here. Re-deriving + // it from a fresh store (limit 0) trimmed everything older than + // SESSION_RECENT_WINDOW out of the store — the dropdown and home + // list then showed only the last few hours of history. + const retained = Math.max(limit, store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) + const childSessions = store.session.filter((s) => !!s.parentID) + const next = trimSessions([...nonArchived, ...childSessions], { + limit: retained, + permission: session.data.permission, + }) + batch(() => { + next.forEach(session.remember) + setStore( + "sessionTotal", + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), + ) + setStore("session", reconcile(next, { key: "id" })) + }) + sessionMeta.set(key, { limit: retained }) + }) + .catch((err) => { + console.error("Failed to load sessions", err) + const project = getFilename(directory) + showToast({ + variant: "error", + title: language.t("toast.session.listFailed.title", { project }), + description: formatServerError(err, language.t), + }) + }) + .then(() => null), + }) + .then(() => {}) + + sessionLoads.set(key, promise) + void promise.finally(() => { + sessionLoads.delete(key) + children.unpin(key) + }) + return promise + } + + async function bootstrapInstance(directory: string) { + const key = directoryKey(directory) + if (!key) return + const pending = booting.get(key) + if (pending) return pending + + children.pin(key) + const promise = Promise.resolve().then(async () => { + const child = children.ensureChild(directory) + const cache = children.vcsCache.get(key) + if (!cache) return + const sdk = sdkFor(directory) + await bootstrapDirectory({ + directory, + scope: serverSDK.scope, + mcp: children.mcp(key), + global: { + config: globalStore.config, + path: globalStore.path, + project: globalStore.project, + provider: globalStore.provider, + }, + sdk, + api: serverSDK.api, + store: child[0], + setStore: child[1], + vcsCache: cache, + loadSessions, + translate: language.t, + queryClient, + session, + protocol: serverSDK.protocol, + }) + }) + + booting.set(key, promise) + void promise.finally(() => { + booting.delete(key) + children.unpin(key) + }) + return promise + } + + const indexSession = (info: Parameters[0]) => { + const key = directoryKey(info.directory) + const existing = children.children[key] + if (!existing) return + applyDirectoryEvent({ + event: { type: "session.created", properties: { info } }, + directory: key, + store: existing[0], + setStore: existing[1], + push: queue.push, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + + const unsub = serverSDK.event.listen((e) => { + const directory = e.name + const key = directoryKey(directory) + const event = e.details + const eventType: string = event.type + const recent = bootingRoot || Date.now() - bootedAt < 1500 + + if (event.current) session.applyV2(event.current) + session.apply(event) + if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { + homeSessions.apply(event) + } + homeSessions.refresh(event.type) + if (eventType === "integration.connection.updated") void refreshProviders() + + if (directory === "global") { + if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) + void activeSessionsQuery.refetch() + applyGlobalEvent({ + event, + project: globalStore.project, + refresh: () => { + if (recent) return + bootstrap.refetch() + }, + setGlobalProject: setProjects, + }) + if ( + eventType === "config.updated" || + eventType === "catalog.updated" || + eventType === "agent.updated" || + eventType === "project.directories.updated" + ) + bootstrap.refetch() + if (eventType === "server.connected" || eventType === "global.disposed") { + if (recent) return + for (const directory of Object.keys(children.children)) { + if (!children.active(directory)) continue + queue.push(directory) + } + } + return + } + + if (event.current?.type === "session.moved") { + const info = session.get(event.current.data.sessionID) + if (info) indexSession(info) + } + if (event.current?.type === "session.forked") + void session + .resolve(event.current.data.sessionID, { force: true }) + .then(indexSession) + .catch(() => {}) + + const existing = children.children[key] + if (!existing) return + children.mark(key) + if ( + event.current?.type === "session.moved" || + // event.current?.type === "session.archived" || + event.current?.type === "session.forked" || + eventType === "command.updated" || + eventType === "config.updated" || + eventType === "agent.updated" + ) + queue.push(key) + if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) + if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) + const [store, setStore] = existing + applyDirectoryEvent({ + event, + directory, + store, + setStore, + push: (directory) => { + if (children.active(directory)) queue.push(directory) + }, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + vcsCache: children.vcsCache.get(key), + loadLsp: () => { + if (!children.active(key)) return + void queryClient.fetchQuery(queryOptionsApi.lsp(key)) + }, + loadReferences: () => { + if (!children.active(key)) return + void queryClient.fetchQuery(queryOptionsApi.references(key)) + }, + }) + }) + + onCleanup(unsub) + onCleanup(() => { + queue.dispose() + }) + onCleanup(() => { + for (const directory of Object.keys(children.children)) { + children.disposeDirectory(directoryKey(directory)) + } + }) + + onMount(() => { + if (typeof requestAnimationFrame === "function") { + eventFrame = requestAnimationFrame(() => { + eventFrame = undefined + eventTimer = setTimeout(() => { + eventTimer = undefined + void serverSDK.event.start() + }, 0) + }) + } else { + eventTimer = setTimeout(() => { + eventTimer = undefined + void serverSDK.event.start() + }, 0) + } + }) + + const projectApi = { + loadSessions, + meta(directory: string, patch: ProjectMeta) { + children.projectMeta(directory, patch) + }, + icon(directory: string, value: string | undefined) { + children.projectIcon(directory, value) + }, + } + + const updateConfigMutation = useMutation(() => ({ + mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), + onSuccess: () => { + bootstrap.refetch() + // Invalidate all provider queries so newly configured custom providers + // appear immediately in the available provider list across all directories. + queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) + }, + })) + + return { + data: globalStore, + set, + get ready() { + return globalStore.ready + }, + get error() { + return globalStore.error + }, + child: children.child, + peek: children.peek, + disableMcp: children.disableMcp, + queryOptions: queryOptionsApi, + refreshProviders, + // bootstrap, + updateConfig: updateConfigMutation.mutateAsync, + project: projectApi, + session, + homeSessions, + mcp: { + toggle: async (directory: string, name: string) => { + const key = directoryKey(directory) + const sdk = sdkFor(key) + const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status + if (!status) return + await toggleMcp({ + status, + connect: async () => { + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.connect({ name }) + return + } + await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) + }, + disconnect: async () => { + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.disconnect({ name }) + return + } + await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) + }, + authenticate: async () => { + await sdk.mcp.auth.authenticate({ name }) + }, + refresh: async () => { + await queryClient.refetchQueries(queryOptionsApi.mcp(key)) + await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) + }, + }) + }, + }, + } +} + +export function createServerSyncContext(serverSDK: ServerSDK) { + const inner = createServerSyncContextInner(serverSDK) + return Object.assign(inner, { + ensureDirSyncContext: createRefCountMap( + (dir) => createDirSyncContext(dir, inner, serverSDK), + (dir) => inner.disableMcp(dir), + directoryKey, + ), + }) +} + +export type ServerSync = ReturnType + +export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ + name: "ServerSync", + // Returns an accessor so the resolved server can change reactively without + // re-instantiating the subtree (mirrors useServerSDK). + init: (props: { server?: Accessor }) => { + const global = useGlobal() + const language = useLanguage() + const server = useServer() + + return createMemo(() => { + const conn = props.server?.() ?? server.current + if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) + return global.ensureServerCtx(conn).sync + }) + }, +}) + +export function useQueryOptions() { + const sync = useServerSync() + return createMemo(() => sync().queryOptions) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home.tsx index fef093f9..71b62fa3 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/home.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/home.tsx @@ -49,6 +49,7 @@ import { homeProjectDirectories, homeProjectNavigation, projectForSession, + sessionListDirectories, sortedRootSessions, toggleHomeProjectSelection, } from "@/pages/layout/helpers" @@ -287,7 +288,11 @@ function HomeDesign() { // lists, so it must load EVERY project's sessions regardless of selection. // Scoping to the selected project (the old behavior) made the flat list flip // empty/populated with selection and hid non-selected projects' sessions. - const projectDirectories = createMemo(() => projects().flatMap(directories)) + // amicode#288: a fresh client has an empty opened-projects registry — fall + // back to the server's registered projects so history is still listed. + const projectDirectories = createMemo(() => + sessionListDirectories(projects(), focusedSync().data.project ?? []), + ) const search = createMemo(() => state.search.trim()) const sessionLoad = useQuery(() => ({ queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.test.ts new file mode 100644 index 00000000..5dfa543c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, test } from "bun:test" +import { + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + drainPendingDeepLinks, + parseDeepLink, + parseNewSessionDeepLink, +} from "./deep-links" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { + childSessionOnPath, + closeHomeProject, + displayName, + effectiveWorkspaceOrder, + errorMessage, + hasProjectPermissions, + homeProjectNavigation, + homeProjectDirectories, + homeSessionServerStatus, + latestRootSession, + sessionListDirectories, + toggleHomeProjectSelection, +} from "./helpers" +import { pathKey } from "@/utils/path-key" +import { ServerConnection } from "@/context/server" + +const serverKey = ServerConnection.Key.make + +const session = (input: Partial & Pick) => + ({ + title: "", + version: "v2", + parentID: undefined, + messageCount: 0, + permissions: { session: {}, share: {} }, + time: { created: 0, updated: 0, archived: undefined }, + ...input, + }) as Session + +describe("layout deep links", () => { + test("parses open-project deep links", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + }) + + test("ignores non-project deep links", () => { + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("https://example.com")).toBeUndefined() + }) + + test("ignores malformed deep links safely", () => { + expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow() + expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined() + }) + + test("parses links when URL.canParse is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(URL, "canParse") + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) + try { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + } finally { + if (original) Object.defineProperty(URL, "canParse", original) + if (!original) Reflect.deleteProperty(URL, "canParse") + } + }) + + test("ignores open-project deep links without directory", () => { + expect(parseDeepLink("opencode://open-project")).toBeUndefined() + expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined() + }) + + test("collects only valid open-project directories", () => { + const result = collectOpenProjectDeepLinks([ + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", + ]) + expect(result).toEqual(["/a", "/c"]) + }) + + test("parses new-session deep links with optional prompt", () => { + expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" }) + expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({ + directory: "/tmp/demo", + prompt: "hello world", + }) + }) + + test("ignores new-session deep links without directory", () => { + expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined() + expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined() + }) + + test("collects only valid new-session deep links", () => { + const result = collectNewSessionDeepLinks([ + "opencode://new-session?directory=/a", + "opencode://open-project?directory=/b", + "opencode://new-session?directory=/c&prompt=ship%20it", + ]) + expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }]) + }) + + test("drains global deep links once", () => { + const target = { + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) +}) + +describe("layout workspace helpers", () => { + test("normalizes trailing slash in workspace key", () => { + expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo") + expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo") + }) + + test("preserves posix and drive roots in workspace key", () => { + expect(String(pathKey("/"))).toBe("/") + expect(String(pathKey("///"))).toBe("/") + expect(String(pathKey("C:\\"))).toBe("C:/") + expect(String(pathKey("C://"))).toBe("C:/") + expect(String(pathKey("C:///"))).toBe("C:/") + }) + + test("keeps local first while preserving known order", () => { + const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) + expect(result).toEqual(["/root", "/c", "/b"]) + }) + + test("finds the latest root session across workspaces", () => { + const result = latestRootSession( + [ + { + path: { directory: "/root" }, + session: [session({ id: "root", directory: "/root", time: { created: 1, updated: 1, archived: undefined } })], + }, + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "workspace", + directory: "/workspace", + time: { created: 2, updated: 2, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("workspace") + }) + + test("detects project permissions with a filter", () => { + const result = hasProjectPermissions( + { + root: [{ id: "perm-root" }, { id: "perm-hidden" }], + child: [{ id: "perm-child" }], + }, + (item) => item.id === "perm-child", + ) + + expect(result).toBe(true) + }) + + test("ignores project permissions filtered out", () => { + const result = hasProjectPermissions( + { + root: [{ id: "perm-root" }], + }, + () => false, + ) + + expect(result).toBe(false) + }) + + test("ignores archived and child sessions when finding latest root session", () => { + const result = latestRootSession( + [ + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "archived", + directory: "/workspace", + time: { created: 10, updated: 10, archived: 10 }, + }), + session({ + id: "child", + directory: "/workspace", + parentID: "parent", + time: { created: 20, updated: 20, archived: undefined }, + }), + session({ + id: "root", + directory: "/workspace", + time: { created: 30, updated: 30, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("root") + }) + + test("finds the direct child on the active session path", () => { + const list = [ + session({ id: "root", directory: "/workspace" }), + session({ id: "child", directory: "/workspace", parentID: "root" }), + session({ id: "leaf", directory: "/workspace", parentID: "child" }), + ] + + expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child") + expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf") + expect(childSessionOnPath(list, "root", "root")).toBeUndefined() + expect(childSessionOnPath(list, "root", "other")).toBeUndefined() + }) + + test("formats fallback project display name", () => { + expect(displayName({ worktree: "/tmp/app" })).toBe("app") + expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") + expect(displayName({ worktree: "/" })).toBe("/") + }) + + test("scopes home project selection by server", () => { + expect( + toggleHomeProjectSelection(undefined, serverKey("https://debian.example"), "/home/luke/repos/amazon"), + ).toEqual({ + server: serverKey("https://debian.example"), + directory: "/home/luke/repos/amazon", + }) + expect( + toggleHomeProjectSelection( + { server: serverKey("https://windows.example"), directory: "/home/luke/repos/amazon" }, + serverKey("https://debian.example"), + "/home/luke/repos/amazon", + ), + ).toEqual({ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" }) + expect( + toggleHomeProjectSelection( + { server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" }, + serverKey("https://debian.example"), + "/home/luke/repos/amazon", + ), + ).toEqual({ server: serverKey("https://debian.example") }) + }) + + test("closes a home project through its server context", () => { + const closed: string[] = [] + + expect( + closeHomeProject( + { server: serverKey("https://windows.example"), directory: "/shared" }, + serverKey("https://debian.example"), + { close: (directory) => closed.push(directory) }, + "/shared", + ), + ).toEqual({ server: serverKey("https://windows.example"), directory: "/shared" }) + expect(closed).toEqual(["/shared"]) + expect( + closeHomeProject( + { server: serverKey("https://debian.example"), directory: "/shared" }, + serverKey("https://debian.example"), + { close: (directory) => closed.push(directory) }, + "/shared", + ), + ).toEqual({ server: serverKey("https://debian.example") }) + }) + + test("defers home project navigation until its server is active", () => { + expect( + homeProjectNavigation(serverKey("sidecar"), serverKey("https://debian.example"), "/YW1hem9u/session"), + ).toEqual({ + server: serverKey("https://debian.example"), + href: "/YW1hem9u/session", + }) + expect( + homeProjectNavigation( + serverKey("https://debian.example"), + serverKey("https://debian.example"), + "/YW1hem9u/session", + ), + ).toEqual({ + href: "/YW1hem9u/session", + }) + }) + + test("preserves picker order when adding multiple projects", () => { + expect(homeProjectDirectories(["/first", "/second"])).toEqual(["/first", "/second"]) + expect(homeProjectDirectories("/only")).toEqual(["/only"]) + expect(homeProjectDirectories(null)).toEqual([]) + }) + + test("hides status derived from an inactive server", () => { + let reads = 0 + const status = () => { + reads++ + return { working: true, tint: "red" } + } + expect(homeSessionServerStatus(false, status)).toEqual({ + working: false, + tint: undefined, + }) + expect(reads).toBe(0) + expect(homeSessionServerStatus(true, status)).toEqual({ + working: true, + tint: "red", + }) + expect(reads).toBe(1) + }) + + test("extracts api error message and fallback", () => { + expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom") + expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") + expect(errorMessage("unknown", "fallback")).toBe("fallback") + }) + + describe("sessionListDirectories", () => { + test("uses opened projects when present", () => { + const opened = [{ worktree: "/a", sandboxes: ["/a-sbx"] }, { worktree: "/b" }] + const server = [{ worktree: "/server", sandboxes: ["/server-sbx"] }] + expect(sessionListDirectories(opened, server)).toEqual(["/a", "/a-sbx", "/b"]) + }) + + test("falls back to server projects when nothing is opened (fresh client, amicode#288)", () => { + const server = [ + { worktree: "/", sandboxes: ["/staging"] }, + { worktree: "/amicode" }, + { worktree: "/opencode" }, + ] + expect(sessionListDirectories([], server)).toEqual(["/", "/staging", "/amicode", "/opencode"]) + }) + + test("fallback dedupes and drops empty entries", () => { + const server = [ + { worktree: "/a", sandboxes: ["/a"] }, + { worktree: "/a" }, + { worktree: "", sandboxes: [""] }, + ] + expect(sessionListDirectories([], server)).toEqual(["/a"]) + }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts new file mode 100644 index 00000000..4105a082 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts @@ -0,0 +1,172 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { pathKey } from "@/utils/path-key" +import type { ServerConnection } from "@/context/server" +import type { HomeProjectSelection } from "@/context/layout" + +type SessionStore = { + session?: Session[] + path: { directory: string } +} + +function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +const isRootVisibleSession = (session: Session, directory: string) => + pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived + +export const roots = (store: SessionStore) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) + +export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) + +export const latestRootSession = (stores: SessionStore[], now: number) => + stores.flatMap(roots).sort(sortSessions(now))[0] + +export function hasProjectPermissions( + request: Record | undefined, + include: (item: T) => boolean = () => true, +) { + return Object.values(request ?? {}).some((list) => list?.some(include)) +} + +export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => { + if (!activeID || activeID === rootID) return + const map = new Map((sessions ?? []).map((session) => [session.id, session])) + let id = activeID + + while (id) { + const session = map.get(id) + if (!session?.parentID) return + if (session.parentID === rootID) return session + id = session.parentID + } +} + +export const displayName = (project: { name?: string; worktree: string }) => + project.name || getFilename(project.worktree) || project.worktree + +/** + * Directories whose sessions the list surfaces (Sessions dropdown, home list) + * aggregate over: the user's opened projects, falling back to the server's + * registered projects when nothing has been opened yet (fresh client, empty + * persisted registry — amicode#288). Session listing only; this must NOT feed + * the project switcher, or closing a server-registered project becomes + * impossible (the fallback would re-add it). + */ +export function sessionListDirectories( + opened: { worktree: string; sandboxes?: string[] }[], + serverProjects: { worktree: string; sandboxes?: string[] }[], +): string[] { + const dirs = opened.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]) + if (dirs.length > 0) return dirs + const seen = new Set() + return serverProjects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]).filter((d) => { + if (!d || seen.has(d)) return false + seen.add(d) + return true + }) +} + +export function toggleHomeProjectSelection( + current: HomeProjectSelection | undefined, + server: ServerConnection.Key, + directory: string, +): HomeProjectSelection { + if (current?.server === server && current.directory === directory) return { server } + return { server, directory } +} + +export function closeHomeProject( + selected: HomeProjectSelection | undefined, + server: ServerConnection.Key, + projects: { close: (directory: string) => void }, + directory: string, +) { + projects.close(directory) + if (selected?.server === server && selected.directory === directory) return { server } + return selected +} + +export function homeProjectNavigation(active: ServerConnection.Key, server: ServerConnection.Key, href: string) { + if (active === server) return { href } + return { server, href } +} + +export function homeProjectDirectories(result: string | string[] | null) { + if (!result) return [] + return Array.isArray(result) ? result : [result] +} + +export function homeSessionServerStatus(active: boolean, status: () => { working: boolean; tint?: string }) { + if (!active) return { working: false, tint: undefined } + return status() +} + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg" + if (icon?.override) return icon.override + if (icon?.color) return undefined + return icon?.url +} + +export function projectForSession( + session: Session, + projects: T[], + byID: Map = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))), +) { + const direct = byID.get(session.projectID) + if (direct) return direct + const directory = pathKey(session.directory) + return projects.find( + (project) => + pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) +} + +export const errorMessage = (err: unknown, fallback: string) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return fallback +} + +export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { + const root = pathKey(local) + const live = new Map() + + for (const dir of dirs) { + const key = pathKey(dir) + if (key === root) continue + if (!live.has(key)) live.set(key, dir) + } + + if (!persisted?.length) return [local, ...live.values()] + + const result = [local] + for (const dir of persisted) { + const key = pathKey(dir) + if (key === root) continue + const match = live.get(key) + if (!match) continue + result.push(match) + live.delete(key) + } + + return [...result, ...live.values()] +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx index f40c172c..3334c753 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx @@ -1,7 +1,7 @@ import type { FilePart, Project, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createQuery, skipToken, useMutation } from "@tanstack/solid-query" +import { createQuery, keepPreviousData, skipToken, useMutation } from "@tanstack/solid-query" import { batch, ErrorBoundary, @@ -695,7 +695,7 @@ export default function Page() { return { queryKey: sessionDiffKey(), enabled: !!sessionID, - placeholderData: [] as SnapshotFileDiff[], + placeholderData: keepPreviousData, queryFn: sessionID ? () => sdk() @@ -708,7 +708,11 @@ export default function Page() { const reviewDiffs = createMemo(() => { // Server endpoint returns the authoritative full-session diff (queries all messages). const serverDiffs = sessionDiffQuery.data ?? [] - if (serverDiffs.length > 0) { + // Once the server has responded at least once, trust it — even if it returned []. + // The client-side fallback is only for the initial load before any server response, + // never during refetches (where keepPreviousData already preserves the last result). + const serverResponded = sessionDiffQuery.status === "success" || sessionDiffQuery.isPlaceholderData + if (serverDiffs.length > 0 || serverResponded) { // Server paths are relative to the project root — prefix with ~/project-path const dir = sdk().directory const home = typeof globalThis.process !== "undefined" ? globalThis.process.env?.HOME : undefined @@ -761,7 +765,7 @@ export default function Page() { return { queryKey: ["session-touched-files", sessionID ?? "", sessionDiffVersion()] as const, enabled: !!sessionID, - placeholderData: [] as Array<{ file: string; status: string }>, + placeholderData: keepPreviousData, staleTime: 30_000, queryFn: sessionID ? async () => { @@ -1011,6 +1015,9 @@ export default function Page() { ), ) + // Bump diff_version when the watcher reports external changes to files in the + // current session's worktree (#743). Debounced: 1s trailing-edge per session. + let watcherDebounce: ReturnType | undefined const stopVcs = sdk().event.listen((evt) => { const details = evt.details as { type: string; properties?: unknown } if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return @@ -1020,8 +1027,18 @@ export default function Page() { : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return + const id = params.id + if (!id) return + if (watcherDebounce !== undefined) clearTimeout(watcherDebounce) + watcherDebounce = setTimeout(() => { + watcherDebounce = undefined + sync().set("diff_version", id, (v: number | undefined) => (v ?? 0) + 1) + }, 1000) + }) + onCleanup(() => { + stopVcs() + if (watcherDebounce !== undefined) clearTimeout(watcherDebounce) }) - onCleanup(stopVcs) createEffect( on( @@ -1302,6 +1319,10 @@ export default function Page() { }, onDiffStyleChange: layout.review.setDiffStyle, state: reviewV2State, + onRefresh: () => { + const id = params.id + if (id) sync().set("diff_version", id, (v: number | undefined) => (v ?? 0) + 1) + }, onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }), onLineCommentUpdate: updateCommentInContext, onLineCommentDelete: removeCommentFromContext, diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows-current.test.ts index 942ed881..8c09bafc 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -308,6 +308,58 @@ describe("current session timeline rows", () => { } }) + test("suppresses per-message DiffSummary rows even when summary.diffs is populated (#733)", () => { + const source = [ + { + id: "msg_u", + type: "user", + text: "edit some files", + time: { created: 1 }, + summary: { + additions: 10, + deletions: 3, + files: 2, + diffs: [ + { file: "src/foo.ts", additions: 7, deletions: 2 }, + { file: "src/bar.ts", additions: 3, deletions: 1 }, + ], + }, + }, + { + id: "msg_a", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "done" }], + time: { created: 2, completed: 3 }, + }, + ] as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + // Inject summary.diffs onto the user message (normalizeSessionMessages + // strips it, but the live store propagates it) + const userMsg = messages.get("msg_u")! + ;(userMsg as any).summary = (source[0] as any).summary + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + normalized.messages.filter((m) => m.role === "user"), + ) + + // No DiffSummary row should appear — the section is suppressed (#733) + const tags = result.rows.map((r) => r._tag) + expect(tags).not.toContain("DiffSummary") + // The normal rows are still present + expect(tags).toContain("UserMessage") + expect(tags).toContain("AssistantPart") + expect(tags).toContain("ThinkingMeta") + }) + test("turnStartedAt is threaded through Thinking and AssistantPart rows from user message time.created", () => { const source = [ { id: "msg_u", type: "user", text: "go", time: { created: 1000 } }, diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows.ts index 202c3e42..7f7e78c0 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/rows.ts @@ -3,7 +3,7 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" -import { uniqueSummaryDiffs } from "./summary-diffs" +// import { uniqueSummaryDiffs } from "./summary-diffs" // suppressed (harmoniqs/amicode#733) export { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -290,15 +290,9 @@ export namespace Timeline { if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id })) - const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs) - if (diffs.length > 0 && (status === "idle" || !isActive)) { - rows.push( - new TimelineRow.DiffSummary({ - userMessageID: userMessage.id, - diffs, - }), - ) - } + // Per-message "Changed files" section suppressed (harmoniqs/amicode#733): + // redundant with the side-panel Files Changed tab, and the unfiltered + // snapshot diff leaks cross-session file changes during concurrent turns. if (error) { const data = error.data?.message diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/accumulate-diffs.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/accumulate-diffs.test.ts index 422abf52..cec26586 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/accumulate-diffs.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/accumulate-diffs.test.ts @@ -91,4 +91,24 @@ describe("accumulateDiffs", () => { test("empty input returns empty output", () => { expect(accumulateDiffs([])).toEqual([]) }) + + test("multi-edit sums diverge from net diff (documents flash risk)", () => { + // A file edited 3 times: +3/-1, +2/-4, +1/-0 + // accumulateDiffs sums: +6/-5 + // A real git net diff might be +2/-1 (or anything else) + // This divergence is what the user sees as a "flash" when the client + // fallback briefly replaces the server's net diff during a refetch. + const result = accumulateDiffs([ + edit("src/a.ts", { additions: 3, deletions: 1, patch: "p1" }), + edit("src/a.ts", { additions: 2, deletions: 4, patch: "p2" }), + edit("src/a.ts", { additions: 1, deletions: 0, patch: "p3" }), + ]) + // The fallback SUMS, not nets — this is the documented behavior that + // makes the flash visible (different numbers than the server response). + expect(result[0].additions).toBe(6) + expect(result[0].deletions).toBe(5) + // A hypothetical net diff would be lower — the client must never show + // this stale/inflated data during a refetch. The fix is keepPreviousData + // on the query so the fallback never fires while server data exists. + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/review-panel-v2.tsx index 9767b0c1..a10099bb 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -46,6 +46,7 @@ export type ReviewPanelV2Props = { diffStyle: SessionReviewDiffStyle onDiffStyleChange?: (style: SessionReviewDiffStyle) => void state: ReviewPanelV2State + onRefresh?: () => void onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void @@ -152,6 +153,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffStyle={props.diffStyle} expandMode={props.state.expandMode()} readFile={readFile} + onRefresh={props.onRefresh} filePicker={({ onSelect }) => { const files = filteredFiles() diff --git a/packages/app-bundle/overlay/packages/core/src/filesystem/watcher.ts b/packages/app-bundle/overlay/packages/core/src/filesystem/watcher.ts new file mode 100644 index 00000000..a9cff44b --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/src/filesystem/watcher.ts @@ -0,0 +1,140 @@ +export * as Watcher from "./watcher" + +// @ts-ignore +import { createWrapper } from "@parcel/watcher/wrapper" +import type ParcelWatcher from "@parcel/watcher" +import { makeLocationNode } from "../effect/app-node" +import { Cause, Context, Effect, Layer } from "effect" +import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import path from "path" +import { Config } from "../config" +import { EventV2 } from "../event" +import { Flag } from "../flag/flag" +import { FSUtil } from "../fs-util" +import { Git } from "../git" +import { Location } from "../location" +import { lazy } from "../util/lazy" +import { Ignore } from "./ignore" +import { Protected } from "./protected" + +declare const OPENCODE_LIBC: string | undefined + +const SUBSCRIBE_TIMEOUT_MS = 10_000 + +export const Event = FileSystemWatcher.Event + +const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { + try { + const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC + const binding = require( + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, + ) + return createWrapper(binding) as typeof import("@parcel/watcher") + } catch { + return + } +}) + +function getBackend() { + if (process.platform === "win32") return "windows" + if (process.platform === "darwin") return "fs-events" + if (process.platform === "linux") return "inotify" +} + +function protecteds(dir: string) { + return Protected.paths().filter((item) => { + const relative = path.relative(dir, item) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + }) +} + +export const hasNativeBinding = () => !!watcher() + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/v2/FileWatcher") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) + + const backend = getBackend() + const location = yield* Location.Service + if (!backend) { + yield* Effect.logError("watcher backend not supported", { + directory: location.directory, + platform: process.platform, + }) + return Service.of({}) + } + + const w = watcher() + if (!w) return Service.of({}) + + yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const subscriptions: ParcelWatcher.AsyncSubscription[] = [] + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))), + ) + + const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { + for (const update of updates) { + if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) + if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) + if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" })) + } + } + + const subscribe = (directory: string, ignore: string[]) => { + const pending = w.subscribe(directory, callback, { ignore, backend }) + return Effect.promise(() => pending).pipe( + Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))), + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) + return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) + }), + ) + } + + const config = (yield* (yield* Config.Service).entries()) + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((item) => item.info.watcher?.ignore ?? []) + if (location.vcs) { + yield* Effect.forkScoped( + subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), + ) + } + + if (location.vcs?.type === "git") { + const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory + const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined + if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { + const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( + (entry) => (entry.name === "HEAD" ? [] : [entry.name]), + ) + yield* Effect.forkScoped(subscribe(vcs, ignore)) + } + } + + return Service.of({}) + }).pipe( + Effect.catchCause((cause) => { + return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( + Effect.as(Service.of({})), + ) + }), + ), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/app-bundle/overlay/packages/core/src/flag/flag.ts b/packages/app-bundle/overlay/packages/core/src/flag/flag.ts index 4c1d3616..4a7d58eb 100644 --- a/packages/app-bundle/overlay/packages/core/src/flag/flag.ts +++ b/packages/app-bundle/overlay/packages/core/src/flag/flag.ts @@ -35,9 +35,6 @@ export const Flag = { OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"), // Experimental - OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( - Config.withDefault(false), - ), OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), diff --git a/packages/app-bundle/overlay/packages/core/test/filesystem/watcher.test.ts b/packages/app-bundle/overlay/packages/core/test/filesystem/watcher.test.ts new file mode 100644 index 00000000..21909c76 --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/test/filesystem/watcher.test.ts @@ -0,0 +1,297 @@ +import { $ } from "bun" +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip + +type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node]))) + +const configLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => Effect.succeed([]), + }), +) + +const flagsLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) + +// Same flags — verifies the watcher runs without any explicit enable flag (#743). +const defaultFlagsLayer = flagsLayer + +function provide(directory: string, vcs?: Location.Interface["vcs"]) { + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), + ) + return Effect.provide( + AppNodeBuilder.build(Watcher.node, [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]).pipe(Layer.provide(flagsLayer)), + ) +} + +function provideDefault(directory: string, vcs?: Location.Interface["vcs"]) { + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), + ) + return Effect.provide( + AppNodeBuilder.build(Watcher.node, [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]).pipe(Layer.provide(defaultFlagsLayer)), + ) +} + +function withTmp( + f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect, + options?: { git?: boolean; init?: (directory: string) => Promise; useDefaultFlags?: boolean }, +) { + const providerFn = options?.useDefaultFlags ? provideDefault : provide + return Effect.acquireRelease( + Effect.promise(async () => { + const tmp = await tmpdir() + if (!options?.git) return { tmp, vcs: undefined } + await $`git init`.cwd(tmp.path).quiet() + await $`git config core.fsmonitor false`.cwd(tmp.path).quiet() + await $`git config commit.gpgsign false`.cwd(tmp.path).quiet() + await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet() + await $`git config user.name Test`.cwd(tmp.path).quiet() + await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet() + await options.init?.(tmp.path) + return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } } + }), + ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(providerFn(tmp.path, vcs)))) +} + +function wait(check: (event: WatcherEvent) => boolean) { + return Effect.gen(function* () { + const events = yield* EventV2.Service + const deferred = yield* Deferred.make() + const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( + Stream.runForEach((event) => { + if (!check(event.data)) return Effect.void + return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) + }), + Effect.forkScoped, + ) + yield* Effect.yieldNow + return { deferred, fiber } + }) +} + +function maybeNextUpdate( + check: (event: WatcherEvent) => boolean, + trigger: Effect.Effect, + timeout: Duration.Input = "5 seconds", +) { + return Effect.acquireUseRelease( + wait(check), + ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)), + ({ fiber }) => Fiber.interrupt(fiber), + ) +} + +function nextUpdate(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect) { + return Effect.gen(function* () { + const result = yield* maybeNextUpdate(check, trigger) + if (Option.isSome(result)) return result.value + return yield* Effect.fail(new Error("timed out waiting for file watcher update")) + }) +} + +function eventuallyUpdate(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect) { + return Effect.gen(function* () { + while (true) { + const result = yield* maybeNextUpdate(check, trigger(), "250 millis") + if (Option.isSome(result)) return result.value + } + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")), + }), + ) +} + +function noUpdate(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect, timeout = 500) { + return Effect.acquireUseRelease( + wait(check), + ({ deferred }) => + trigger.pipe( + Effect.andThen(Deferred.await(deferred)), + Effect.timeoutOption(`${timeout} millis`), + Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))), + ), + ({ fiber }) => Fiber.interrupt(fiber), + ) +} + +function ready(directory: string) { + const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`) + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* eventuallyUpdate( + (event) => event.file === file, + () => fs.writeFileString(file, `ready-${Math.random()}`), + ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid) + }) +} + +describeWatcher("Watcher", () => { + it.live("publishes root create, update, and delete events", () => + withTmp( + (directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const file = path.join(directory, "watch.txt") + yield* ready(directory) + for (const item of [ + { event: "add" as const, trigger: fs.writeFileString(file, "a") }, + { event: "change" as const, trigger: fs.writeFileString(file, "b") }, + { event: "unlink" as const, trigger: fs.remove(file) }, + ]) { + expect( + yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger), + ).toEqual({ + file, + event: item.event, + }) + } + }), + { git: true }, + ), + ) + + it.live("skips non-git roots", () => + withTmp((directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const file = path.join(directory, "plain.txt") + yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain")) + }), + ), + ) + + it.live("cleanup stops publishing events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* ready(tmp.path).pipe( + provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }), + Effect.scoped, + ) + const file = path.join(tmp.path, "after-dispose.txt") + yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe( + Effect.provideService(EventV2.Service, events), + ) + }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))), + ) + + it.live("ignores .git/index changes", () => + withTmp( + (directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const index = path.join(directory, ".git", "index") + yield* ready(directory) + yield* noUpdate( + (event) => event.file === index, + fs + .writeFileString(path.join(directory, "tracked.txt"), "a") + .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid), + ) + }), + { git: true }, + ), + ) + + it.live("publishes .git/HEAD events", () => + withTmp( + (directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const head = path.join(directory, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* ready(directory) + yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet()) + expect( + yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)), + ).toMatchObject({ file: head }) + }), + { git: true }, + ), + ) + + const describeSymlink = process.platform !== "win32" ? describe : describe.skip + describeSymlink("symlinked .git", () => { + it.live("publishes .git/HEAD events through a symlinked .git directory", () => + withTmp( + (directory) => + Effect.gen(function* () { + const afs = yield* FSUtil.Service + const actual = path.join(directory, "..", `actual_${path.basename(directory)}`) + yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true }))) + yield* ready(directory) + const head = path.join(directory, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet()) + expect( + yield* nextUpdate( + (event) => event.file === path.join(actual, "HEAD"), + afs.writeFileString(head, `ref: refs/heads/${branch}\n`), + ), + ).toEqual({ file: path.join(actual, "HEAD"), event: "change" }) + }), + { + git: true, + init: async (directory) => { + const actual = path.join(directory, "..", `actual_${path.basename(directory)}`) + await fs.rename(path.join(directory, ".git"), actual) + await fs.symlink(actual, path.join(directory, ".git")) + }, + }, + ), + ) + }) + + it.live("runs by default for git repos without the enable flag (#743)", () => + withTmp( + (directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const file = path.join(directory, "default-watch.txt") + yield* ready(directory) + expect( + yield* nextUpdate((event) => event.file === file && event.event === "add", fs.writeFileString(file, "a")), + ).toEqual({ file, event: "add" }) + }), + { git: true, useDefaultFlags: true }, + ), + ) +}) 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 5ab54e3b..7adcd82e 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 @@ -42,6 +42,7 @@ export type SessionReviewFilePreviewV2Props = { readFile?: (path: string) => Promise filePicker?: (pickerProps: { onSelect: (path: string) => void }) => JSX.Element onSelectFile?: (file: string) => void + onRefresh?: () => void onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 0274093d..1d2b7b7f 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,21 +1,21 @@ { "version": "1.18.10", "repo": "harmoniqs/opencode", - "tag": "v1.18.10-amicode.19", + "tag": "v1.18.10-amicode.21", "source": "release", - "ref": "d5123f6dbd4a306c42361a2e65faf8424752d6bf", + "ref": "d161eb0cfc6d03a53311e083b590005b4161c13a", "platforms": { "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", - "sha256": "7cbff96c70738ae8b8dddf7a85912aba6298503e47bd885a3a21ce8cd9adab51" + "sha256": "2a8c5e2863e377ee168363b1a73ba32d9d9f041cda143f2f98421222ac165067" }, "linux-arm64": { "asset": "opencode-linux-arm64.tar.gz", - "sha256": "311b3dfaf576c9908e818207e4323344dd9ee5971d26bc8324b352a70312875b" + "sha256": "d6a4643e19adc83d39b33bfbbbf1b77dc5c7ccead7c3cf09eccd7f6cae8a4a30" }, "linux-x64": { "asset": "opencode-linux-x64.tar.gz", - "sha256": "bc33a3997618d66dc306a87eb614872e65641ed8abe132972069ae58c2ef1e4f" + "sha256": "e07fd08de7f71784502d89e72d6c27cf8c1a6d5d0810ee03e2b6d8d1a9c3ab78" } } } diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index 2da0cc9e..7618911c 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -1,12 +1,12 @@ { - "recordedAt": "2026-09-02T21:22:05.111Z", + "recordedAt": "2026-09-04T15:19:29.898Z", "fork": { "version": "1.18.10", - "tag": "v1.18.10-amicode.19" + "tag": "v1.18.10-amicode.21" }, - "sandbox": "/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9", - "sandboxReal": "/private/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9", - "seededAt": 1788384123848, + "sandbox": "/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa", + "sandboxReal": "/private/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa", + "seededAt": 1788535168065, "entries": [ { "name": "cold read — synthesized identity + stats + remembers", @@ -75,7 +75,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/vaults/attachable-demo\"}" + "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/vaults/attachable-demo\"}" }, { "name": "vaults — post-attach (cache bust, new mount)", @@ -189,7 +189,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve mount-prefixed (tier 3)", @@ -200,7 +200,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" }, { "name": "resolve relative w/ dir part (tier 4 → project dir)", @@ -211,7 +211,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve bare typed-prefix — miss (tier 5)", @@ -354,7 +354,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":2064123845,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":2215168060,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — failed (result.toml ≠ finished)", @@ -376,7 +376,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":1365723845,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":1516768060,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — explicit lab", @@ -409,7 +409,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — valid PDF (refreshed listing)", @@ -424,7 +424,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1788384124572,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1788535169341,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — bad_filetype refusal", @@ -464,7 +464,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1788384124572,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1788535169341,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "widget registry — builtins with content hashes", @@ -624,7 +624,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-09-02T21:21:03.847Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-09-02T21:21:03.847Z\",\"stale\":false,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-09-04T15:18:28.063Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-09-04T15:18:28.063Z\",\"stale\":false,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" }, { "name": "connections catalog — configured filtered out", @@ -817,7 +817,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-09-02T21:21:03.847Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"token\",\"browser\"]}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-09-04T15:18:28.063Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"token\",\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"token\",\"browser\"]}],\"error\":null}" }, { "name": "project create — mkdir (git absent, best-effort)", @@ -831,7 +831,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" + "body": "{\"ok\":true,\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" }, { "name": "project create — collision", @@ -885,7 +885,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"parentDir\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/26/98pr37253yvcxfjlzkzg_c_r0000gn/T/amicode-fixture-L5X0J9/AmicodeProjects/prior-project\"}]}" + "body": "{\"ok\":true,\"parentDir\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/x2/8wpd9k2j61s6ms5s5ht1297h0000gn/T/amicode-fixture-gWh3pa/AmicodeProjects/prior-project\"}]}" } ] }