mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 22:02:03 +00:00
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:
parent
e874cafad2
commit
027f50947c
40 changed files with 398 additions and 2564 deletions
|
|
@ -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
38
dev-docs/INDEX.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
172
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import type { RightPanelManifest } from "./plugin-manifest"
|
||||
|
||||
export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelManifest[] = []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
101
packages/ui/src/lib/audio-utils.test.ts
Normal file
101
packages/ui/src/lib/audio-utils.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("+")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue