refactor: retire obsolete docs, dead APIs and speculative loaders (#880)

## Scope
Implement the two repository-wide Ponytail audit lists, deduplicating
overlapping recommendations. Based on current `dev@aa2776ef`, on
`refactor/ponytail-cleanup`; no direct dev changes. Initial delta:
**3,548 net lines removed**, two direct dependencies removed (and their
unused transitive lockfile entries).

## Changes and preserved behavior
- Delete superseded MVP/UI/tool-rendering/summary docs; compact the
index and canonical architecture references and remove
per-review/temp-path bookkeeping from runtime-transition history.
Preserve security/ownership rules, technical minimum rationale,
compatibility provenance and reproduction commands.
- Remove confirmed-unused UI surfaces, Electron JSON storage IPC,
obsolete release fetch/shell runners and dead
bridge/storage/command/keyboard utilities. Keep existing native
environment discovery and shared-service lifecycle untouched.
- Replace the empty first-party plugin loading layer with direct
built-in right-panel construction. External sandboxed extensions,
consent, assets authority, activation, ordering and visibility remain
intact.
- Retire the unused incremental-part message mutation engine and
exclusively owned pending-part buffers/budgets/types. The active native
reducer still publishes bounded authoritative snapshots. Preserve
optimistic send identity, pending sends, permissions, usage,
pruning/revert, memory accounting and scroll restoration. Rewrite old
tests using snapshots rather than removing relevant behavioral
assertions.
- Share speech streaming/base64 helpers, retaining playback lifecycle
and cancellation in each caller; deterministic transport regression
covers ordering, start-once, completion and failures.
- Replace three Material icons with existing Lucide icons; remove
`@suid/icons-material` and unused `ts-node` without upgrading other
dependencies.
- Drop the redundant Favorites implementation-shape test; behavioral
store/browser regressions stay. Repair two inherited tests to current
palette and lazy copy contracts, preserving image/privacy assertions.

## Validation
Server/UI/Electron typechecks, UI production build, targeted
core-panel/Favorites/audio/image/palette checks pass. Full local suites
and focused browser/native Electron regressions are in progress. The
broad server run found one existing deadline-sensitive pending-broker
test; its entire 20-test file passes in isolation. Independent
gatekeeper review is required on this exact committed head, findings
will be corrected and re-reviewed to zero before the explicitly
authorized admin merge. No user credentials/shared daemon/database were
used for testing.

## Size notes
Touched existing large files:
`packages/ui/src/stores/message-v2/instance-store.ts` ~1,450 lines,
`packages/ui/src/stores/message-v2/instance-store.test.ts` ~516 lines,
`packages/ui/src/stores/conversation-speech.ts` ~432 lines. No size-only
refactor; source/test coverage reduction is tied to proven-unused paths.
Native application is not replaced/restarted by these isolated checks.
This commit is contained in:
Pascal André 2026-10-08 01:03:40 +02:00 • committed by GitHub
parent e874cafad2
commit 027f50947c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 398 additions and 2564 deletions

View file

@ -98,6 +98,7 @@
- Session pruning is a narrow V2 plugin/RPC exception under `packages/server/src/opencode/session-pruning/`; see `dev-docs/SESSION_PRUNING_RPC.md`. Bundle it with the shared server for both desktop hosts and provision through normal native plugin discovery. RPC registrations follow backend presence; clean shutdown removes that backend's lease and crashes expire. Loading never deletes content. Deletion occurs only on an explicit pruning request, without an extra enable-write switch or beta-number gate. Keep generic RPC proxy access closed. Writes validate actual storage, a fresh daemon-storage identity challenge and the native durable execution claim inside a synchronous SQLite transaction. Run isolated native concurrency/payload and client-cache regressions; tests must never target the shared daemon or a user's database.
- The same presence-owned bundle exposes only `codenomad.pending-requests.snapshot` for loaded-only Form/Permission recovery. Its private native-service reader checks native RPC capability and actual graph, existing-only lease, queue, codec and identity contracts without a version allowlist; keep tested evidence distinct from incompatibility and do not raise the global minimum speculatively. The authenticated pending broker uses one established owned execution-host root and fixed RPC POST, never candidate `location.get`, ordinary queue GETs, debug inventory or the unreleased `/api/location/pending` API. Validate origin, full coverage and every placement before connection-scoped capability admission; only bounded declared native `rpc.unavailable`/`rpc.method_not_found` errors denote unsupported, never authorization for a worktree-wide fallback. Other failures retain queues non-authoritatively. Preserve compaction admission, deletion/connection fences, global Forms and settlement tombstones. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for bootstrap warming and snapshot limits.
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
- Built-in right-panel tabs are constructed directly in `right-panel/core-runtime.tsx`; external sandboxed panels retain their independent consent/loading path. Do not restore an empty first-party plugin loader. Native transcript events reduce through `stores/opencode-data.ts` and publish bounded snapshots with `hydrateMessages`; do not reintroduce an unused second partial-part reducer. Shared speech byte/MediaSource transport lives in `lib/audio-utils.ts`, while playback ownership and cancellation stay with its callers.
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.

38
dev-docs/INDEX.md Normal file
View file

@ -0,0 +1,38 @@
# Documentation Index
Start with [README](../README.md) for installation and usage, then
[architecture](architecture.md) for ownership boundaries and code locations.
[AGENTS.md](../AGENTS.md) defines contribution, styling and i18n conventions.
## Runtime and ownership
- [OpenCode compatibility](OPENCODE_V2_COMPATIBILITY.md): qualification policy, consumed contracts and integration limits.
- [Stable-runtime transition](OPENCODE_V2_POST_BETA.md): technical minimum, installation/recovery, retired adapters and migration evidence.
- [Worktree/session placement](WORKTREE_SESSION_PLACEMENT.md): native location identity and authorized conversation movement.
- [Session environment](SESSION_ENVIRONMENT.md): server-only execution-host snapshots and fail-closed per-send synchronization.
- [Cache refresh conventions](CACHE_REFRESH_CONVENTIONS.md): display snapshots, invalidation and authoritative mutation reads.
## History and pruning
- [History queries](SESSION_HISTORY_QUERIES.md): bounded search, counts and cleanup queries.
- [History navigation](SESSION_HISTORY_NAVIGATION.md): structural indexes and anchor windows.
- [Pruning RPC](SESSION_PRUNING_RPC.md): narrow native integration and packaging.
- [Pruning safety](SESSION_PRUNING_SAFETY.md): transactional authorization, identity and replay contracts.
## UI and extensions
- [Files panel](FILES_PANEL.md): workspace editing, Git changes/history and preview lifecycle.
- [Native interruptions](NATIVE_INTERRUPTION_UX.md): permissions/Forms dock, drafts and pending-request recovery.
- [Provider accounts](PROVIDER_ACCOUNTS_UX_PLAN.md): native account selection and bounded quota/rotation policy.
- [Plugin activation](PLUGIN_ACTIVATION_CONTROLS.md): explicit Global/Project controls.
- [Panel extensions](PANEL_EXTENSIONS.md): distribution, trust, consent and sandbox/API boundaries.
- [Palette sources](PALETTE_SOURCES.md): color provenance and validation.
## Desktop automation and validation
- [Desktop instrumentation](DEVELOPER_MODE.md): always-available automation and authenticated transport; no Developer Mode gate.
- [Browser automation](BROWSER_AUTOMATION.md): owned native previews and targeting.
- [Device emulation](BROWSER_DEVICE_EMULATION.md): touch profiles and sizing contracts.
- [Tauri backports](../packages/tauri-app/vendor/README.md): upstream provenance, validation commands and override removal criteria.
- [Build guide](../BUILD.md): packaging and platform build commands.
- [Contributing](../CONTRIBUTING.md): development and test workflows.

View file

@ -1,9 +1,11 @@
# OpenCode V2 stable-runtime transition
**Implementation:** PR #696, based on #695 and the client/plugin 2.0.11 alignment
in #728. The first implementation was re-reviewed after the user challenged its
arbitrary version floor and incomplete cleanup. Earlier test/review results below
describe that earlier diff, not approval of its scope or the current correction.
**Implementation provenance:** PR #696, based on #695 and the client/plugin 2.0.11
alignment in #728. The corrected technical floor supersedes the initial version-only
policy. Current recommendation and dependency pins are defined by
[runtime support](../packages/server/src/opencode/runtime-support.ts), the
[server](../packages/server/package.json), [UI](../packages/ui/package.json) and
[pruning](../packages/server/src/opencode/session-pruning/package.json) manifests.
## Release contract
@ -11,14 +13,14 @@ describe that earlier diff, not approval of its scope or the current correction.
`session.step.started.data.started`; 2.0.7 introduces it. The pinned Solid
reducer consumes that field directly for assistant-message creation times.
Retiring the timestamp fallback therefore requires this native event contract.
- **2.0.11 is recommended and release-tested**, independently of that minimum.
Being the latest publication or matching the client/plugin pin is not a
technical reason to reject 2.0.7–2.0.10.
- **2.0.11 was the transition's recommended, release-tested target**, independently
of that minimum. The current recommendation is source-defined above; neither
the latest publication nor the client/plugin pin justifies rejecting 2.0.7–2.0.10.
- Stable versions below 2.0.7 and historical `0.0.0-beta-*` publications are
refused with a timestamp-contract explanation and HTTP 426. Unlisted versions,
future majors and custom/prerelease labels are unverified rather than rejected
by their label; they undergo authenticated bounded API recognition.
- Client and plugin dependencies remain separately pinned at 2.0.11.
- Client/plugin dependency pins are separate from runtime admission.
- Authenticated daemon metadata, not an older selected discovery CLI, controls
connection admission. Admission precedes functional requests and plugin
provisioning. Replacement daemons are checked again, even at the same URL.
@ -225,40 +227,32 @@ and pruning acceptance. CI adds first installation and old-seed migration at
the fixed minimum and resolved latest stable on Windows, Linux and macOS ARM64.
The existing cross-platform native pruning gate remains in place.
### Earlier implementation evidence (through `4a77d162`)
### Retained acceptance evidence
These results predate the technical-floor correction and are retained as history:
The earlier implementation through `4a77d162` and corrected acceptance at
`781c3a42` establish these specific behaviors, not universal compatibility:
| Check | Result |
| --- | --- |
| Complete final server suite | 621 passed, 2 skipped; no failures/cancellations. |
| Final managed-setup/API regressions | 22 covered in the final server suite, including real manager reconnect/reload, cross-selection serialization and concurrent receipt publication. |
| Setup browser regressions | 7 passed, including optional activation, explicit required restart, informed configuration reload and activation failure after successful installation. |
| Server/UI TypeScript | Passed. |
| Native old-seed migration | Both beta-19271 and 2.0.3 passed against 2.0.11 on Windows and Linux. |
| No-system-Node install | Passed Windows with packaged Node 24.20.0 and Linux with isolated Node/npm; POSIX lifecycle shell stays available without a system Node on PATH. |
| Feature gatekeeper | Three passes; five actionable findings corrected and regression-tested; final feature pass reports none. |
| Acceptance/CI gatekeeper | Added whole-run/request deadlines, bounded child termination and cursor-cycle rejection; three failure-guard tests pass and re-review reports none. |
| WSL path gatekeeper | Fixed stale overlapping-claim ownership and POSIX URL escaping; 19 path/install/lifecycle tests pass and re-review reports none. |
| Explicit reload gatekeeper | Fixed cross-binary serialization at shared-service authority scope; 16 focused service/route/manager tests pass and re-review reports none. |
| Real Windows→WSL | Native Linux/UNC, Windows mount and symlink-to-mount modes passed authenticated provisioning, heartbeat stability, explicit reload, restart and reconnect; all fixture daemons stopped. |
| Native reload effects | Active synthetic-provider stream completed with one request/no replay and unchanged daemon PID; pending Form was cancelled and original Shell/PTY became unavailable. |
| WSL fixture gatekeeper | Whole-run/request/cleanup bounds and immediate detached-consumer error handling are covered by seven guard regressions; final re-review reports none. |
| Windows release hosts | Rebuilt Electron and Tauri passed actual missing installation, bundled-Node setup, 2.0.3 daemon preservation until explicit restart, 2.0.11 activation, pending-folder continuation, and explicit configuration reload. Captures inspected and artifact/resource hashes retained. |
| Final discovery/desktop gatekeeper | Fresh WSL absence, alternate loopback, metadata translation and old-daemon detection are covered; 35 focused lifecycle tests pass. Independent final review reports no actionable findings and verifies the final artifact hashes/evidence. |
| Remote CI at `419fe6a6` | All test jobs passed: full tests, runtime contracts, cross-platform installation/migration and native pruning, Windows/macOS Tauri. Distribution builds were still running at the single follow-up check; later changes require their own CI result. |
- Native 2.0.7–2.0.10 and the 2.0.11 control passed authenticated schema/config discovery, plugin/RPC loading, Shell/environment replacement, two-session isolation, synthetic provider prompt/wait/context and stable PID.
Production environment, locations/worktrees/relay, pruning/proxy/rendered UI and automation passed at 2.0.7; environment/pruning also passed at 2.0.10, including real compaction and pre-compaction pruning.
Differing provider/model settings are not emitted by CodeNomad; the raw editor saves runtime-specific user text.
- Windows and Linux/WSL beta-19271/2.0.3 migrations passed to 2.0.7 and 2.0.11 with the coverage above. Exact CLI archives/hashes and unchanged seed hashes were checked; no shared service or user storage was used.
- Bundled-Node installation passed without system Node on Windows/Linux, retaining the POSIX lifecycle shell.
Both release hosts passed missing installation, optional activation, explicit incompatible-daemon restart, informed reload and activation failure after installation.
At `781c3a42`, installing 2.0.11 retained a 2.0.10 daemon's PID/version and resumed the folder without restart; 2.0.3 required explicit restart. Captures/hashes were inspected and isolated windows/services cleaned up.
- Windows→WSL Linux/UNC, mounted and symlink-to-mount paths passed authenticated provisioning, heartbeats and reload/restart/reconnect.
Reload kept the PID and completed the synthetic stream once without replay, while cancelling the pending Form and making the original Shell/PTY unavailable.
- Discovery covers absent WSL registration, forwarding failures, mounted/aliased metadata and `127.0.0.2`; configuration alone never proves a running process.
Regression fixes retain stale-selection/cross-backend downgrade fences, same-version Windows and concurrent receipt publication, non-disruptive reconnect, cross-binary service serialization, overlapping WSL claim ownership and POSIX URL escaping.
Fixtures enforce whole-run/request/cleanup bounds, child termination, cursor-cycle rejection and detached-consumer errors.
- CLI parsing preserves custom labels/`+build`; unorderable versions never auto-update/downgrade. Clean builds and desktop fixtures exclude stale generated adapters/installers.
Body-portal recovery plus real-style pointer tests prevent the pending-folder overlay blocking setup reentry after dismissal.
- `306c2ba4` brought #723 history/navigation and #732/#734 README changes. Native 2.0.7 passed 241-message
search/counts/batch cleanup, 1,501-message indexes, distant windows, exact payload/restoration, pruning,
compaction, concurrency, discovery and leases. The pack-outside-checkout regression verifies `navigation-scope.ts`, `outline-index.ts` and `outline-preview.ts` distribution. Existing APIs/storage justify no higher floor.
Earlier remaining release result:
- Green remote CI for the final pushed diff. Earlier test jobs at `419fe6a6`
passed across all configured platforms; that is not a substitute for the final
head's CI. No continuous CI monitoring is performed.
The final discovery cross-check also covers a configured but absent WSL service,
registered-service forwarding failures, mounted/aliased metadata access and valid
non-default loopback addresses such as `127.0.0.2`. Registration absence and an
existing registered daemon remain distinct; configuration alone is not evidence
of a running process.
Earlier CI test jobs at `419fe6a6` passed across configured platforms, but distribution
builds were still running at the recorded check. Final-head remote CI was unconfirmed;
historical local/native/packaged acceptance is not a substitute for the current head's CI.
### Reproduce desktop and WSL acceptance
@ -275,109 +269,14 @@ node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI mounted
node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI aliased reload-safety
```
Final Windows desktop evidence directories (under the approved local temporary
`opencode/` directory) are `codenomad-setup-desktop-O0ScgO` (old daemon/restart/folder),
`codenomad-setup-desktop-FiVAai` (installation/reload), and
`codenomad-setup-desktop-Vk2RV8` (installation/folder). Each contains `results.json`,
artifact/resource hashes, per-host logs/API responses and screenshots. Final WSL
discovery runs are `codenomad-wsl-setup-6RvOIs`, `codenomad-wsl-setup-CqxLn8` and
`codenomad-wsl-setup-KSOQ1V`; the detailed reload characterization with final
consumer-error handling is `codenomad-wsl-setup-9tivGc`.
Autonomous gatekeeper review led to regression fixes for optional
activation, stale-selection restart, cross-backend downgrade, same-version
Windows publication, non-disruptive workspace reconnect, fixture timeout/error
cleanup, WSL canonical path/ownership/URL handling, cross-selection service-action
serialization and old-daemon/fresh-WSL discovery. Every reported actionable finding
was corrected, revalidated and independently re-reviewed; the final pass is clear.
Each desktop fixture emits `results.json`, artifact/resource hashes, per-host
logs/API responses and screenshots in its isolated output directory. Retain those
artifacts with the qualification PR/CI rather than documenting machine-local paths.
Do not remove the remaining historical authority checks based solely on this
synthetic seed. Keep the compatibility audit in
[OPENCODE_V2_COMPATIBILITY.md](OPENCODE_V2_COMPATIBILITY.md) as historical evidence.
## Decision history
## Policy correction provenance
- 2026-09-16: #695 established connection-scoped compatibility; #696 began as a
documentation-only retirement plan without selecting a floor.
- 2026-09-20: stable V2 publication is established. The user requested the actual
implementation in #696. The plan-only status is superseded by the code above.
- 2026-09-20 initial implementation: minimum/client/plugin baseline 2.0.11.
**The user rejected the minimum rationale and the assistant's attribution of
implementation choices to an agreed scope.** That version-only policy is superseded.
- 2026-09-20 correction: the user accepts retiring older runtimes if a technical
reason is demonstrated. Minimum 2.0.7 follows the native step timestamp;
recommendation 2.0.11 remains separate. Complete the obsolete serializers'
retirement, keep current authority checks, and make optional updates non-blocking.
## Correction evidence
- Direct native checks on 2.0.7, 2.0.8, 2.0.9, 2.0.10 and 2.0.11 control:
13 scenarios each passed, covering authenticated schema, config discovery,
current plugin/RPC loading, real local shell/environment snapshot replacement,
two-session isolation, synthetic provider prompt/wait/context and stable PID.
Evidence: approved temporary `opencode/pr696-native-207-210/REPORT.md` and
`results-2.0.7_2.0.8_2.0.9_2.0.10_2.0.11.json`.
- Corrected final server suite: 622 passed, 2 skipped; server/UI typechecks passed.
- Production-boundary native suites passed on 2.0.7: session environment,
locations/worktrees/relay, full pruning/proxy with rendered UI, and automation.
Environment and full pruning/proxy also passed on 2.0.10. Both versions passed
real compaction and pruning of pre-compaction history. CodeNomad does not emit
the differing provider/model compaction-settings shapes; its raw config editor
saves user-authored runtime-specific text. Evidence: `pr696-native-207-210/ACCEPTANCE.md`.
- Native 2.0.3→2.0.7 migration passed with 215-message histories, distinct old
identities, forks, provider configuration/connections and same-version Form
durability controls. Evidence: `codenomad-history-migration-IoFhtY`.
- Native beta-19271→2.0.7 migration also passed on Windows; both beta-19271 and
2.0.3→2.0.7 passed under native Linux/WSL. Exact CLI archives were verified,
original seed hashes stayed unchanged, and isolated processes were cleaned up.
Evidence: `pr696-native-207-210/MIGRATION-MINIMUM207.md`.
- Seven browser setup scenarios passed. Rendered captures distinguish minimum
2.0.7 from recommendation 2.0.11 and keep 2.0.10 usable with optional update.
Evidence: approved temporary `opencode/696-rigorous-browser/`.
- CI now runs runtime qualification at the source-defined technical minimum and
latest, and migration targets read the same minimum rather than duplicating it.
- The independent review found a real CLI-parser mismatch: custom labels were
lost and `+build` suffixes truncated. Production parsing and HTTP/updater tests
now preserve those labels, with no automatic update/downgrade for unorderable
versions. Re-review closed all source/docs/CI findings; a separate final review
also approved the compatible-daemon fixture and generated-output clean step.
- Server builds clean generated `dist` before compilation; desktop acceptance
checks that deleted adapters/installers do not survive incremental packaging.
- Rebuilt Tauri acceptance found a recovery-layer regression when opening a
folder: the loading overlay intercepted the persistent setup button after
dismissal. The recovery entry now mounts through a body portal. The real-style
browser regression uses normal pointer clicks with the folder overlay present;
all seven setup scenarios and UI typecheck passed after correction.
- Final packaged acceptance passed on Electron and Tauri at `781c3a42`:
installing 2.0.11 retained the compatible 2.0.10 daemon with identical PID and
version, optional restart wording, and a ready resumed workspace. The retired
2.0.3 scenario passed with explicit restart. Both hosts passed recovery reentry
over the pending-folder overlay and absence of retired generated modules.
Final captures were inspected; all fixture windows/services were cleaned up.
Evidence: `pr696-native-207-210/DESKTOP-FINAL.md`,
`codenomad-setup-desktop-Z6gPDT` (compatible) and
`codenomad-setup-desktop-kr69SG` (retired).
- Corrected local/native/packaged acceptance is complete. Final-head remote CI
remains a separate, unconfirmed result; it was not continuously polled.
## Pre-merge refresh (2026-09-21)
- Integrated `origin/dev` at `306c2ba4`: full-history search/global navigation
(#723) and README updates (#732/#734). The merge was conflict-free. The new
history/navigation RPC methods and routes coexist with setup/restart/reload;
current ownership and transactional pruning checks remain intact.
- The incoming plugin manifest omitted `navigation-scope.ts`, `outline-index.ts`
and `outline-preview.ts`. Full server tests and independent review both found
the broken standalone package. Added all three to its distribution allowlist;
the pack-outside-checkout entrypoint regression now passes.
- Refreshed server suite: **643 passed, 2 skipped**. Server/UI typechecks and
**29 browser tests** for setup, full-history search and global navigation pass.
- **154 UI store/reducer tests** pass, including history, outline persistence,
session request authority, pruning pagination and restored client state.
- The merged bundled plugin passes the native 2.0.7 suite with rendered UI:
241-message search/counts/batch cleanup and 1,501-message structural indexes,
distant windows, exact native payloads and restoration all pass. Existing
pruning, compaction, concurrency, discovery and lease checks pass as well.
Evidence: `696-merge-native207.log` and `codenomad-pruning-native-VD53eb`.
- The new history features use existing runtime/plugin APIs and storage columns;
source review found no reason to raise the technical minimum above 2.0.7.
#695 established connection-scoped compatibility on 2026-09-16. On 2026-09-20, #696's version-only 2.0.11 floor was rejected in favor of demonstrated dependencies, retained authority checks and non-blocking optional updates. The native timestamp establishes 2.0.7, not the publication/pin. CI qualification and migration read the source-defined minimum and resolved latest stable rather than duplicating a floor.

View file

@ -2,7 +2,7 @@
## Overview
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. Both server and UI use the official `@opencode/client` network contract. The runtime CLI is independently managed.
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. The official `@opencode/client` dependency pins live in the [server](../packages/server/package.json) and [UI](../packages/ui/package.json) manifests; the runtime CLI is independently managed.
```text
Desktop host -> CodeNomad server -> one shared OpenCode service
@ -32,7 +32,7 @@ Workspaces are not OpenCode processes and do not own ports or PIDs. Closing an o
Electron and Tauri run one native singleton process and one CodeNomad backend per channel/config profile. A second launch opens another UUID-backed window by default; Advanced settings can restore most-recent-window focus, while `--new-window` always creates another window. Stable, dev, and non-default config profiles isolate singleton identity, backend/browser storage, and client state.
OpenCode sessions and messages remain shared through the global daemon. Window membership, tabs, drafts, view state, and native bounds are local to each UUID window. Client-state V3 is a per-window envelope over the V2 content-addressed partition graph: immutable partitions are prepared before atomic root publication, writes and migrations are fenced by current ownership, and garbage collection runs after publication while retaining every partition referenced by any window.
OpenCode sessions and messages remain shared through the global daemon. Window membership, tabs, drafts, view state, and native bounds are local to each UUID window. Client-state V3 is a per-window envelope over the V2 SHA-256 content-addressed partition graph: immutable partitions are prepared before atomic root publication, writes and migrations are fenced by current ownership and renderer authority, and garbage collection runs after publication while retaining every partition referenced by any window.
Previews use unguessable capabilities for HTTP and WebSocket traffic. Electron and Windows Tauri local windows open HTTP(S) pages in hardened native child webviews with isolated storage; other clients use the existing capability-scoped iframe proxy. SideCar/browser iframes remain opaque-origin sandboxes without `allow-same-origin`; preview element comments use a source-checked message bridge instead of parent DOM access.
@ -46,7 +46,7 @@ CodeNomad control APIs live under `/api/*`. Important routes include:
- `/api/storage`, `/api/settings`, `/api/filesystem`, `/api/speech`
- `/api/opencode-plugin/automation`, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations
Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Upstream additions require an explicit proxy review and are not available automatically.
Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, validates prompt files, defaults safe requests to the workspace location, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Never trust a browser-supplied worktree path: resolve ownership server-side. Upstream additions require an explicit proxy review and are not available automatically.
Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId`; Yolo notifications use `/api/events`.
@ -54,9 +54,9 @@ Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId
`packages/ui/src/lib/sdk-manager.ts` uses `OpenCode.make()` and caches generated Promise clients by instance proxy path. `packages/ui/src/stores/opencode-client.ts` is the root-client authority; native directory/location fields replace old per-worktree SDK clients.
The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` maps native location events to CodeNomad `instance.event` records, and `/api/events` multiplexes them with workspace and Yolo events for the browser. The stream is volatile and has no replay guarantee: reconnect must refetch authoritative state.
The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` maps native location events to CodeNomad `instance.event` records, and `/api/events` multiplexes them with workspace and Yolo events for the browser; heartbeats use `POST /api/client-connections/pong`. The stream is volatile and has no replay guarantee: reconnect must reconcile sessions and pending requests and reread authoritative file/config state. Pending recovery uses the loaded-only broker, not worktree-wide ordinary lists; see [compatibility](OPENCODE_V2_COMPATIBILITY.md). Relay ordering and ownership fences remain specified in [AGENTS.md](../AGENTS.md).
Current native events include session lifecycle/output events (`session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, `session.tool.*`), file invalidation via `filesystem.changed`, and configuration invalidation via `config.updated`.
Current native events include session lifecycle/output events (`session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, `session.tool.*`), background Shell refresh via `shell.created`, `shell.exited`, `shell.deleted`, file invalidation via `filesystem.changed`, and configuration invalidation via `config.updated`.
## Feature Ownership
@ -74,23 +74,12 @@ Current native events include session lifecycle/output events (`session.created`
| Desktop inspection and CDP feedback | Current CodeNomad desktop host and bundled automation plugin, available at normal startup |
| Autonomous browser previews | CodeNomad desktop browser controllers and the same bundled automation plugin, independent of Developer Mode |
Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored; the narrow bundled automation plugin and session-pruning RPC use native V2 discovery and backend presence.
Session Shell and conversation instructions are native features, not plugins. Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows removal. The proxy verifies Shell `cwd` ownership before ID-scoped operations and preserves native output cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin`, server plugin/background-process and per-workspace runtime paths remain deleted and must not be restored; the narrow bundled automation plugin and session-pruning RPC use native V2 discovery and backend presence.
## Persistence
CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only.
## Key Files
## Implementation Map
- `packages/server/src/index.ts`
- `packages/server/src/server/http-server.ts`
- `packages/server/src/workspaces/opencode-service.ts`
- `packages/server/src/workspaces/manager.ts`
- `packages/server/src/workspaces/instance-events.ts`
- `packages/server/src/workspaces/git-mutations.ts`
- `packages/server/src/permissions/auto-accept-manager.ts`
- `packages/server/src/opencode/automation-plugin.ts`
- `packages/ui/src/lib/sdk-manager.ts`
- `packages/ui/src/lib/api-client.ts`
- `packages/ui/src/stores/session-api.ts`
- `packages/ui/src/stores/session-actions.ts`
Server entrypoints are `packages/server/src/index.ts` and `server/http-server.ts`; shared-service authority lives in `workspaces/opencode-service.ts` and `workspaces/manager.ts`, event routing in `workspaces/instance-events.ts`, and bundled automation provisioning in `opencode/automation-plugin.ts`. UI native calls use `stores/opencode-client.ts`, `session-api.ts` and `session-actions.ts`; CodeNomad routes use `lib/api-client.ts`. Current integration conventions are in [AGENTS.md](../AGENTS.md).

172
package-lock.json generated
View file

@ -2099,30 +2099,6 @@
"solid-js": "^1.8"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@ -6309,18 +6285,6 @@
"integrity": "sha512-Hsi4O3dBOm7rrlqKoWfNoTeRFAXm/7TPaeEmyxNx+wFaT3eROjMVdhadAIiagFT+PsHrq/6fDauUI5TkL+5Zvg==",
"license": "MIT"
},
"node_modules/@suid/icons-material": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.9.0.tgz",
"integrity": "sha512-2idgaT/JARd12dwDfocZBQizaiZVgR0ujRsVc61OlAuPZbeH+3TrSxUJkE3Z7+TPftw9+6p0A24GhJjJLvi6RQ==",
"license": "MIT",
"dependencies": {
"@suid/material": "0.19.0"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/material": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@suid/material/-/material-0.19.0.tgz",
@ -6711,34 +6675,6 @@
"node": ">= 10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tufjs/canonical-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@ -7112,19 +7048,6 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -8503,7 +8426,6 @@
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -8823,13 +8745,6 @@
"node": ">= 10"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@ -9211,16 +9126,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@ -12374,13 +12279,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/make-fetch-happen": {
"version": "15.0.6",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz",
@ -16541,57 +16439,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -17026,13 +16873,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
@ -18570,16 +18410,6 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/zip-stream": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
@ -18696,7 +18526,6 @@
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"esbuild": "^0.25.12",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
@ -18733,7 +18562,6 @@
"@kobalte/core": "0.13.11",
"@opencode/client": "2.0.24",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",

View file

@ -1,121 +0,0 @@
import { app, ipcMain } from "electron"
import { join } from "path"
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
import { existsSync } from "fs"
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
// File watching for config changes
let configWatchers = new Set<number>()
let configLastModified = 0
let configCache: string | null = null
async function ensureDirectories() {
try {
await mkdir(CONFIG_DIR, { recursive: true })
await mkdir(INSTANCES_DIR, { recursive: true })
} catch (error) {
console.error("Failed to create directories:", error)
}
}
async function readConfigWithCache(): Promise<string> {
try {
const stats = await stat(CONFIG_FILE)
const currentModified = stats.mtime.getTime()
// If file hasn't been modified since last read, return cache
if (configCache && configLastModified >= currentModified) {
return configCache
}
const content = await readFile(CONFIG_FILE, "utf-8")
configCache = content
configLastModified = currentModified
return content
} catch (error) {
// File doesn't exist or can't be read
configCache = null
configLastModified = 0
throw error
}
}
function invalidateConfigCache() {
configCache = null
configLastModified = 0
}
export function setupStorageIPC() {
ensureDirectories()
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
ipcMain.handle("storage:readConfigFile", async () => {
try {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})
ipcMain.handle("storage:writeConfigFile", async (_, content: string) => {
try {
await writeFile(CONFIG_FILE, content, "utf-8")
invalidateConfigCache()
// Notify other renderer processes about config change
const windows = require("electron").BrowserWindow.getAllWindows()
windows.forEach((win: any) => {
if (win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send("storage:configChanged")
}
})
} catch (error) {
console.error("Failed to write config file:", error)
throw error
}
})
ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
return await readFile(instanceFile, "utf-8")
} catch (error) {
// Return empty instance data if file doesn't exist
return JSON.stringify({ messageHistory: [] }, null, 2)
}
})
ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
await writeFile(instanceFile, content, "utf-8")
} catch (error) {
console.error(`Failed to write instance file for ${filename}:`, error)
throw error
}
})
ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
if (existsSync(instanceFile)) {
await unlink(instanceFile)
}
} catch (error) {
console.error(`Failed to delete instance file for ${filename}:`, error)
throw error
}
})
}
// Clean up on app quit
app.on("before-quit", () => {
configCache = null
configLastModified = 0
})

View file

@ -1,4 +1,3 @@
import { spawn, spawnSync } from "child_process"
import path from "path"
interface ShellCommand {
@ -76,66 +75,3 @@ export function getUserShellEnv(): NodeJS.ProcessEnv {
}
return sanitizeShellEnv(process.env)
}
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
if (!supportsUserShell()) {
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
}
})
})
}
export function runUserShellCommandSync(userCommand: string): string {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
const result = spawnSync(command, args, { encoding: "utf-8", env })
if (result.status !== 0) {
const stderr = (result.stderr || "").toString().trim()
throw new Error(stderr || "Shell command failed")
}
return (result.stdout || "").toString().trim()
}

View file

@ -49,7 +49,6 @@
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}

View file

@ -1,24 +1,3 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
@ -26,78 +5,12 @@ interface NormalizedVersion {
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
export function compareVersionStrings(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
return compareVersions(left, right)
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
export function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()

View file

@ -16,7 +16,6 @@
"@kobalte/core": "0.13.11",
"@opencode/client": "2.0.24",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",

View file

@ -1,113 +0,0 @@
import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const inlineLoadedLanguages = new Set<string>()
type LoadLanguageArg = Parameters<Highlighter["loadLanguage"]>[0]
type CodeToHtmlOptions = Parameters<Highlighter["codeToHtml"]>[1]
interface CodeBlockInlineProps {
code: string
language?: string
}
export function CodeBlockInline(props: CodeBlockInlineProps) {
const { t } = useI18n()
const { isDark } = useTheme()
const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false)
const [ready, setReady] = createSignal(false)
let highlighter: Highlighter | null = null
onMount(async () => {
highlighter = await getSharedHighlighter()
setReady(true)
await updateHighlight()
})
createEffect(() => {
if (ready()) {
isDark()
props.code
props.language
void updateHighlight()
}
})
const updateHighlight = async () => {
if (!highlighter) return
if (!props.language) {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
return
}
try {
const language = props.language as LoadLanguageArg
if (!inlineLoadedLanguages.has(props.language)) {
await highlighter.loadLanguage(language)
inlineLoadedLanguages.add(props.language)
}
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light-high-contrast",
})
setHtml(highlighted)
} catch {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
}
}
const copyCode = async () => {
const success = await copyToClipboard(props.code)
if (success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<Show
when={ready()}
fallback={
<pre class="tool-call-content">
<code>{props.code}</code>
</pre>
}
>
<div class="code-block-inline">
<div class="code-block-header">
<Show when={props.language}>
<span class="code-block-language">{props.language}</span>
</Show>
<button onClick={copyCode} class="code-block-copy">
<svg
class="copy-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
{t("codeBlockInline.actions.copied")}
</Show>
</span>
</button>
</div>
<div innerHTML={html()} />
</div>
</Show>
)
}

View file

@ -5,8 +5,7 @@ import type { DrawerViewState } from "./types"
import { PlusSquare, Search } from "lucide-solid"
import IconButton from "@suid/material/IconButton"
import ArrowBackIcon from "@suid/icons-material/ArrowBack"
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import { ArrowLeft, Info } from "lucide-solid"
import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint"
@ -76,7 +75,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
aria-pressed={props.activeSessionId() === "info"}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
<Info size={20} aria-hidden="true" />
</IconButton>
<IconButton
size="small"
@ -85,7 +84,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<ArrowBackIcon fontSize="small" />
<ArrowLeft size={20} aria-hidden="true" />
</IconButton>
</div>
</div>

View file

@ -8,8 +8,7 @@ import {
type DragEvent as SolidDndDragEvent,
} from "@thisbeyond/solid-dnd"
import IconButton from "@suid/material/IconButton"
import ArrowForwardIcon from "@suid/icons-material/ArrowForward"
import { Settings2 } from "lucide-solid"
import { ArrowRight, Settings2 } from "lucide-solid"
import TabScroll from "../../../tab-scroll"
import type { Instance } from "../../../../types/instance"
@ -31,8 +30,6 @@ import {
} from "./registry"
import { createCoreRightPanelRuntime } from "./core-runtime"
import { FILES_PANEL_MIGRATION_KEY, mergeFilesPanelCustomization } from "./files-panel-state"
import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest"
import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins"
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
import { useI18n } from "../../../../lib/i18n"
import { useTheme } from "../../../../lib/theme"
@ -188,14 +185,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
moveTab(String(draggable.id), String(droppable.id))
}
const openRightPanelTab = (tabId: string) => {
updateRightPanelCustomization((current) => ({
...current,
hiddenTabIds: current.hiddenTabIds.filter((id) => id !== tabId),
}))
setRightPanelTab(tabId)
}
const handleTabKeyDown = (event: KeyboardEvent, currentTabId: string) => {
const tabs = visibleRightPanelTabs()
const index = tabs.findIndex((tab) => tab.id === currentTabId)
@ -213,37 +202,24 @@ const RightPanel: Component<RightPanelProps> = (props) => {
queueMicrotask(() => document.getElementById(tabId(target.id))?.focus())
}
const rightPanelPluginRuntime = loadRightPanelPluginManifests(
[
createCoreRightPanelRuntime({
isActive: props.isActive,
t: props.t,
instanceId: props.instanceId,
get instance() { return props.instance },
activeSessionId: props.activeSessionId,
activeSession: props.activeSession,
isPhoneLayout: props.isPhoneLayout,
rightDrawerWidth: props.rightDrawerWidth,
rightDrawerWidthInitialized: props.rightDrawerWidthInitialized,
promptInputApi: props.promptInputApi,
rightPanelTab,
expandedItems: rightPanelExpandedItems,
onExpandedItemsChange: handleAccordionChange,
customization: rightPanelCustomization,
onCustomizationChange: updateRightPanelCustomization,
extraStatusSections: () => extraStatusSections(),
}),
...RIGHT_PANEL_PLUGIN_MANIFESTS,
],
{
instanceId: props.instanceId,
t: props.t,
activeSessionId: props.activeSessionId,
isTabActive: (tabId) => rightPanelTab() === tabId,
openTab: openRightPanelTab,
reportAttention: () => undefined,
},
)
const coreModule = createCoreRightPanelRuntime({
isActive: props.isActive,
t: props.t,
instanceId: props.instanceId,
get instance() { return props.instance },
activeSessionId: props.activeSessionId,
activeSession: props.activeSession,
isPhoneLayout: props.isPhoneLayout,
rightDrawerWidth: props.rightDrawerWidth,
rightDrawerWidthInitialized: props.rightDrawerWidthInitialized,
promptInputApi: props.promptInputApi,
rightPanelTab,
expandedItems: rightPanelExpandedItems,
onExpandedItemsChange: handleAccordionChange,
customization: rightPanelCustomization,
onCustomizationChange: updateRightPanelCustomization,
extraStatusSections: () => extraStatusSections(),
})
const extensionForTab = (id: string) => extensions.entries().find(entry => id === `extension:${entry.manifest.id}`)
const externalModules = createMemo<RightPanelModule[]>(() => extensions.entries().map(entry => {
@ -253,8 +229,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
context={{ apiVersion: 1, sessionId: props.activeSessionId(), locale: locale(), appearance: theme.isDark() ? "dark" : "light" }} />,
}] }
}))
const rightPanelModules = createMemo(() => [...rightPanelPluginRuntime.modules, ...externalModules()])
const rightPanelPluginErrors = createMemo(() => rightPanelPluginRuntime.errors)
const rightPanelModules = createMemo(() => [coreModule, ...externalModules()])
const allRightPanelTabs = createMemo(() => collectRightPanelItems<RightPanelTabModule>(rightPanelModules(), "tabs"))
const visibleRightPanelTabs = createMemo(() =>
applyRightPanelItemCustomization(
@ -296,7 +271,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
title={props.t("instanceShell.rightDrawer.toggle.close")}
onClick={props.onCloseRightDrawer}
>
<ArrowForwardIcon fontSize="small" />
<ArrowRight size={20} aria-hidden="true" />
</IconButton>
<IconButton
ref={customizationTriggerRef}
@ -408,19 +383,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}}
</For>
<Show when={rightPanelPluginErrors().length > 0}>
<For each={rightPanelPluginErrors()}>
{(error: RightPanelPluginLoadError) => (
<div class="right-panel-customization-row">
<span class="right-panel-customization-label">
{props.t("instanceShell.rightPanel.customize.moduleUnavailable", {
module: error.displayNameKey ? props.t(error.displayNameKey) : error.pluginId,
})}
</span>
</div>
)}
</For>
</Show>
</div>
<Show when={extensions.failed()}><p role="alert">{props.t("panelExtensions.error")}</p></Show>
<button

View file

@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { it } from "node:test"
import { createCoreRightPanelModule, createCoreStatusSectionManifest } from "./core-plugin"
it("defines built-in tabs and status sections without a plugin loading layer", () => {
const render = () => undefined as any
const module = createCoreRightPanelModule({ renderFilesTab: render, renderStatusTab: render })
const sections = createCoreStatusSectionManifest({ renderTokens: render, renderYoloModeSection: render,
renderProviderUsage: render, renderBackgroundProcesses: render, renderMcpStatus: render, renderPluginStatus: render })
assert.deepEqual(module.tabs?.map(entry => entry.id), ["files", "status"])
assert.equal(module.tabs?.find(entry => entry.id === "status")?.alwaysVisible, true)
assert.deepEqual(sections.statusSections?.map(entry => entry.id), ["tokens", "yolo-mode", "provider-usage", "background-processes", "mcp", "plugins"])
assert.equal(module.tabs?.every(entry => entry.render === render), true)
assert.equal(sections.statusSections?.every(entry => entry.render === render), true)
})

View file

@ -1,6 +1,5 @@
import type { JSX } from "solid-js"
import type { RightPanelManifest } from "./plugin-manifest"
import type { RightPanelModule } from "./registry"
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
@ -18,33 +17,27 @@ interface CoreStatusSectionRenderers {
renderPluginStatus: () => JSX.Element
}
export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelManifest {
export function createCoreRightPanelModule(renderers: CoreRightPanelRenderers): RightPanelModule {
return {
id: "core-right-panel",
displayNameKey: "instanceShell.rightPanel.modules.core",
descriptionKey: "instanceShell.rightPanel.modules.core.description",
origin: "first-party",
create: () => ({
id: "core-right-panel",
displayNameKey: "instanceShell.rightPanel.modules.core",
descriptionKey: "instanceShell.rightPanel.modules.core.description",
origin: "first-party",
tabs: [
{
id: "files",
labelKey: "instanceShell.rightPanel.tabs.files",
order: 20,
render: renderers.renderFilesTab,
},
{
id: "status",
labelKey: "instanceShell.rightPanel.tabs.status",
order: 30,
alwaysVisible: true,
render: renderers.renderStatusTab,
},
],
}),
tabs: [
{
id: "files",
labelKey: "instanceShell.rightPanel.tabs.files",
order: 20,
render: renderers.renderFilesTab,
},
{
id: "status",
labelKey: "instanceShell.rightPanel.tabs.status",
order: 30,
alwaysVisible: true,
render: renderers.renderStatusTab,
},
],
}
}

View file

@ -9,7 +9,7 @@ import { closeFilePreview, openFilePreview, type FilePreviewTarget } from "../..
import { showSessionChat } from "../../../../stores/session-previews"
import { useGitChanges } from "./useGitChanges"
import { useGitHistory } from "./useGitHistory"
import { createCoreRightPanelManifest } from "./core-plugin"
import { createCoreRightPanelModule } from "./core-plugin"
import { useWorkspaceTree } from "./useWorkspaceTree"
import { FILES_PANEL_MODE_KEY, type FilesPanelMode } from "./files-panel-state"
import { readStoredEnum } from "../storage"
@ -76,7 +76,7 @@ export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOption
openFilePreview(options.instanceId, { ...file, sessionId, slug: slug(), directory: directory(), serviceDirectory: worktree()?.serviceDirectory })
}
onCleanup(() => closeFilePreview(options.instanceId))
return createCoreRightPanelManifest({
return createCoreRightPanelModule({
renderFilesTab: () => <LazyFilesPanel t={options.t} git={git} history={history} tree={tree} gitAvailable={gitAvailable()} mode={mode()} onModeChange={setMode}
instanceId={options.instanceId} worktrees={worktrees()} slug={slug()} directory={worktree()?.directory ?? options.instance.folder}
branch={worktree()?.branch ?? null} onWorktreeChange={value => {

View file

@ -1,90 +0,0 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { createCoreRightPanelManifest, createCoreStatusSectionManifest } from "./core-plugin"
import { loadRightPanelPluginManifests, type RightPanelHostContext, type RightPanelManifest } from "./plugin-manifest"
const host: RightPanelHostContext = {
instanceId: "abc",
t: (key) => key,
activeSessionId: () => "session-1",
isTabActive: () => false,
openTab: () => {},
}
const manifest = (id: string, events: string[]): RightPanelManifest => ({
id,
displayNameKey: id,
origin: "first-party",
create: (context) => {
events.push(`${id}:create:${context.instanceId}:${context.activeSessionId()}`)
return {
id,
displayNameKey: id,
origin: "first-party",
tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }],
}
},
})
describe("right panel plugin manifests", () => {
it("creates modules with host context", () => {
const events: string[] = []
const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], host)
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"])
assert.deepEqual(events, ["first:create:abc:session-1", "second:create:abc:session-1"])
})
it("skips duplicate ids without blocking other plugins", () => {
const events: string[] = []
const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], host)
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"])
assert.equal(runtime.errors.length, 1)
assert.equal(runtime.errors[0]?.pluginId, "plugin")
})
it("skips plugins that fail during load", () => {
const runtime = loadRightPanelPluginManifests(
[
{ id: "bad", displayNameKey: "bad", origin: "first-party", create: () => { throw new Error("boom") } },
manifest("good", []),
],
host,
)
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"])
assert.equal(runtime.errors.length, 1)
assert.equal(runtime.errors[0]?.pluginId, "bad")
})
it("defines core right panel tabs and status sections as manifests", () => {
const render = () => undefined as any
const rightPanel = createCoreRightPanelManifest({
renderFilesTab: render,
renderStatusTab: render,
})
const statusSections = createCoreStatusSectionManifest({
renderTokens: render,
renderYoloModeSection: render,
renderProviderUsage: render,
renderBackgroundProcesses: render,
renderMcpStatus: render,
renderPluginStatus: render,
})
const rightPanelModule = rightPanel.create(host)
assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["files", "status"])
assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true)
assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [
"tokens",
"yolo-mode",
"provider-usage",
"background-processes",
"mcp",
"plugins",
])
})
})

View file

@ -1,75 +0,0 @@
import type { Accessor } from "solid-js"
import type { RightPanelModule } from "./registry"
export interface RightPanelHostContext {
instanceId: string
t: (key: string, vars?: Record<string, any>) => string
activeSessionId: Accessor<string | null>
isTabActive: (tabId: string) => boolean
openTab: (tabId: string) => void
reportAttention?: (attention: RightPanelAttention) => void
}
export interface RightPanelAttention {
moduleId: string
tabId?: string
messageKey: string
severity: "info" | "warning" | "critical"
}
export interface RightPanelManifest {
id: string
displayNameKey: string
descriptionKey?: string
origin: "first-party"
create: (host: RightPanelHostContext) => RightPanelModule
}
export interface RightPanelPluginLoadError {
pluginId: string
displayNameKey?: string
phase: "create"
error: unknown
}
export interface LoadedRightPanelPlugins {
modules: RightPanelModule[]
errors: RightPanelPluginLoadError[]
}
export function loadRightPanelPluginManifests(
manifests: readonly RightPanelManifest[],
context: RightPanelHostContext,
): LoadedRightPanelPlugins {
const modules: RightPanelModule[] = []
const errors: RightPanelPluginLoadError[] = []
const seen = new Set<string>()
for (const manifest of manifests) {
if (!manifest.id || seen.has(manifest.id)) {
errors.push({
pluginId: manifest.id || "<missing>",
displayNameKey: manifest.displayNameKey,
phase: "create",
error: new Error("Duplicate or missing right panel plugin id"),
})
continue
}
seen.add(manifest.id)
try {
const module = manifest.create(context)
modules.push({
...module,
id: manifest.id,
displayNameKey: manifest.displayNameKey,
descriptionKey: manifest.descriptionKey ?? module.descriptionKey,
origin: manifest.origin,
})
} catch (error) {
errors.push({ pluginId: manifest.id, displayNameKey: manifest.displayNameKey, phase: "create", error })
}
}
return { modules, errors }
}

View file

@ -1,3 +0,0 @@
import type { RightPanelManifest } from "./plugin-manifest"
export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelManifest[] = []

View file

@ -1,4 +1,4 @@
import { readClientLayoutValue, writeClientLayoutValue } from "../../../stores/client-state"
import { readClientLayoutValue } from "../../../stores/client-state"
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
export const MIN_SESSION_SIDEBAR_WIDTH = 220
@ -16,16 +16,6 @@ export const RIGHT_OPEN_STORAGE_KEY = "opencode-session-right-drawer-open-v1"
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
export const RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY = "opencode-session-right-panel-customization-v1"
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
@ -39,31 +29,6 @@ export const clampRightWidth = (value: number) => {
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
}
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
const getOpenStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_OPEN_STORAGE_KEY : RIGHT_OPEN_STORAGE_KEY)
export function readStoredPinState(side: "left" | "right", defaultValue: boolean) {
const stored = readClientLayoutValue(getPinStorageKey(side))
if (stored === "true") return true
if (stored === "false") return false
return defaultValue
}
export function persistPinState(side: "left" | "right", value: boolean) {
writeClientLayoutValue(getPinStorageKey(side), value ? "true" : "false")
}
export function readStoredOpenState(side: "left" | "right", defaultValue: boolean) {
const stored = readClientLayoutValue(getOpenStorageKey(side))
if (stored === "true") return true
if (stored === "false") return false
return defaultValue
}
export function persistOpenState(side: "left" | "right", value: boolean) {
writeClientLayoutValue(getOpenStorageKey(side), value ? "true" : "false")
}
export function readStoredRightPanelTab(defaultValue: string): string {
const stored = readClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY)
if (stored === "status") return "status"
@ -81,20 +46,6 @@ export function readStoredRightPanelTab(defaultValue: string): string {
return defaultValue
}
export function readStoredPanelWidth(key: string, fallback: number) {
const stored = readClientLayoutValue(key)
if (!stored) return fallback
const parsed = Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
export function readStoredBool(key: string): boolean | null {
const stored = readClientLayoutValue(key)
if (stored === "true") return true
if (stored === "false") return false
return null
}
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
const stored = readClientLayoutValue(key)
if (!stored) return null

View file

@ -8,8 +8,7 @@ import {
type JSX,
type Setter,
} from "solid-js"
import ArrowForwardIcon from "@suid/icons-material/ArrowForward"
import ArrowBackIcon from "@suid/icons-material/ArrowBack"
import { ArrowRight, ArrowLeft } from "lucide-solid"
import type { TranslateParams } from "../../../lib/i18n"
@ -119,11 +118,11 @@ export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeAp
}
const leftAppBarButtonIcon = () => {
return createComponent(ArrowForwardIcon, { fontSize: "small" })
return createComponent(ArrowRight, { size: 20, "aria-hidden": true })
}
const rightAppBarButtonIcon = () => {
return createComponent(ArrowBackIcon, { fontSize: "small" })
return createComponent(ArrowLeft, { size: 20, "aria-hidden": true })
}
const handleLeftAppBarButtonClick = () => {

View file

@ -1,520 +0,0 @@
import { Dialog } from "@kobalte/core/dialog"
import { Switch } from "@kobalte/core/switch"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { toDataURL } from "qrcode"
import { ChevronRight, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli"
import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
const log = getLogger("actions")
interface RemoteAccessOverlayProps {
open: boolean
onClose: () => void
}
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
const list = addresses()
if (!allowExternalConnections()) {
return { recommended: null, hidden: [] }
}
return splitRemoteAddresses(list)
})
const refreshMeta = async () => {
setLoading(true)
setError(null)
setPasswordError(null)
try {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
createEffect(() => {
if (props.open) {
void refreshMeta()
}
})
const toggleExpanded = async (url: string) => {
if (expandedUrl() === url) {
setExpandedUrl(null)
return
}
setExpandedUrl(url)
if (!qrCodes()[url]) {
try {
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
} catch (err) {
log.error("Failed to generate QR code", err)
}
}
}
const handleAllowConnectionsChange = async (checked: boolean) => {
const allow = Boolean(checked)
const targetMode: "local" | "all" = allow ? "all" : "local"
if (targetMode === currentMode()) {
return
}
if (applyingListeningMode()) {
return
}
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) {
// Switch will revert automatically since `checked` is derived from store state
return
}
setApplyingListeningMode(true)
setError(null)
try {
// Important: await the config patch before restart so Electron reads the updated mode from disk.
await setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
}
void refreshMeta()
}
const handleOpenUrl = (url: string) => {
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (err) {
log.error("Failed to open URL", err)
}
}
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError(t("remoteAccess.password.error.tooShort"))
return
}
if (next !== confirm) {
setPasswordError(t("remoteAccess.password.error.mismatch"))
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return (
<Dialog
open={props.open}
modal
onOpenChange={(nextOpen) => {
if (!nextOpen) {
props.onClose()
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay remote-overlay-backdrop" />
<div class="remote-overlay">
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
<header class="remote-header">
<div>
<p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
<h2 class="remote-title">{t("remoteAccess.title")}</h2>
<p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
</div>
<button type="button" class="remote-close" onClick={props.onClose} aria-label={t("remoteAccess.close")}>
×
</button>
</header>
<div class="remote-body">
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
</div>
</div>
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
<span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
</button>
</div>
<Switch
class="remote-toggle"
checked={allowExternalConnections()}
onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked)
}}
disabled={loading() || applyingListeningMode()}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}</span>
<Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control>
<div class="remote-toggle-copy">
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
<span class="remote-toggle-caption">
{allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")}
</span>
</div>
</Switch>
<p class="remote-toggle-note">
{t("remoteAccess.toggle.note")}
</p>
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
</div>
</div>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">{t("remoteAccess.authStatus.unavailable")}</div>}
>
<div class="remote-card">
<p class="remote-help">
{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}
</p>
<p class="remote-help">
{authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.newPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder={t("remoteAccess.password.form.placeholder")}
/>
</div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.confirmPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
</Show>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
disabled={savingPassword()}
onClick={() => void handleSubmitPassword()}
>
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
</button>
</div>
</Show>
</div>
</Show>
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Wifi class="remote-icon" />
<div>
<p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
</div>
</div>
</div>
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list">
<Show when={meta()?.localUrl}>
{(url) => {
const value = () => url()
const expandedState = () => expandedUrl() === value()
const qr = () => qrCodes()[value()]
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{value()}</p>
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: value() })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<Show when={displayAddresses().recommended}>
{(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronRight class="remote-address-disclosure-chevron disclosure-chevron" />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</Show>
</Show>
</section>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View file

@ -1,39 +0,0 @@
import { createMemo, type Component } from "solid-js"
import { Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { useTheme } from "../lib/theme"
interface ThemeModeToggleProps {
class?: string
}
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
const { t } = useI18n()
const { themeMode, cycleThemeMode } = useTheme()
const modeLabel = () => {
const mode = themeMode()
if (mode === "system") return t("theme.mode.system")
if (mode === "light") return t("theme.mode.light")
return t("theme.mode.dark")
}
const icon = createMemo(() => {
const mode = themeMode()
if (mode === "system") return <Laptop class="w-4 h-4" />
if (mode === "light") return <Sun class="w-4 h-4" />
return <Moon class="w-4 h-4" />
})
return (
<button
type="button"
class={props.class ?? "new-tab-button"}
onClick={cycleThemeMode}
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
title={t("theme.toggle.title", { mode: modeLabel() })}
>
{icon()}
</button>
)
}

View file

@ -0,0 +1,101 @@
import assert from "node:assert/strict"
import { getEventListeners } from "node:events"
import test from "node:test"
import { streamToMediaSource } from "./audio-utils"
test("audio streaming preserves append order, playback timing, completion and failures", async () => {
for (const scenario of ["complete", "no-complete", "append-error", "play-error", "read-error"] as const) {
const events: string[] = []
const errors: unknown[] = []
const failure = new Error(scenario)
let errorMessageReads = 0
class SyntheticSourceBuffer extends EventTarget {
updating = false
appendBuffer(buffer: ArrayBuffer) {
assert.equal(this.updating, false, "appends must not overlap")
events.push(`append:${[...new Uint8Array(buffer)]}`)
this.updating = true
queueMicrotask(() => {
this.updating = false
if (scenario === "append-error") {
this.dispatchEvent(new Event("error"))
}
events.push("updateend")
this.dispatchEvent(new Event("updateend"))
})
}
}
const sourceBuffer = new SyntheticSourceBuffer()
const mediaSource = {
readyState: "open",
addSourceBuffer(mimeType: string) {
assert.equal(mimeType, "audio/mpeg")
return sourceBuffer
},
endOfStream() {
assert.equal(sourceBuffer.updating, false)
events.push("end")
},
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
if (scenario === "read-error") {
controller.error(failure)
return
}
controller.enqueue(new Uint8Array())
controller.enqueue(new Uint8Array([0, 1, 2, 0]).subarray(1, 3))
controller.enqueue(new Uint8Array([3]))
controller.close()
},
})
await streamToMediaSource({
mediaSource: mediaSource as unknown as MediaSource,
stream,
mimeType: "audio/mpeg",
onPlayable: async () => {
events.push("play")
if (scenario === "play-error") throw failure
},
onComplete: scenario === "no-complete" ? undefined : () => {
events.push("complete")
},
onError: (error) => {
errors.push(error)
},
appendErrorMessage: () => {
errorMessageReads += 1
return "Échec de la génération audio"
},
})
if (scenario === "complete" || scenario === "no-complete") {
assert.deepEqual(events, [
"append:1,2",
"updateend",
"play",
"append:3",
"updateend",
"end",
...(scenario === "complete" ? ["complete"] : []),
])
assert.deepEqual(errors, [])
} else {
assert.equal(errors.length, 1)
if (scenario === "append-error") {
assert.equal((errors[0] as Error).message, "Échec de la génération audio")
assert.deepEqual(events, ["append:1,2", "updateend"])
} else {
assert.equal(errors[0], failure)
assert.deepEqual(events, scenario === "play-error" ? ["append:1,2", "updateend", "play"] : [])
}
}
assert.equal(errorMessageReads, scenario === "append-error" ? 1 : 0)
assert.equal(getEventListeners(sourceBuffer, "updateend").length, 0)
assert.equal(getEventListeners(sourceBuffer, "error").length, 0)
}
})

View file

@ -19,3 +19,94 @@ export function createMediaRecorder(stream: MediaStream): MediaRecorder {
export function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
export function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
const binary = atob(audioBase64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
}
export async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
onPlayable: () => Promise<void>
onComplete?: () => void
onError: (error: unknown) => void
appendErrorMessage: () => string
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
const queue: Uint8Array[] = []
let processing = false
let startedPlayback = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk, options.appendErrorMessage)
if (!startedPlayback) {
startedPlayback = true
await options.onPlayable()
}
processing = false
await flushQueue()
}
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value && value.byteLength > 0) {
queue.push(value)
await flushQueue()
}
}
while (queue.length > 0 || sourceBuffer.updating) {
if (queue.length > 0) {
await flushQueue()
} else {
await waitForUpdateEnd(sourceBuffer)
}
}
if (options.mediaSource.readyState === "open") {
options.mediaSource.endOfStream()
}
options.onComplete?.()
} catch (error) {
options.onError(error)
}
}
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array, errorMessage: () => string): Promise<void> {
return new Promise((resolve, reject) => {
const handleUpdateEnd = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error(errorMessage()))
}
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
sourceBuffer.removeEventListener("error", handleError)
}
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
sourceBuffer.addEventListener("error", handleError, { once: true })
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
})
}
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
return new Promise((resolve) => {
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
})
}

View file

@ -3,16 +3,17 @@ import { it } from "node:test"
import { classicDarkSurfaces } from "./classic-dark-surfaces"
import { normalizeColorScheme } from "./theme-scheme"
it("restores the V1 dark message tint, roles, accent hover and borders", () => {
it("restores compatible V1 dark message tint, roles and borders without masking the current accent", () => {
const colors = normalizeColorScheme("classic").colors!
assert.equal(colors.userAccent, "#2196F3")
assert.equal(colors.agentAccent, "#D97706")
assert.equal(colors.compactionAccent, "#C084FC")
assert.deepEqual(classicDarkSurfaces(colors), {
"--message-user-bg": "#202734", "--accent-hover": "#0066CC",
"--message-user-bg": "#202734",
"--message-tool-border": "#ADB5BD", "--border-secondary": "#3A3A3A",
"--border-muted": "#3A3A3A", "--border-strong": "#3A3A3A",
})
assert.equal(classicDarkSurfaces({ ...colors, accentPrimary: "#0080FF" })["--accent-hover"], "#0066CC")
})
it("does not mask a customized dark palette", () => {

View file

@ -1,7 +1,5 @@
import type { Command } from "./commands"
import type { CommandInfo } from "@opencode/client"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
import { getLogger } from "./logger"
import { tGlobal } from "./i18n"
@ -27,41 +25,3 @@ export async function promptForCommandArguments(command: CommandInfo): Promise<s
return null
}
}
function formatCommandLabel(name: string): string {
if (!name) return ""
return name.charAt(0).toUpperCase() + name.slice(1)
}
export function buildCustomCommandEntries(instanceId: string, commands: CommandInfo[]): Command[] {
return commands.map((cmd) => ({
id: `custom:${instanceId}:${cmd.name}`,
label: formatCommandLabel(cmd.name),
description: () => cmd.description ?? tGlobal("commands.custom.entries.descriptionFallback"),
category: "Custom Commands",
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
action: async () => {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId || sessionId === "info") {
showAlertDialog(tGlobal("commands.custom.sessionRequired.message"), {
title: tGlobal("commands.custom.sessionRequired.title"),
variant: "warning",
})
return
}
try {
const args = await promptForCommandArguments(cmd)
if (args === null) {
return
}
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
log.error("Failed to run custom command", error)
showAlertDialog(tGlobal("commands.custom.runFailed.message"), {
title: tGlobal("commands.custom.runFailed.title"),
variant: "error",
})
}
},
}))
}

View file

@ -5,6 +5,7 @@ import { useI18n } from "../i18n"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { formatToMimeType, getSpeechPlaybackSupport } from "../speech-playback-support"
import { createObjectUrlFromBase64, streamToMediaSource } from "../audio-utils"
type SpeechPlaybackState = "idle" | "loading" | "playing"
@ -263,7 +264,7 @@ export function useSpeech(options: UseSpeechOptions) {
mediaSource: nextMediaSource,
stream,
mimeType,
audioElement: nextAudio,
appendErrorMessage: () => "Failed to append audio stream chunk.",
onPlayable: async () => {
if (currentRequest !== requestVersion) return
if (state() !== "playing") {
@ -323,94 +324,3 @@ function attachPlaybackLifecycle(ownerId: string, audio: HTMLAudioElement) {
audio.addEventListener("ended", finish, { once: true })
audio.addEventListener("error", finish, { once: true })
}
async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
audioElement: HTMLAudioElement
onPlayable: () => Promise<void>
onComplete: () => void
onError: (error: unknown) => void
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
let startedPlayback = false
let queue: Uint8Array[] = []
let processing = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk)
if (!startedPlayback) {
startedPlayback = true
await options.onPlayable()
}
processing = false
await flushQueue()
}
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value && value.byteLength > 0) {
queue.push(value)
await flushQueue()
}
}
while (queue.length > 0 || sourceBuffer.updating) {
if (queue.length > 0) {
await flushQueue()
} else {
await waitForUpdateEnd(sourceBuffer)
}
}
if (options.mediaSource.readyState === "open") {
options.mediaSource.endOfStream()
}
options.onComplete()
} catch (error) {
options.onError(error)
}
}
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const handleUpdateEnd = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to append audio stream chunk."))
}
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
sourceBuffer.removeEventListener("error", handleError)
}
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
sourceBuffer.addEventListener("error", handleError, { once: true })
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
})
}
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
return new Promise((resolve) => {
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
})
}
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
const binary = atob(audioBase64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
}

View file

@ -1,30 +1 @@
import type { KeyboardShortcut } from "./keyboard-registry"
export const isMac = () => navigator.platform.toLowerCase().includes("mac")
export const modKey = (event?: KeyboardEvent) => {
if (!event) return isMac() ? "metaKey" : "ctrlKey"
return isMac() ? event.metaKey : event.ctrlKey
}
export const modKeyPressed = (event: KeyboardEvent) => {
return isMac() ? event.metaKey : event.ctrlKey
}
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
const parts: string[] = []
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
parts.push(isMac() ? "Cmd" : "Ctrl")
}
if (shortcut.modifiers.shift) {
parts.push("Shift")
}
if (shortcut.modifiers.alt) {
parts.push(isMac() ? "Option" : "Alt")
}
parts.push(shortcut.key.toUpperCase())
return parts.join("+")
}

View file

@ -111,46 +111,6 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
assert.doesNotMatch(footer, /favoritesOnly\.showAll/)
})
test("the favorites mode is a stored preference that never follows the active model", () => {
const source = fs.readFileSync(new URL("../components/model-selector.tsx", import.meta.url), "utf8")
const mode = source.slice(source.indexOf("const favoritesOnlyPreference"), source.indexOf("const visibleOptions"))
const visible = source.slice(source.indexOf("const visibleOptions"), source.indexOf("const groupedVisibleOptions"))
const toggle = source.slice(source.indexOf("const canChooseFavoritesMode"), source.indexOf("const favoritesToggleLabel"))
// The stored choice is reported as such and is revocable even without favorites.
assert.match(mode, /const favoritesOnlyPreference = \(\) => getFavoritesOnlyPreference\(\)/)
assert.match(mode, /createMemo\(\(\) => hasFavorites\(\) && favoritesOnlyPreference\(\)\)/)
assert.doesNotMatch(mode, /searchActive|currentModelIsFavorite/)
assert.match(toggle, /const canChooseFavoritesMode = createMemo\(\(\) => hasFavorites\(\) \|\| favoritesOnlyPreference\(\)\)/)
assert.match(toggle, /setFavoritesOnlyPreference\(!favoritesOnlyPreference\(\)\)/)
assert.doesNotMatch(toggle, /if \(!hasFavorites\(\)\) return/)
// The active model is added to the chosen mode unless its own visibility
// preference hides it, which keeps a hidden model unselectable.
assert.match(visible, /const modeModels = favoritesOnlyEnabled\(\) \? favoriteModels\(\) : sortedModels\(\)/)
assert.match(visible, /if \(!current \|\| modeModels\.some\(\(model\) => model\.key === current\.key\)\) return modeModels/)
assert.match(visible, /const hiddenByPreference = !current\.unavailable && !isModelVisible\([\s\S]{0,140}current\.id,[\s\S]{0,40}\)/)
assert.match(visible, /if \(hiddenByPreference\) return modeModels/)
assert.match(visible, /return \[\.\.\.modeModels, current\]\.sort\(compareModels\)/)
for (const removed of [
"setManualAll", "setExplicitFavorites", "autoFavoritesEligibleAtOpen",
"wasFavoritesOnlyEnabled", "wasCurrentModelFavorite", "currentModelIsFavorite",
]) {
assert.doesNotMatch(source, new RegExp(removed))
}
// The write is published synchronously and serialized in click order.
const preferences = fs.readFileSync(new URL("../stores/preferences.tsx", import.meta.url), "utf8")
assert.match(preferences, /favoritesOnly: \(source\.models as any\)\?\.favoritesOnly === true/)
assert.match(preferences, /models: \{\s*recents: ModelPreference\[\]\s*favorites: ModelPreference\[\]\s*thinkingSelections: Record<string, string>\s*favoritesOnly: boolean\s*\}/)
const modeStore = preferences.slice(preferences.indexOf("const [pendingFavoritesOnly"), preferences.indexOf("function getModelThinkingSelection"))
assert.match(modeStore, /return pendingFavoritesOnly\(\) \?\? uiState\(\)\.models\.favoritesOnly/)
assert.match(modeStore, /if \(getFavoritesOnlyPreference\(\) === enabled\) return\s*setPendingFavoritesOnly\(enabled\)/)
assert.match(modeStore, /const write = favoritesOnlyWriteQueue\s*\.catch\(\(\) => undefined\)\s*\.then\(async \(\) => \{[\s\S]{0,240}\}\)/)
assert.match(modeStore, /favoritesOnlyWriteQueue = write\s*latestFavoritesOnlyWrite = write\s*void write\.then\(\(\) => \{[\s\S]{0,200}if \(latestFavoritesOnlyWrite !== write\) return[\s\S]{0,200}setPendingFavoritesOnly/)
})
test("provider auth keeps its catalog location across deferred operation steps", () => {
const source = fs.readFileSync(
new URL("../components/provider-auth/provider-manager-modal.tsx", import.meta.url),

View file

@ -23,10 +23,11 @@ test("native images survive normalization without entering copy, local search or
assert.deepEqual(state.content, content)
assert.equal(state.content?.filter(isToolImageContent).length, 1)
const chrome = defaultRenderer.getOutputChrome!({ toolState: () => state } as ToolRendererContext)
const copyText = chrome?.getCopyText?.() ?? chrome?.copyText
const search = defaultRenderer.getSearchText!({ toolCall: part, toolState: state, toolName: "mcp_image" })
const speech = buildToolSpeechText({ title: "Image tool", state, t: key => key })
for (const text of [chrome?.copyText ?? "", search.join(" "), speech]) assert.ok(!text.includes("base64"))
assert.equal(chrome?.copyText, content.length === 1 ? undefined : "Generated image")
for (const text of [copyText ?? "", search.join(" "), speech]) assert.ok(!text.includes("base64"))
assert.equal(copyText, content.length === 1 ? undefined : "Generated image")
}
})

View file

@ -1,86 +0,0 @@
import { applyPatch, parsePatch } from "diff"
type ParsedPatchIndex = ReturnType<typeof parsePatch>[number]
type ParsedHunk = ParsedPatchIndex["hunks"][number]
type SdkPatch = {
oldFileName: string
newFileName: string
oldHeader?: string
newHeader?: string
hunks: Array<{
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: Array<string>
}>
index?: string
}
function invertPatchLine(line: string): string {
if (!line) return line
const op = line[0]
if (op === "+") return `-${line.slice(1)}`
if (op === "-") return `+${line.slice(1)}`
return line
}
function reverseParsedHunk(hunk: ParsedHunk): ParsedHunk {
return {
oldStart: hunk.newStart,
oldLines: hunk.newLines,
newStart: hunk.oldStart,
newLines: hunk.oldLines,
lines: hunk.lines.map(invertPatchLine),
linedelimiters: Array.isArray((hunk as any).linedelimiters) ? (hunk as any).linedelimiters : [],
} as ParsedHunk
}
function reverseParsedIndex(index: ParsedPatchIndex): ParsedPatchIndex {
const hunks = Array.isArray(index.hunks) ? index.hunks : []
return {
...index,
oldFileName: (index as any).newFileName,
newFileName: (index as any).oldFileName,
oldHeader: (index as any).newHeader,
newHeader: (index as any).oldHeader,
hunks: hunks.map(reverseParsedHunk),
} as ParsedPatchIndex
}
export function buildUnifiedDiffFromSdkPatch(patch: SdkPatch): string {
const oldName = patch.oldFileName || "a/file"
const newName = patch.newFileName || "b/file"
const oldHeader = patch.oldHeader ? `\t${patch.oldHeader}` : ""
const newHeader = patch.newHeader ? `\t${patch.newHeader}` : ""
const lines: string[] = []
if (patch.index) {
// jsdiff can parse arbitrary metadata lines before file headers.
lines.push(`Index: ${patch.index}`)
}
lines.push(`--- ${oldName}${oldHeader}`)
lines.push(`+++ ${newName}${newHeader}`)
for (const hunk of patch.hunks || []) {
const oldRange = hunk.oldLines === 1 ? `${hunk.oldStart}` : `${hunk.oldStart},${hunk.oldLines}`
const newRange = hunk.newLines === 1 ? `${hunk.newStart}` : `${hunk.newStart},${hunk.newLines}`
lines.push(`@@ -${oldRange} +${newRange} @@`)
for (const line of hunk.lines || []) {
lines.push(line)
}
}
return `${lines.join("\n")}\n`
}
export function tryReverseApplyUnifiedDiff(afterText: string, diffText: string): string | null {
const normalized = (diffText ?? "").trim()
if (!normalized) return null
const parsed = parsePatch(diffText)
if (!Array.isArray(parsed) || parsed.length === 0) return null
const reversed = reverseParsedIndex(parsed[0])
const result = applyPatch(afterText ?? "", reversed)
return typeof result === "string" ? result : null
}

View file

@ -4,6 +4,7 @@ import { showToastNotification } from "../lib/notifications"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
import { createObjectUrlFromBase64, streamToMediaSource } from "../lib/audio-utils"
import { serverSettings } from "./preferences"
import { loadSpeechCapabilities, speechCapabilities } from "./speech"
import { getActiveSession, sessions } from "./session-state"
@ -400,6 +401,7 @@ async function createStreamingPlaybackHandle(text: string, format: SpeechTtsForm
mediaSource,
stream,
mimeType,
appendErrorMessage: () => tGlobal("messageItem.actions.speak.error.generate"),
onPlayable: async () => {
if (startedPlayback) return
startedPlayback = true
@ -423,94 +425,6 @@ async function createStreamingPlaybackHandle(text: string, format: SpeechTtsForm
}
}
async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
onPlayable: () => Promise<void>
onError: (error: unknown) => void
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
const queue: Uint8Array[] = []
let processing = false
let playbackStarted = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk)
if (!playbackStarted) {
playbackStarted = true
await options.onPlayable()
}
processing = false
await flushQueue()
}
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value && value.byteLength > 0) {
queue.push(value)
await flushQueue()
}
}
while (queue.length > 0 || sourceBuffer.updating) {
if (queue.length > 0) {
await flushQueue()
} else {
await waitForUpdateEnd(sourceBuffer)
}
}
if (options.mediaSource.readyState === "open") {
options.mediaSource.endOfStream()
}
} catch (error) {
options.onError(error)
}
}
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const handleUpdateEnd = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error(tGlobal("messageItem.actions.speak.error.generate")))
}
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
sourceBuffer.removeEventListener("error", handleError)
}
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
sourceBuffer.addEventListener("error", handleError, { once: true })
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
})
}
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
return new Promise((resolve) => {
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
})
}
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
const binary = atob(audioBase64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
}
function extractLeadingSpokenBlock(text: string): string {
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
if (!match?.[1]) return ""

View file

@ -1,10 +1,10 @@
import type { PermissionRequest } from "../../types/permission"
import { getPermissionCallId, getPermissionMessageId, getPermissionSessionId } from "../../types/permission"
import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Message, MessageInfo } from "../../types/message"
import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
import { canHydrateMessages } from "./message-hydration-authority"
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types"
import type { MessageStatus, SessionRevertState } from "./types"
interface SessionMetadata {
id: string
@ -12,15 +12,6 @@ interface SessionMetadata {
parentId?: string | null
}
function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined {
if (!session) return undefined
return {
id: session.id,
title: session.title,
parentId: session.parentId ?? null,
}
}
function normalizeStatus(status: Message["status"]): MessageStatus {
switch (status) {
case "sending":
@ -79,66 +70,6 @@ export function seedSessionMessagesV2(
return true
}
interface MessageInfoOptions {
status?: MessageStatus
bumpRevision?: boolean
}
export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null | undefined, options?: MessageInfoOptions): void {
if (!info || typeof info.id !== "string" || typeof info.sessionID !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
store.upsertMessage({
id: info.id,
sessionId: info.sessionID,
role: info.role === "user" ? "user" : "assistant",
status: options?.status ?? "complete",
createdAt,
updatedAt: completedAt ?? createdAt,
bumpRevision: Boolean(options?.bumpRevision),
})
store.setMessageInfo(info.id, info)
}
export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | undefined): void {
if (!part || typeof part.messageID !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
store.applyPartUpdate({
messageId: part.messageID,
part,
})
}
export function applyPartDeltaV2(
instanceId: string,
input: { messageId: string; partId: string; field: string; delta: string },
): void {
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
store.applyPartDelta({
messageId: input.messageId,
partId: input.partId,
field: input.field,
delta: input.delta,
bumpSessionRevision: true,
})
}
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void {
if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.replaceMessageId({ oldId, newId, ...(options ?? {}) })
}
function extractPermissionMessageId(permission: PermissionRequest): string | undefined {
return getPermissionMessageId(permission)
}
@ -241,28 +172,6 @@ export function removeMessageV2(instanceId: string, messageId: string, sessionId
store.removeMessage(messageId, sessionId)
}
export function removeMessagePartV2(instanceId: string, messageId: string, partId: string, sessionId?: string): void {
if (!messageId || !partId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removeMessagePart(messageId, partId, sessionId)
}
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)
const existingMessageIds = store.getSessionMessageIds(session.id)
store.addOrUpdateSession({
id: session.id,
title: session.title,
parentId: session.parentId ?? null,
messageIds: existingMessageIds,
})
}
export function getSessionMetadataFromStore(session?: Session | null): SessionMetadata | undefined {
return resolveSessionMetadata(session ?? undefined)
}
export function setSessionRevertV2(instanceId: string, sessionId: string, revert?: SessionRevertState | null): void {
if (!sessionId) return
const store = messageStoreBus.getOrCreate(instanceId)

View file

@ -191,18 +191,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
store.clearInstance()
})
it("clears pending parts omitted by authoritative hydration", () => {
const store = createInstanceMessageStore("pending-cleanup")
store.hydrateMessages("session-1", [{ id: "old", sessionId: "session-1", role: "assistant", status: "complete" }])
store.bufferPendingPart({ messageId: "old", sessionId: "session-1", part: { type: "text", text: "pending" } as any, receivedAt: 1 })
store.hydrateMessages("session-1", [{ id: "current", sessionId: "session-1", role: "user", status: "complete" }])
assert.equal(store.state.pendingParts.old, undefined)
assert.deepEqual(store.getSessionMessageIds("session-1"), ["current"])
store.clearInstance()
})
it("keeps an in-flight pending 'sending' message visible when a force reload snapshot doesn't include it yet", () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })
@ -266,7 +254,33 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
assert.deepEqual(record?.clientPromptDisplayMetadata, displayMetadata, "metadata survives the same-id confirmation")
})
it("retains client part identity through a metadata-only same-id snapshot", () => {
it("carries pending-send identity and display metadata through message ID replacement", () => {
const store = createInstanceMessageStore("replacement")
const metadata = { segments: [{ kind: "pasted" as const, length: 5 }] }
store.upsertMessage({
id: "temporary", sessionId: "session", role: "user", status: "sending", isEphemeral: true,
parts: [{ id: "client-part", type: "text", text: "hello" } as any], clientPromptDisplayMetadata: metadata,
})
store.markSendPending("temporary")
store.replaceMessageId({ oldId: "temporary", newId: "confirmed" })
store.reconcileEmptyAuthoritativeSnapshot("session")
assert.equal(store.getMessage("temporary"), undefined)
assert.deepEqual(store.getSessionMessageIds("session"), ["confirmed"])
assert.equal(store.hasPendingSends("session"), true)
assert.deepEqual(store.getMessage("confirmed")?.clientPromptDisplayMetadata, metadata)
store.hydrateMessages("session", [{
id: "confirmed", sessionId: "session", role: "user", status: "complete", isEphemeral: false,
parts: [{ id: "server-part", type: "text", text: "hello" } as any],
}])
assert.equal(store.hasPendingSends("session"), false)
assert.deepEqual(store.getMessage("confirmed")?.partIds, ["server-part"])
assert.deepEqual(store.getMessage("confirmed")?.clientPromptDisplayMetadata, metadata)
store.clearInstance()
})
it("retains client parts through a metadata-only snapshot until authoritative parts replace them", () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })
store.upsertMessage({
@ -278,13 +292,14 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
{ id: "msg-1", sessionId: "session-1", role: "user", status: "complete", isEphemeral: false },
])
store.confirmServerMessage("msg-1", { clearOptimisticParts: true })
store.applyPartUpdate({
messageId: "msg-1",
part: { id: "server-part", type: "text", text: "hello" } as any,
})
assert.deepEqual(store.getMessage("msg-1")?.partIds, ["client-part"])
store.hydrateMessages("session-1", [{
id: "msg-1", sessionId: "session-1", role: "user", status: "complete", isEphemeral: false,
parts: [{ id: "server-part", type: "text", text: "hello" } as any],
}])
assert.deepEqual(store.getMessage("msg-1")?.partIds, ["server-part"])
assert.equal(store.hasPendingSends("session-1"), false)
})
it("dedupes repeated snapshot ids so messageIds never repeat", () => {
@ -363,16 +378,16 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
store.reconcileEmptyAuthoritativeSnapshot("session-1")
assert.deepEqual(store.getSessionMessageIds("session-1"), ["msg-accepted"])
store.confirmServerMessage("msg-accepted", { clearOptimisticParts: true })
store.applyPartUpdate({
messageId: "msg-accepted",
part: { id: "server-part", type: "text", text: "hello", synthetic: false } as any,
})
store.hydrateMessages("session-1", [{
id: "msg-accepted", sessionId: "session-1", role: "user", status: "complete", isEphemeral: false,
parts: [{ id: "server-part", type: "text", text: "hello", synthetic: false } as any],
}])
assert.deepEqual(store.getMessage("msg-accepted")?.partIds, ["server-part"])
assert.equal(store.getMessage("msg-accepted")?.isEphemeral, false)
assert.equal(store.hasPendingSends("session-1"), false)
})
it("clears only client-created parts when the server confirms a send", () => {
it("replaces client parts with the authoritative snapshot without discarding server synthetic parts", () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })
store.upsertMessage({
@ -380,14 +395,14 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
parts: [{ id: "client-part", type: "text", text: "hello", synthetic: true } as any], isEphemeral: true,
})
store.markSendPending("msg-1")
store.applyPartUpdate({
messageId: "msg-1",
part: { id: "server-synthetic", type: "text", text: "server", synthetic: true } as any,
})
store.confirmServerMessage("msg-1", { clearOptimisticParts: true })
store.hydrateMessages("session-1", [{
id: "msg-1", sessionId: "session-1", role: "user", status: "complete", isEphemeral: false,
parts: [{ id: "server-synthetic", type: "text", text: "server", synthetic: true } as any],
}])
assert.deepEqual(store.getMessage("msg-1")?.partIds, ["server-synthetic"])
assert.equal((store.getMessage("msg-1")?.parts["server-synthetic"]?.data as any).synthetic, true)
assert.equal(store.hasPendingSends("session-1"), false)
})
it("drops an unconfirmed accepted send once idle message authority settles", () => {
@ -416,7 +431,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
id: "msg-stale", sessionID: "session-1", role: "assistant", time: { created: 1 },
tokens: { input: 2, output: 3, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 1,
} as any])
store.bufferPendingPart({ messageId: "msg-stale", part: { type: "text", text: "late" } as any, receivedAt: Date.now() })
store.upsertPermission({
permission: { id: "permission-stale", sessionID: "session-1", action: "edit", resources: [] },
messageId: "msg-stale",
@ -434,7 +448,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
assert.deepEqual(store.getSessionMessageIds("session-1"), ["msg-inflight"], "stale records cleared, in-flight send preserved")
assert.equal(store.getMessage("msg-stale"), undefined)
assert.equal(store.state.pendingParts["msg-stale"], undefined)
assert.equal(store.state.permissions.byMessage["msg-stale"], undefined)
assert.equal(store.state.permissions.queue.length, 0)
assert.equal(store.state.usage["session-1"].totalInputTokens, 0)
@ -451,7 +464,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
id: "msg-old", sessionID: "session-1", role: "assistant", time: { created: 1 },
tokens: { input: 2, output: 3, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 1,
} as any])
store.bufferPendingPart({ messageId: "msg-old", part: { type: "text", text: "late" } as any, receivedAt: Date.now() })
store.upsertPermission({
permission: { id: "permission-old", sessionID: "session-1", action: "edit", resources: [] },
messageId: "msg-old",
@ -465,7 +477,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
assert.deepEqual(store.getSessionMessageIds("session-1"), ["msg-new"])
assert.equal(store.getMessage("msg-old"), undefined)
assert.equal(store.state.pendingParts["msg-old"], undefined)
assert.equal(store.state.permissions.byMessage["msg-old"], undefined)
assert.equal(store.state.permissions.queue.length, 0)
assert.equal(store.state.usage["session-1"].totalInputTokens, 0)

View file

@ -14,7 +14,7 @@ import {
import type { ClientPart, MessageInfo } from "../../types/message"
import { mergePermissionRequest } from "../../types/permission"
import { clearRecordDisplayCacheForInstance, clearRecordDisplayCacheForMessages, getRecordDisplayCacheEntries } from "./record-display-cache"
import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "../../lib/retained-size"
import { estimateRetainedBytesIncrementally } from "../../lib/retained-size"
import { clearInstanceMessageRenderCaches, clearSessionMessageRenderCache, peekSessionMessageRenderCache } from "../../lib/message-render-cache"
import type { MessageWindowState } from "./message-window"
import { shouldSkipPendingRequestUpsert } from "./pending-request-dedupe"
@ -23,8 +23,6 @@ import type {
LatestTodoSnapshot,
MessageRecord,
MessageUpsertInput,
PartUpdateInput,
PendingPartEntry,
PermissionEntry,
ReplaceMessageIdOptions,
ScrollSnapshot,
@ -51,7 +49,6 @@ function createInitialState(instanceId: string): InstanceMessageState {
messages: {},
lastAssistantMessageIds: {},
messageInfoVersion: {},
pendingParts: {},
sessionRevisions: {},
permissions: {
queue: [],
@ -78,52 +75,8 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
return fallbackId
}
const PENDING_PART_MAX_AGE_MS = 30_000
const PENDING_PARTS_PER_MESSAGE_LIMIT = 100
const PENDING_PARTS_PER_SESSION_LIMIT = 100
const PENDING_PARTS_GLOBAL_LIMIT = 500
const PENDING_PART_MAX_RETAINED_BYTES = 1024 * 1024
const PENDING_PARTS_PER_SESSION_BYTE_LIMIT = 4 * 1024 * 1024
const PENDING_PARTS_GLOBAL_BYTE_LIMIT = 8 * 1024 * 1024
const DROPPED_PENDING_MESSAGE_LIMIT = 500
const MAX_TRANSCRIPT_MEASUREMENT_BYTES = 64 * 1024 * 1024
const MAX_TRANSCRIPT_MEASUREMENT_NODES = 500_000
const pendingPartRetainedBytes = Symbol("pendingPartRetainedBytes")
const pendingPartBudgetId = Symbol("pendingPartBudgetId")
let nextPendingPartBudgetId = 0
const pendingPartBudgetEntries = new Map<number, {
instanceId: string
bytes: number
receivedAt: number
isRetained: () => boolean
remove: () => void
}>()
type SizedPendingPartEntry = PendingPartEntry & { [pendingPartRetainedBytes]?: number; [pendingPartBudgetId]?: number }
function getPendingPartRetainedBytes(entry: PendingPartEntry): number {
return (entry as SizedPendingPartEntry)[pendingPartRetainedBytes]
?? estimateRetainedBytes(entry, PENDING_PART_MAX_RETAINED_BYTES)
}
function forgetPendingPartBudgetEntry(entry: PendingPartEntry): void {
const id = (entry as SizedPendingPartEntry)[pendingPartBudgetId]
if (id !== undefined) pendingPartBudgetEntries.delete(id)
}
function enforceGlobalPendingPartBudget(): void {
for (const [id, entry] of pendingPartBudgetEntries) if (!entry.isRetained()) pendingPartBudgetEntries.delete(id)
let bytes = 0
for (const entry of pendingPartBudgetEntries.values()) bytes += entry.bytes
if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) return
const oldest = [...pendingPartBudgetEntries.entries()].sort((left, right) =>
left[1].receivedAt - right[1].receivedAt || left[0] - right[0])
for (const [id, entry] of oldest) {
if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break
pendingPartBudgetEntries.delete(id)
bytes -= entry.bytes
entry.remove()
}
}
function clonePart(part: ClientPart): ClientPart {
// Cloning is intentionally disabled; message parts
@ -131,11 +84,6 @@ function clonePart(part: ClientPart): ClientPart {
return part
}
function cloneStructuredValue<T>(value: T): T {
// Legacy helper kept as a no-op to avoid deep copies.
return value
}
function areMessageIdListsEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false
@ -265,24 +213,12 @@ export interface InstanceMessageStore {
reconcileAuthoritativeMessageIds: (sessionId: string, authoritativeIds: ReadonlySet<string>, baselineRevisions: ReadonlyMap<string, number>) => void
markSendPending: (messageId: string) => void
acceptSend: (messageId: string) => void
confirmServerMessage: (messageId: string, options?: { clearOptimisticParts?: boolean }) => void
failSend: (messageId: string) => void
failPendingSends: (sessionId: string) => void
retirePendingSends: (sessionId: string) => void
upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void
applyPartDelta: (input: {
messageId: string
partId: string
field: string
delta: string
bumpRevision?: boolean
bumpSessionRevision: boolean
}) => void
removeMessage: (messageId: string, fallbackSessionId?: string) => void
removeMessagePart: (messageId: string, partId: string, fallbackSessionId?: string) => void
bufferPendingPart: (entry: PendingPartEntry) => void
flushPendingParts: (messageId: string) => void
replaceMessageId: (options: ReplaceMessageIdOptions) => void
setMessageInfo: (messageId: string, info: MessageInfo) => void
getMessageInfo: (messageId: string) => MessageInfo | undefined
@ -325,12 +261,9 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
// Requests awaiting same-ID persistence confirmation.
const pendingSendIds = new Set<string>()
const droppedPendingMessageIds = new Set<string>()
const optimisticPartIdsByMessage = new Map<string, Set<string>>()
function forgetPendingSend(messageId: string): void {
pendingSendIds.delete(messageId)
optimisticPartIdsByMessage.delete(messageId)
}
function preservePendingSendOnOmission(messageId: string): boolean {
@ -491,14 +424,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const renderCache = peekSessionMessageRenderCache(instanceId, sessionId)
const globalCacheEntries = [...getCacheRetainedEntriesForSession(instanceId, sessionId)]
const globalCacheKeyBytes = globalCacheEntries.reduce((total, entry) => total + entry.keyBytes, 0)
let hasPendingParts = false
for (const messageId in state.pendingParts) {
if (state.pendingParts[messageId]?.some((entry) => entry.sessionId === sessionId)) {
hasPendingParts = true
break
}
}
if ((!session || (session.messageIds.length === 0 && !session.revert)) && !renderCache && !hasPendingParts && globalCacheEntries.length === 0) {
if ((!session || (session.messageIds.length === 0 && !session.revert)) && !renderCache && globalCacheEntries.length === 0) {
return Promise.resolve(0)
}
function* retainedValues(): Generator<unknown> {
@ -507,11 +433,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
yield state.messages[messageId]
yield messageInfoCache.get(messageId)
yield state.messageInfoVersion[messageId]
yield state.pendingParts[messageId]
}
for (const messageId in state.pendingParts) {
const entries = state.pendingParts[messageId]
if (entries?.some((entry) => entry.sessionId === sessionId) && !state.messages[messageId]) yield entries
}
for (const entry of state.permissions.queue) if (entry.permission.sessionID === sessionId) yield entry
if (session) {
@ -633,11 +554,9 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
seenInputIds.add(input.id)
return true
})
for (const input of dedupedInputs) droppedPendingMessageIds.delete(input.id)
const serverIds = dedupedInputs.map((item) => item.id)
const serverIdSet = new Set(serverIds)
const serverIdsWithParts = new Set(dedupedInputs.filter((item) => item.parts !== undefined).map((item) => item.id))
// Preserve only requests that have not received a promptAsync response
// yet. Accepted prompts are confirmed under the same messageID, while
@ -708,7 +627,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const nextMessages: Record<string, MessageRecord> = { ...state.messages }
const nextMessageInfoVersion: Record<string, number> = { ...state.messageInfoVersion }
const nextPendingParts: Record<string, PendingPartEntry[]> = { ...state.pendingParts }
const nextPermissionsByMessage: Record<string, Record<string, PermissionEntry>> = {
...state.permissions.byMessage,
}
@ -724,10 +642,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageInfoCache.delete(id)
forgetPendingSend(id)
clearPromptDisplayOverride(instanceId, sessionId, id)
nextPendingParts[id]?.forEach(forgetPendingPartBudgetEntry)
delete nextMessages[id]
delete nextMessageInfoVersion[id]
delete nextPendingParts[id]
delete nextPermissionsByMessage[id]
})
clearRecordDisplayCacheForMessages(instanceId, omittedIds)
@ -739,7 +655,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
if (options?.confirmPending !== false) {
serverIds.forEach((id) => {
pendingSendIds.delete(id)
if (serverIdsWithParts.has(id)) optimisticPartIdsByMessage.delete(id)
})
}
@ -764,7 +679,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
batch(() => {
setState("messages", () => nextMessages)
setState("messageInfoVersion", () => nextMessageInfoVersion)
setState("pendingParts", () => nextPendingParts)
setState("permissions", "byMessage", () => nextPermissionsByMessage)
// Solid store object updates merge, so omitted keys are deleted explicitly.
@ -785,14 +699,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
}),
)
setState(
"pendingParts",
produce((draft) => {
omittedIds.forEach((id) => {
delete draft[id]
})
}),
)
setState(
"permissions",
produce((draft) => {
@ -864,8 +770,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function markSendPending(messageId: string) {
if (!messageId) return
pendingSendIds.add(messageId)
const record = state.messages[messageId]
optimisticPartIdsByMessage.set(messageId, new Set(record?.partIds ?? []))
}
function updateSendRecord(messageId: string, status: "sent" | "error") {
@ -890,39 +794,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
updateSendRecord(messageId, "sent")
}
function confirmServerMessage(messageId: string, options?: { clearOptimisticParts?: boolean }) {
const clientPartIds = optimisticPartIdsByMessage.get(messageId)
pendingSendIds.delete(messageId)
if (options?.clearOptimisticParts) optimisticPartIdsByMessage.delete(messageId)
const record = state.messages[messageId]
if (record) hooks?.onSessionChanged?.(instanceId, record.sessionId)
if (!record) return
const optimisticPartIds = options?.clearOptimisticParts && record.role === "user"
? record.partIds.filter((id) => clientPartIds?.has(id))
: []
const shouldSettleUser = record.role === "user" && (record.status === "sending" || record.status === "error")
const changed = Boolean(record.isEphemeral || optimisticPartIds.length > 0 || shouldSettleUser)
if (!changed) return
setState(
"messages",
messageId,
produce((draft) => {
draft.isEphemeral = false
if (shouldSettleUser) draft.status = "sent"
if (optimisticPartIds.length > 0) {
const optimisticIds = new Set(optimisticPartIds)
draft.partIds = draft.partIds.filter((id) => !optimisticIds.has(id))
optimisticPartIds.forEach((id) => {
delete draft.parts[id]
})
}
draft.updatedAt = Date.now()
draft.revision += 1
}),
)
bumpSessionRevision(record.sessionId)
}
// Request preparation or prompt submission failed. Keep an error bubble
// until the next authoritative snapshot rather than preserving "sending".
function failSend(messageId: string) {
@ -973,7 +844,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageInfoCache.delete(id)
forgetPendingSend(id)
clearPromptDisplayOverride(instanceId, sessionId, id)
state.pendingParts[id]?.forEach(forgetPendingPartBudgetEntry)
})
clearRecordDisplayCacheForMessages(instanceId, droppedIds)
hooks?.onMessagesRemoved?.(instanceId, sessionId, droppedIds)
@ -995,14 +865,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
}),
)
setState(
"pendingParts",
produce((draft) => {
droppedIds.forEach((id) => {
delete draft[id]
})
}),
)
setState(
"permissions",
produce((draft) => {
@ -1064,7 +926,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
function upsertMessage(input: MessageUpsertInput) {
const pendingDropRequiresReload = droppedPendingMessageIds.delete(input.id)
const normalizedParts = normalizeParts(input.id, input.parts)
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now()
@ -1105,248 +966,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id)
recomputeLastAssistantMessageId(input.sessionId)
bumpSessionRevision(input.sessionId)
if (pendingDropRequiresReload) hooks?.onSessionCleared?.(instanceId, input.sessionId)
}
function markPendingPartDropped(messageId: string, sessionId?: string): void {
if (sessionId) {
hooks?.onSessionCleared?.(instanceId, sessionId)
hooks?.onSessionChanged?.(instanceId, sessionId)
return
}
droppedPendingMessageIds.delete(messageId)
droppedPendingMessageIds.add(messageId)
while (droppedPendingMessageIds.size > DROPPED_PENDING_MESSAGE_LIMIT) {
const oldest = droppedPendingMessageIds.values().next().value
if (oldest === undefined) break
droppedPendingMessageIds.delete(oldest)
}
}
function bufferPendingPart(entry: PendingPartEntry) {
const sessionId = entry.sessionId ?? (typeof (entry.part as any).sessionID === "string" ? (entry.part as any).sessionID : undefined)
const nextEntry = { ...entry, sessionId } as SizedPendingPartEntry
const retainedBytes = estimateRetainedBytes(nextEntry, PENDING_PART_MAX_RETAINED_BYTES)
if (retainedBytes > PENDING_PART_MAX_RETAINED_BYTES) {
markPendingPartDropped(entry.messageId, sessionId)
return
}
const budgetId = ++nextPendingPartBudgetId
Object.defineProperty(nextEntry, pendingPartRetainedBytes, { value: retainedBytes })
Object.defineProperty(nextEntry, pendingPartBudgetId, { value: budgetId })
const changedSessions = new Set<string>()
const droppedMessages = new Map<string, string | undefined>()
if (sessionId) changedSessions.add(sessionId)
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
const messageEntries = [...(draft[entry.messageId] ?? []), nextEntry]
for (const dropped of messageEntries.slice(0, Math.max(0, messageEntries.length - PENDING_PARTS_PER_MESSAGE_LIMIT))) {
droppedMessages.set(entry.messageId, dropped.sessionId)
}
draft[entry.messageId] = messageEntries.slice(-PENDING_PARTS_PER_MESSAGE_LIMIT)
const pending: { messageId: string; entry: PendingPartEntry; bytes: number }[] = []
for (const messageId in draft) {
for (const value of draft[messageId] ?? []) pending.push({ messageId, entry: value, bytes: getPendingPartRetainedBytes(value) })
}
pending.sort((left, right) => left.entry.receivedAt - right.entry.receivedAt)
const sessionEntries = pending.filter((value) => value.entry.sessionId === sessionId)
const remove = new Set(pending.slice(0, Math.max(0, pending.length - PENDING_PARTS_GLOBAL_LIMIT)))
for (const value of sessionEntries.slice(0, Math.max(0, sessionEntries.length - PENDING_PARTS_PER_SESSION_LIMIT))) remove.add(value)
let sessionBytes = sessionEntries.reduce((total, value) => total + value.bytes, 0)
for (const value of sessionEntries) {
if (sessionBytes <= PENDING_PARTS_PER_SESSION_BYTE_LIMIT) break
remove.add(value)
sessionBytes -= value.bytes
}
let globalBytes = pending.reduce((total, value) => total + value.bytes, 0)
for (const value of pending) {
if (globalBytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break
remove.add(value)
globalBytes -= value.bytes
}
for (const value of remove) {
forgetPendingPartBudgetEntry(value.entry)
droppedMessages.set(value.messageId, value.entry.sessionId)
if (value.entry.sessionId) changedSessions.add(value.entry.sessionId)
const list = draft[value.messageId]
const index = list?.indexOf(value.entry) ?? -1
if (index >= 0) list.splice(index, 1)
if (list?.length === 0) delete draft[value.messageId]
}
}))
const isRetained = () => state.pendingParts[entry.messageId]?.some((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? false
if (isRetained()) {
pendingPartBudgetEntries.set(budgetId, {
instanceId,
bytes: retainedBytes,
receivedAt: entry.receivedAt,
isRetained,
remove: () => {
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
const list = draft[entry.messageId]
const index = list?.findIndex((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? -1
if (index >= 0) list.splice(index, 1)
if (list?.length === 0) delete draft[entry.messageId]
}))
markPendingPartDropped(entry.messageId, sessionId)
},
})
enforceGlobalPendingPartBudget()
}
for (const [messageId, droppedSessionId] of droppedMessages) markPendingPartDropped(messageId, droppedSessionId)
for (const changedSessionId of changedSessions) hooks?.onSessionChanged?.(instanceId, changedSessionId)
}
function clearPendingPartsForMessage(messageId: string) {
const entries = state.pendingParts[messageId]
if (!entries) return
entries.forEach(forgetPendingPartBudgetEntry)
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
delete draft[messageId]
}))
}
function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) {
if (!messageId || !partId || part.type !== "tool") {
return
}
const toolCallId =
(part as any).callID ??
(part as any).callId ??
(part as any).toolCallID ??
(part as any).toolCallId ??
(part as any).id ??
undefined
if (!toolCallId) {
return
}
setState(
"permissions",
"byMessage",
messageId,
produce((draft) => {
if (!draft) return
const existing = draft[partId]
for (const [key, entry] of Object.entries(draft)) {
if (!entry || entry.partId) continue
const permissionCallId =
(entry.permission as any).tool?.callID ??
(entry.permission as any).tool?.callId ??
(entry.permission as any).callID ??
(entry.permission as any).callId ??
(entry.permission as any).toolCallID ??
(entry.permission as any).toolCallId ??
(entry.permission as any).metadata?.callID ??
(entry.permission as any).metadata?.callId ??
undefined
if (permissionCallId !== toolCallId) continue
if (!existing || existing.permission.id === entry.permission.id) {
entry.partId = partId
draft[partId] = entry
delete draft[key]
}
break
}
}),
)
}
function applyPartUpdate(input: PartUpdateInput) {
const message = state.messages[input.messageId]
if (!message) {
bufferPendingPart({ messageId: input.messageId, sessionId: typeof (input.part as any).sessionID === "string" ? (input.part as any).sessionID : undefined, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
produce((draft: MessageRecord) => {
if (!draft.partIds.includes(partId)) {
draft.partIds = [...draft.partIds, partId]
}
const existing = draft.parts[partId]
const nextRevision = existing ? existing.revision + 1 : (cloned as any).version ?? 0
draft.parts[partId] = {
id: partId,
data: cloned,
revision: nextRevision,
}
draft.updatedAt = Date.now()
if (input.bumpRevision ?? true) {
draft.revision += 1
}
}),
)
rebindPermissionForPart(input.messageId, partId, cloned)
if (isCompletedTodoPart(cloned)) {
recordLatestTodoSnapshot(message.sessionId, {
messageId: input.messageId,
partId,
timestamp: Date.now(),
})
}
// Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes.
bumpSessionRevision(message.sessionId)
}
function applyPartDelta(input: {
messageId: string
partId: string
field: string
delta: string
bumpRevision?: boolean
bumpSessionRevision?: boolean
}) {
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
return
}
const message = state.messages[input.messageId]
if (!message) {
// Best-effort: drop deltas for unknown messages.
return
}
let applied = false
setState(
"messages",
input.messageId,
produce((draft: MessageRecord) => {
const entry = draft.parts[input.partId]
if (!entry?.data) return
const part = entry.data as any
const currentValue = part?.[input.field]
if (typeof currentValue === "string" || currentValue === undefined || currentValue === null) {
part[input.field] = `${currentValue ?? ""}${input.delta}`
applied = true
}
if (!applied) return
entry.revision += 1
draft.updatedAt = Date.now()
if (input.bumpRevision ?? true) {
draft.revision += 1
}
}),
)
if (applied && (input.bumpSessionRevision ?? true)) {
bumpSessionRevision(message.sessionId)
}
}
function removeMessage(messageId: string, fallbackSessionId?: string) {
@ -1374,7 +995,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
clearRecordDisplayCacheForMessages(instanceId, [messageId])
sessionIds.forEach((sessionId) => hooks?.onMessagesRemoved?.(instanceId, sessionId, [messageId]))
state.pendingParts[messageId]?.forEach(forgetPendingPartBudgetEntry)
batch(() => {
sessionIds.forEach((sessionId) => {
@ -1391,10 +1011,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageInfoCache.delete(messageId)
setState("pendingParts", produce((draft) => {
delete draft[messageId]
}))
setState("permissions", "byMessage", produce((draft) => {
delete draft[messageId]
}))
@ -1444,22 +1060,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
}
function flushPendingParts(messageId: string) {
const pending = state.pendingParts[messageId]
if (!pending || pending.length === 0) {
return
}
const now = Date.now()
const validEntries = pending.filter((entry) => now - entry.receivedAt <= PENDING_PART_MAX_AGE_MS)
if (validEntries.length === 0) {
clearPendingPartsForMessage(messageId)
return
}
validEntries.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
clearPendingPartsForMessage(messageId)
}
function replaceMessageId(options: ReplaceMessageIdOptions) {
if (options.oldId === options.newId) return
const existing = state.messages[options.oldId]
@ -1470,10 +1070,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
// The optimistic send is now confirmed under its real id; retire the
// pending marker (carry it to the new id only if still mid-flight).
if (pendingSendIds.has(options.oldId) && existing.isEphemeral && existing.status === "sending") {
const optimisticParts = optimisticPartIdsByMessage.get(options.oldId)
forgetPendingSend(options.oldId)
pendingSendIds.add(options.newId)
if (optimisticParts) optimisticPartIdsByMessage.set(options.newId, optimisticParts)
}
const cloned: MessageRecord = {
@ -1533,11 +1131,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
}
const pending = state.pendingParts[options.oldId]
if (pending) {
setState("pendingParts", options.newId, pending)
}
clearPendingPartsForMessage(options.oldId)
maybeUpdateLatestTodoFromRecord(cloned)
}
@ -1727,11 +1320,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
.filter((record) => record.sessionId === sessionId)
.map((record) => record.id)
const messageIdSet = new Set(messageIds)
for (const [messageId, entries] of Object.entries(state.pendingParts)) {
for (const entry of entries) {
if (messageIdSet.has(messageId) || entry.sessionId === sessionId) forgetPendingPartBudgetEntry(entry)
}
}
storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length })
clearRecordDisplayCacheForMessages(instanceId, messageIds)
@ -1751,11 +1339,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageIds.forEach((id) => messageInfoCache.delete(id))
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
for (const id of messageIds) delete draft[id]
for (const id in draft) if (draft[id]?.some((entry) => entry.sessionId === sessionId)) delete draft[id]
}))
const belongsToSession = (entry: PermissionEntry) => entry.permission.sessionID === sessionId
|| Boolean(entry.messageId && messageIdSet.has(entry.messageId))
setState("permissions", produce((draft) => {
@ -1806,14 +1389,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function clearInstance() {
for (const [id, entry] of pendingPartBudgetEntries) if (entry.instanceId === instanceId) pendingPartBudgetEntries.delete(id)
droppedPendingMessageIds.clear()
clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions))
clearRecordDisplayCacheForInstance(instanceId)
clearInstanceMessageRenderCaches(instanceId)
messageInfoCache.clear()
pendingSendIds.clear()
optimisticPartIdsByMessage.clear()
setState(reconcile(createInitialState(instanceId)))
}
@ -1832,17 +1412,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
reconcileAuthoritativeMessageIds,
markSendPending,
acceptSend,
confirmServerMessage,
failSend,
failPendingSends,
retirePendingSends,
upsertMessage,
applyPartUpdate,
applyPartDelta,
removeMessage,
removeMessagePart,
bufferPendingPart,
flushPendingParts,
replaceMessageId,
setMessageInfo,
getMessageInfo,

View file

@ -17,10 +17,6 @@ it("releases transcript payloads and owned indexes, preserving siblings and the
time: { created: 1, completed: 2 },
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
} as any)
store.bufferPendingPart({
messageId: `pending-${sessionId}`, sessionId,
part: { id: "part", type: "text", text: "pending" } as any, receivedAt: Date.now(),
})
store.setScrollSnapshot(sessionId, "message-stream", {
scrollTop: 42, atBottom: false, windowIsLatest: false, windowCursor: "older", newerCursors: [null],
})
@ -47,14 +43,12 @@ it("releases transcript payloads and owned indexes, preserving siblings and the
assert.equal(store.state.usage.target, undefined)
assert.equal(store.state.sessionRevisions.target, undefined)
assert.equal(store.state.lastAssistantMessageIds.target, undefined)
assert.equal(store.state.pendingParts["pending-target"], undefined)
assert.equal(store.state.permissions.byMessage.target, undefined)
assert.equal(store.state.permissions.byMessage.__global__, undefined)
assert.deepEqual(store.state.permissions.queue.map(entry => entry.permission.id), ["permission-sibling"])
assert.equal(store.state.permissions.active?.permission.id, "permission-sibling")
assert.equal(store.getScrollSnapshot("target", "message-stream"), snapshot)
assert.equal(await store.estimateSessionRetainedBytes("target"), 0)
assert.ok(store.state.pendingParts["pending-sibling"])
assert.ok(await store.estimateSessionRetainedBytes("sibling") > 1024 * 1024)
assert.deepEqual(cleared, ["target"])

View file

@ -44,13 +44,6 @@ export interface SessionRecord {
messageWindow?: MessageWindowState
}
export interface PendingPartEntry {
messageId: string
sessionId?: string
part: ClientPart
receivedAt: number
}
export interface PermissionEntry {
permission: PermissionRequest
messageId?: string
@ -114,7 +107,6 @@ export interface InstanceMessageState {
messages: Record<string, MessageRecord>
lastAssistantMessageIds: Record<string, string | undefined>
messageInfoVersion: Record<string, number>
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>
permissions: InstancePermissionState
usage: Record<string, SessionUsageState>
@ -143,12 +135,6 @@ export interface MessageUpsertInput {
bumpRevision?: boolean
}
export interface PartUpdateInput {
messageId: string
part: ClientPart
bumpRevision?: boolean
}
export interface ReplaceMessageIdOptions {
oldId: string
newId: string

View file

@ -1,24 +0,0 @@
import { createSignal } from "solid-js"
function makeKey(instanceId: string, sessionId: string): string {
return `${instanceId}:${sessionId}`
}
const [compactingSessions, setCompactingSessions] = createSignal<Map<string, boolean>>(new Map())
export function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
setCompactingSessions((prev) => {
const next = new Map(prev)
const key = makeKey(instanceId, sessionId)
if (isCompacting) {
next.set(key, true)
} else {
next.delete(key)
}
return next
})
}
export function isSessionCompactionActive(instanceId: string, sessionId: string): boolean {
return compactingSessions().get(makeKey(instanceId, sessionId)) ?? false
}

View file

@ -133,7 +133,7 @@ function Prototype() {
rightPanelTab: () => "files", expandedItems: () => [], onExpandedItemsChange() {},
customization: () => parseRightPanelCustomization(null), onCustomizationChange() {}, extraStatusSections: () => [],
})
const tab = runtime.create({ instanceId: id, t, activeSessionId: () => sessionId, isTabActive: () => true, openTab() {} }).tabs!.find(tab => tab.id === "files")!
const tab = runtime.tabs!.find(tab => tab.id === "files")!
;(window as any).fixture = { calls, setActive, close: () => closeFilePreview(id), target: () => getFilePreview(id),
invalidate: () => invalidateFilesystemCaches(id),
switchSession: (value: string) => { setSelectedSession(value); if (SessionView) setActiveSession(id, value) },