diff --git a/.agents/skills/performance-engineering/SKILL.md b/.agents/skills/performance-engineering/SKILL.md index 6bcd7123..1b1eb6f7 100644 --- a/.agents/skills/performance-engineering/SKILL.md +++ b/.agents/skills/performance-engineering/SKILL.md @@ -27,6 +27,34 @@ Do not optimize against a toy fixture when the report provides production scale. ## Workflow +### 0. Trust The Measurement Before Trusting The Number + +A measurement setup that is wrong produces clean, confident, wrong numbers, and +a clean number ends an investigation. Establish validity first. + +**Prove the environment is not throttled.** Chrome stops producing frames and +throttles timers for windows it considers backgrounded or occluded, headless or +not. A capture taken that way reports near-zero rendering work no matter what +the page does. Disable background/occlusion throttling at launch and measure +frame liveness inside the capture. The same applies to any environment that +idles when unobserved. + +**Prove zero is a measurement.** A metric reading zero, absent, or perfectly +quiet is a claim that requires evidence, because a disabled instrument reports +exactly the same thing. `RunTask` only appears under the disabled-by-default +timeline category; a scenario opened for the wrong directory renders nothing at +all. Before believing a quiet result, confirm the instrument fired and the +workload actually ran: assert on an independent signal, such as DOM growth +alongside the application's own render counters. + +**Prove the workload is comparable.** When the stimulus varies in size between +runs, per-second and total figures are not comparable. Normalise by units of +work delivered, and check run-to-run spread on an unchanged build before +attributing any difference to a change. + +Do not report a number whose validity you have not established. State which +validity checks ran. + ### 1. Reproduce And Measure - Reproduce the exact interaction, not a nearby helper in isolation. @@ -37,6 +65,24 @@ Do not optimize against a toy fixture when the report provides production scale. Do not infer a bottleneck from code appearance when a trace or counter can identify it. +**Never accept an "after" without a "before" on the identical scenario and +build.** Measuring a fixed build against a remembered number, a different +scenario, or a nearby baseline proves nothing: the mechanism you changed may +not even execute in the path you measured. Re-run the unchanged build through +the same scenario, however inconvenient the rebuild. Expect to discover that a +plausible fix changes nothing. + +**A sampling profiler cannot explain native work.** Self time attributed to +`(program)` says only that the time was not in interpreted JavaScript. Use the +timeline trace, which names parsing, style recalculation, layout, layerization, +paint, and raster, and reserve the sampler for attributing application code. + +**Reproduction may require production scale you do not have.** A threshold +effect is invisible below its threshold, and a development workspace is usually +below it. When a report will not reproduce, compare the reporter's scale +against yours on the specific dimension the code keys on before concluding the +bug is absent. + Profiling identifies where time is spent; it does not prove behavioral equivalence. Separately verify the applicable state, identity, layout, and lifecycle transitions for every structural optimization. ### 2. Write The Cost Equation @@ -149,6 +195,30 @@ Add a cache only when all are explicit: A cache inside an `O(consumers × entities × candidates)` loop is a mitigation, not automatically a complete fix. +## Repository Tooling + +`scripts/perf/DOCUMENTATION.md` is the entry point: it covers every capture +command, how to stand up a production build to measure against, how to read the +artifacts, and the validity guarantees these scripts enforce. Read it before +measuring. + +Four unattended capture commands exist; prefer them over ad-hoc timing code, +and extend them when a scenario is missing rather than measuring by hand. + +| Command | Answers | +|---|---| +| `bun run profile:idle` | What the app does while nobody interacts with it. Supports `--session`, `--tab`, `--then-tab`, `--panel`, `--expand-projects` to reach a specific mounted state, plus `--baseline` and `--budget-*` for regression gating. | +| `bun run profile:session` | What a streaming assistant response costs. Creates a session, dispatches a prompt through the `openchamber session` CLI, and records until the session reports idle. Reports the long-task distribution, a timeline-trace breakdown, running animations, and output-normalised metrics. | +| `bun run profile:animation` | What a CSS animation costs, isolated from the app. Animate only `transform` and `opacity`; everything else recalculates style every frame. | +| `bun run profile:browser` | A manually driven capture when the interaction cannot be scripted. | + +Both automated commands fail loudly rather than reporting a clean result when +the renderer was throttled, the trace collected no tasks, or the scenario never +rendered. Keep that property when extending them. + +Measure a production build. A development build's render and bundle behaviour +does not represent what users run. + ## Verification Require both correctness and performance guards: @@ -169,6 +239,32 @@ Require both correctness and performance guards: State what was not measured. Never claim a freeze is fixed from type-check and unit tests alone. +## Revert What You Cannot Measure + +A change that does not move its target metric is not a small win, a safety +improvement, or a cleanup. It is unvalidated complexity, and shipping it under +a performance rationale makes the next investigation harder by implying the +path was already optimised. Revert it and record the hypothesis as rejected. + +This applies to a change whose benefit appears only in reasoning, one measured +against the wrong baseline, and one whose measured scenario turns out to behave +identically without it. + +Report negative results explicitly. "Disabling this removed 40% of the +layerization, and the fix that preserved the visuals did not" is a finding, and +the next person needs it. + +## Know When To Stop + +Compare the remaining cost against the user-facing budget, not against zero. +When the interaction already sits far inside budget, further optimisation of +that path trades real regression risk for an invisible gain, and it displaces +work on the path the user actually reported. Say so and move on. + +Cost that comes from intentional, user-visible behaviour is not waste. Removing +it is a product decision, not a performance fix, and it needs the owner's +agreement rather than a quiet commit. + ## Hotfix Policy Ship a bounded cache-only or local mitigation under deadline pressure only when: @@ -192,9 +288,16 @@ If the interaction remains above budget, do not call the mitigation the complete | "Move it to a worker" | Moving waste changes responsiveness, not total cost or data correctness. | | "Empty means nothing exists" | Empty after failure or partial loading is not authoritative absence. | | "We can optimize later" | Add a scale regression now or the multiplier will return. | +| "The profile is clean" | Prove the instrument fired and the renderer was not throttled. A disabled instrument looks identical to a fast app. | +| "It is much faster now" | Against which baseline, on which build, in which scenario? Re-run the unchanged build. | +| "Most of the time is `(program)`" | The sampler cannot see native work. Read the timeline trace. | +| "It does not reproduce here" | Compare your scale to the reporter's on the dimension the code keys on. | +| "It cannot hurt to keep the change" | An unmeasured change is unvalidated complexity that hides the path from the next investigation. | ## Exit Checklist +- [ ] Measurement validity established: no throttling, instruments confirmed firing, workload comparable. +- [ ] Baseline captured from the unchanged build through the identical scenario. - [ ] Exact interaction and production scale reproduced. - [ ] Cost equation written and dominant multipliers removed. - [ ] Sources of truth, completeness, and invalidation explicit. @@ -205,4 +308,6 @@ If the interaction remains above budget, do not call the mitigation the complete - [ ] Operation-count or repeated-event regression test prevents recurrence. - [ ] Structural optimizations have transition-focused correctness coverage independent of performance measurements. - [ ] When mount topology or activation boundaries change, instrumentation distinguishes those transitions from steady state. +- [ ] Every change retained is justified by a measured difference; unvalidated ones reverted and recorded as rejected. +- [ ] Remaining cost compared against the budget, and stopping justified when inside it. - [ ] Correctness, type, lint, and relevant runtime validations pass. diff --git a/.agents/skills/sync-state-invariants/SKILL.md b/.agents/skills/sync-state-invariants/SKILL.md index 9aa3fec0..c6cd720a 100644 --- a/.agents/skills/sync-state-invariants/SKILL.md +++ b/.agents/skills/sync-state-invariants/SKILL.md @@ -97,6 +97,28 @@ For streaming-frequency work, also load `performance-engineering`. - Key runtime-scoped caches by runtime identity when IDs or paths can collide. - Clean optimistic and local cache state after partial failures. +### Never Evict What Is In Use + +An entry acquired during render but protected only after commit is unprotected +for the whole render pass. Eviction that runs on acquisition therefore disposes +entries that are actively mounting; the next render recreates them in a loading +state, which issues another fetch, which repeats forever. The symptom is an +endless request loop and sawtoothing listeners, heap, and CPU, and it appears +only once live entries outnumber the limit, so it never reproduces on a small +workspace. + +- Define what protects an entry from eviction, and prove that protection is in + place before eviction can observe the entry, not one commit later. +- Treat capacity as a soft target. Overflowing briefly is always cheaper than + evict/recreate cycles; bound the cache with idle-time eviction instead. +- Never run an eviction scan on the acquisition path. Coalesce it into one + deferred pass so a render mounting many entries scans once, not once per + entry. +- Keep explicit lifecycle edges, such as the last consumer releasing an entry, + synchronous. Deferring those changes an observable contract. +- Raising a limit is a workaround, not a fix. It relocates the cliff and hides + the loop from everyone whose workload is smaller than the new number. + ## Persisted Snapshot Ordering When state exists in memory and one or more persistent stores, define an explicit authority and ordering protocol: @@ -136,5 +158,6 @@ Cover the relevant lifecycle, not only static state: - New session lookup assumes SSE already indexed it. - Optimistic data has no shadow entry or rollback. - Snapshot-difference cleanup treats its first startup snapshot as a disappearance event. +- Eviction runs on the acquisition path, or a cache limit is raised in response to a request loop. - Missing or malformed persistence becomes authoritative empty state. - Debounced writes are canceled on owner/lifecycle change without completing against the captured owner or an explicit durability/data-loss contract. diff --git a/.agents/skills/theme-system/SKILL.md b/.agents/skills/theme-system/SKILL.md index 028d3a92..0430e1d3 100644 --- a/.agents/skills/theme-system/SKILL.md +++ b/.agents/skills/theme-system/SKILL.md @@ -71,8 +71,34 @@ import { Icon } from '@/components/icon/Icon'; Use `IconName` for icon values stored in arrays, objects, state, or config. `Icon` has no `size` prop. Run `bun run icons:generate` when introducing a sprite name, and never edit `sprite.ts` manually. Load `references/icons.md` for the complete workflow. +## Animation Contract + +Animate only `transform` and `opacity`. The compositor drives those; every other +property recalculates style on each frame for as long as the animation runs, and +geometry properties add layout on top. Measured on this repository's fixture, +identical at any element count from 1 to 32: + +| Animated property | Style recalculations/sec | Layouts/sec | +|---|---|---| +| `transform`, `opacity`, `filter` | 0 | 0 | +| `rotate` (the individual property) | 60 | 0 | +| `background-position`, `border-color`, `box-shadow` | 60 | 0 | +| `width` and other geometry | 60 | 60 | + +- `rotate: 360deg` is not a cheap synonym for `transform: rotate(360deg)`. + Prefer the `transform` form. +- Cost applies for the entire time an animation runs, so an indicator tied to a + long-running operation pays it continuously. An indicator that is not + conveying anything should not be animating. +- `will-change`, wrapper elements, `contain`, and `steps()` timing do not make a + non-composited property cheap. Only changing the property does. +- Verify with `bun run profile:animation` rather than reasoning about it; add a + variant to `scripts/perf/animation-fixture.html` for a technique not covered. + See `scripts/perf/DOCUMENTATION.md`. + ## Verification +- Animations are limited to `transform` and `opacity`, or their cost was measured and accepted. - No hardcoded/palette colors were introduced. - Buttons use shared variants and sizes. - Icons use `Icon`/`IconName`, and generated sprite changes are intentional. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47dbb0c5..28a3afc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -204,8 +204,8 @@ jobs: bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own — we must rebuild against the - # target Electron ABI before packaging, otherwise better-sqlite3/ - # node-pty/bun-pty crash on require inside the packaged app. + # target Electron ABI before packaging, otherwise node-pty/bun-pty + # crash on require inside the packaged app. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never bun run verify:opencode-cli:packaged diff --git a/.gitignore b/.gitignore index 525d0eff..09edef64 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,5 @@ workspaces/ *.pid .worktrees/ test-results/ -artifacts/browser-profile-*/ +artifacts/ + diff --git a/.opencode/agent/simplifier.md b/.opencode/agent/simplifier.md index 08506418..d88cdb0e 100644 --- a/.opencode/agent/simplifier.md +++ b/.opencode/agent/simplifier.md @@ -1,5 +1,5 @@ --- -mode: subagent +mode: all description: Simplifies recently modified OpenChamber code for clarity and maintainability while preserving exact behavior. Use after implementation with a concrete scope or a request to simplify current worktree changes. permission: edit: allow @@ -16,13 +16,13 @@ permission: "*.env.example": allow bash: "*": ask - "bun test*": allow - "bun run type-check*": allow - "bun run lint*": allow - "bun run build*": allow - "bun run docs:validate": allow - "bun run dead-code": allow - "git *": allow + bun test*: allow + bun run type-check*: allow + bun run lint*: allow + bun run build*: allow + bun run docs:validate: allow + bun run dead-code: allow + git *: allow --- You are an expert code simplification specialist for OpenChamber. Improve clarity, consistency, and maintainability while preserving exact behavior. Prefer readable, explicit code over compact or clever code. @@ -77,4 +77,4 @@ Do not edit until the required project guidance and local context have been read 3. Re-read the edited code and verify that the observable contract is unchanged. 4. Run the narrowest validation required by the repository guidance and actual risk. Use package-scoped checks for local executable changes and broader checks only for genuinely shared contracts. 5. Run `bun run dead-code` only when files, exports, types, entrypoints, or import shapes changed, and inspect its non-blocking report. -6. Summarize meaningful clarity improvements and report exactly what was and was not validated. +6. Summarize meaningful clarity improvements and report exactly what was and was not validated. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 270e0a00..968cccc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ High-value anchors: - Sync: `packages/ui/src/sync/DOCUMENTATION.md` - Stores: `packages/ui/src/stores/DOCUMENTATION.md` - CLI: `packages/web/bin/lib/DOCUMENTATION.md` +- Performance measurement tooling: `scripts/perf/DOCUMENTATION.md` - VS Code runtime: `packages/vscode/src/DOCUMENTATION.md` - Electron: `packages/electron/README.md` - Mobile: `packages/mobile/README.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index 281df160..22de68b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.18.0] - 2026-08-04 + - **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. - **Mobile/Tablet:** reworked the tablet and foldable layout around the phone's navigation — a persistent resizable sessions sidebar on the left, the workspace (Changes, Files, Terminal, Notes, MCP) as a resizable right sidebar, and app pages like settings and instances shown as centered dialogs. An open diff, edited file, or attached terminal now survives rotation. - **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech). - Performance: fixed Bun dependency chunking so the web app no longer downloads a single 18.5 MB vendor bundle at startup; heavy syntax highlighting, screenshot, diagram, editor, and image-conversion libraries now load only when needed (thanks to @makeittech). +- Performance: expanding projects with many worktrees no longer repeatedly reloads their session data. - UI/Localization: added German interface translations and German documentation (thanks to @SGD-DEV). - Mobile/Android: pairing QR codes can now be scanned on devices without Google Play Services; the camera closes as soon as a code is recognized, followed by a connection-in-progress screen. - Mobile/Android: left and right drawer swipes can now start farther from the screen edge, outside Android's system Back gesture area. @@ -18,8 +21,16 @@ All notable changes to this project will be documented in this file. - Terminal: opening a terminal no longer waits for the terminal view to finish loading, and startup output is retained if it arrives before the view appears (thanks to @makeittech). - Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271). - Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update. +- Chat: prompts sent through the private relay no longer produce duplicate replies when the connection drops after OpenCode accepted the message, and a queued message already being sent is no longer included in another send. - Settings/Skills: repository-local `.agents/skills` now appear for the active project (thanks to @makeittech). +- Settings/Skills: renaming a skill now preserves its instructions and supporting files; only skills in locations OpenChamber can safely rename show the action (thanks to @makeittech). +- Sessions: sessions in a newly created worktree now appear without restarting or refreshing the app. +- Agents/CLI: creating a session in a new worktree no longer reports a timeout while the worktree continues to be created in the background. - Sessions: archiving and unarchiving now stays scoped to the current instance and workspace (thanks to @alexandrereyes). +- Usage: added DeepSeek quota tracking (thanks to @airtaxi). +- Usage: Kimi for Coding now calculates usage correctly when the provider reports either used or remaining quota (thanks to @makeittech). +- Desktop/Linux: terminals and OpenCode now start with the correct shell arguments in AppImage installs, fixing broken zsh startup (thanks to @makeittech). +- Files: browser clients now label file exports as downloads and no longer show the desktop-only reveal action (thanks to @makeittech). - Chat: assistant messages no longer render active HTML. - VSCode: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). diff --git a/bun.lock b/bun.lock index 2209fc6d..370b1124 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,6 @@ "version": "1.17.2", "dependencies": { "@openchamber/web": "workspace:*", - "better-sqlite3": "^12.10.0", "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", @@ -270,7 +269,6 @@ "@opencode-ai/sdk": "1.18.11", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", - "better-sqlite3": "^12.10.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", @@ -1583,16 +1581,12 @@ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], - "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], @@ -2005,8 +1999,6 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], diff --git a/docs/references/chat_example.png b/docs/references/chat_example.png index 6b2b3f40..a98c219c 100644 Binary files a/docs/references/chat_example.png and b/docs/references/chat_example.png differ diff --git a/package.json b/package.json index 030fc1d0..ac088390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.17.2", + "version": "1.18.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -40,7 +40,7 @@ "lint:mobile": "bun run --cwd packages/mobile lint", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", - "postinstall": "node ./fix-deprecation.js && patch-package", + "postinstall": "node ./fix-deprecation.js && patch-package && node ./packages/electron/scripts/ensure-electron.mjs --best-effort", "dev:web": "bun run --cwd packages/web build:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch", "dev:web:full": "node ./scripts/dev-web-full.mjs", @@ -81,7 +81,10 @@ "release:prepare": "bun run build && bun run type-check && bun run lint", "release:test": "./scripts/test-release-build.sh", "release:test:intel": "./scripts/test-release-build.sh x86_64", - "release:test:arm": "./scripts/test-release-build.sh aarch64" + "release:test:arm": "./scripts/test-release-build.sh aarch64", + "profile:idle": "node scripts/profile-idle.mjs", + "profile:session": "node scripts/profile-session.mjs", + "profile:animation": "node scripts/profile-animation.mjs" }, "dependencies": { "@base-ui/react": "^1.4.0", diff --git a/packages/electron/README.md b/packages/electron/README.md index 45722457..9b971a2b 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -23,6 +23,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | +| `scripts/ensure-electron.mjs` | Verifies the installed Electron binary is complete and repairs it via the postinstall under Bun | | `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` | | `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` | | `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging | @@ -43,10 +44,18 @@ bun run electron:dev The Electron workspace package trusts Electron's install script so `bun install` downloads the platform runtime in fresh checkouts and worktrees. +Electron's postinstall (`node install.js`) is run by `bun install` with the system Node. Under Node 24, the bundled `extract-zip@2.0.1` silently unpacks only the first entry of the Electron zip, leaving `dist/` without the binary and `path.txt` missing. To keep this from blocking desktop work: + +- The root `postinstall` runs `ensure-electron.mjs --best-effort`, which detects an incomplete Electron install (missing binary, stale `dist/version`/`path.txt`, or a binary of the wrong architecture) and repairs it by re-running the postinstall under Bun (which extracts correctly), falling back to Node. +- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted. +- The check can be run on demand with `bun run --cwd packages/electron ensure:electron`; set `ELECTRON_SKIP_BINARY_DOWNLOAD=1` to skip repair (e.g. CI without a network). +- Unit tests in `scripts/ensure-electron.test.mjs` (run via `bun run --cwd packages/electron test:architecture`) cover healthy/missing/stale installs, wrong-architecture binaries, repair fallback, and `--best-effort`. + Useful variants: ```bash bun run electron:dev:bundled +bun run --cwd packages/electron ensure:electron bun run type-check:electron bun run lint:electron ``` @@ -67,7 +76,7 @@ That runs, in order: 2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`. 3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. 4. `rebuild:native` to rebuild native modules for Electron. -5. `package.mjs` to run `electron-builder`; its `afterPack` hook stages the rebuilt `better-sqlite3` binary that Electron Builder's Bun dependency collector otherwise omits. +5. `package.mjs` to run `electron-builder`; its `afterPack` hook stages the compiled macOS icon asset catalog. Build output goes to `packages/electron/dist`. diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs index 397aac76..28e11900 100644 --- a/packages/electron/linux-app-discovery.mjs +++ b/packages/electron/linux-app-discovery.mjs @@ -234,6 +234,11 @@ const commandExists = (program, env = process.env) => { const findEntry = (entries, appId, appName) => entries.find((entry) => desktopEntryMatchesApp(entry, appName, appId)) || null; +const isTerminalEmulatorEntry = (entry) => { + const categories = Array.isArray(entry?.categories) ? entry.categories : []; + return categories.some((category) => normalizeComparable(category) === 'terminalemulator'); +}; + export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = 'path', entries = [], env = process.env }) => { if (appId === 'finder') { return [{ kind: 'default', targetKind, targetPath }]; @@ -241,7 +246,9 @@ export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = ' const specs = []; if (TERMINAL_APP_IDS.has(appId)) { const directory = targetKind === 'file' ? path.dirname(targetPath) : targetPath; - const terminalEntry = findEntry(entries, appId, appName); + const terminalEntry = appId === 'terminal' + ? entries.find(isTerminalEmulatorEntry) || null + : findEntry(entries, appId, appName); if (terminalEntry) { const spec = buildCommandFromDesktopExec(terminalEntry, directory); if (spec) specs.push(spec); @@ -515,7 +522,7 @@ export const buildLinuxInstalledApps = async (apps, options = {}) => { ...FILE_MANAGER_ICON_FALLBACKS, ], { ...options, env }); } else if (normalizeComparable(name) === 'terminal') { - const terminalEntry = findEntry(entries, 'terminal', name) + const terminalEntry = entries.find(isTerminalEmulatorEntry) || findEntry(entries, 'ghostty', 'Ghostty'); iconDataUrl = resolveIconDataUrlForName([ terminalEntry?.icon, diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index ca7a493e..e9bca981 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2255,6 +2255,13 @@ const dispatchMenuAction = (action) => { dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; +// Append-style menu actions must reach the renderer exactly once. Dual IPC+DOM +// delivery (dispatchMenuAction) would insert the selection twice. +const dispatchAddSelectionToChat = () => { + const target = getMenuTargetWindow(); + if (target) emitToWindow(target, 'openchamber:menu-action', 'add-selection-to-chat'); +}; + // Mini-chat draft windows are not deduplicated, so this must reach the renderer // exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer // resolves the active directory/project and opens the window. @@ -4564,6 +4571,7 @@ const buildMacMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() }, + { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4582,7 +4590,7 @@ const buildMacMenu = () => { { label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'System Theme', click: () => dispatchAction('theme-system') }, { type: 'separator' }, - { label: 'Toggle Session Sidebar', accelerator: 'Cmd+L', click: () => dispatchAction('toggle-sidebar') }, + { label: 'Toggle Session Sidebar', accelerator: 'Cmd+Alt+L', click: () => dispatchAction('toggle-sidebar') }, { label: 'Toggle Memory Debug', accelerator: 'Cmd+Shift+D', click: () => dispatchAction('toggle-memory-debug') }, { type: 'separator' }, { role: 'togglefullscreen' }, @@ -4661,6 +4669,7 @@ const buildAutoHiddenMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, + { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4683,7 +4692,7 @@ const buildAutoHiddenMenu = () => { { label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'System Theme', click: () => dispatchAction('theme-system') }, { type: 'separator' }, - { label: 'Toggle Session Sidebar', accelerator: 'Ctrl+L', click: () => dispatchAction('toggle-sidebar') }, + { label: 'Toggle Session Sidebar', accelerator: 'Ctrl+Alt+L', click: () => dispatchAction('toggle-sidebar') }, { label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') }, { type: 'separator' }, { role: 'togglefullscreen' }, diff --git a/packages/electron/package.json b/packages/electron/package.json index 1ba7d13b..67800186 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.17.2", + "version": "1.18.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", @@ -8,7 +8,6 @@ "main": "./dist-bundle/main.mjs", "dependencies": { "@openchamber/web": "workspace:*", - "better-sqlite3": "^12.10.0", "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", "electron-updater": "^6.8.3" @@ -32,6 +31,7 @@ "dev": "node ./scripts/electron-dev.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs", "build": "bun -e \"process.exit(0)\"", + "ensure:electron": "node ./scripts/ensure-electron.mjs", "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", @@ -39,7 +39,7 @@ "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs", + "test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs ./scripts/ensure-electron.test.mjs", "test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs", "test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs", "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs", diff --git a/packages/electron/scripts/after-pack.cjs b/packages/electron/scripts/after-pack.cjs index 542ea614..22b43ba3 100644 --- a/packages/electron/scripts/after-pack.cjs +++ b/packages/electron/scripts/after-pack.cjs @@ -5,23 +5,6 @@ module.exports = (context) => { const resourcesPath = context.electronPlatformName === 'darwin' ? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') : path.join(context.appOutDir, 'resources'); - const betterSqliteDir = path.dirname(require.resolve('better-sqlite3/package.json')); - const betterSqliteBinary = path.join(betterSqliteDir, 'build', 'Release', 'better_sqlite3.node'); - if (!fs.existsSync(betterSqliteBinary)) { - throw new Error(`Missing rebuilt better-sqlite3 binary at ${betterSqliteBinary}`); - } - const packagedBetterSqliteBinary = path.join( - resourcesPath, - 'app.asar.unpacked', - 'node_modules', - 'better-sqlite3', - 'build', - 'Release', - 'better_sqlite3.node', - ); - fs.mkdirSync(path.dirname(packagedBetterSqliteBinary), { recursive: true }); - fs.copyFileSync(betterSqliteBinary, packagedBetterSqliteBinary); - if (context.electronPlatformName !== 'darwin') return; const sourceAssetsPath = path.join(__dirname, '..', 'resources', 'icons', 'Assets.car'); diff --git a/packages/electron/scripts/bundle-main.mjs b/packages/electron/scripts/bundle-main.mjs index 1c867134..5d925d9c 100644 --- a/packages/electron/scripts/bundle-main.mjs +++ b/packages/electron/scripts/bundle-main.mjs @@ -30,7 +30,6 @@ const result = await Bun.build({ '@openchamber/web/*', 'bun-pty', 'node-pty', - 'better-sqlite3', ], minify: false, sourcemap: 'none', diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index 8269c31d..a4ee60e4 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.mjs @@ -45,6 +45,25 @@ function spawnProcess(command, args, options = {}) { }); } +function ensureElectronInstalled() { + // Electron's postinstall can silently fail to extract the binary under + // Node 24 (see ensure-electron.mjs). Fail fast with a repair attempt + // before wiring up the dev servers so the error is actionable. + const result = spawnSync('node', [path.join(__dirname, 'ensure-electron.mjs')], { + cwd: repoRoot, + stdio: 'inherit', + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + '[electron:dev] electron binary is missing or incomplete and could not be repaired. ' + + 'Run `bun run --cwd packages/electron ensure:electron` (or `bun install`) with a network connection.', + ); + } +} + function runProcess(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -183,6 +202,8 @@ async function main() { let hmrApiPort = ''; let hmrUiPort = ''; + ensureElectronInstalled(); + if (useBundledUi) { await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']); } else { diff --git a/packages/electron/scripts/ensure-electron.mjs b/packages/electron/scripts/ensure-electron.mjs new file mode 100644 index 00000000..57a12cda --- /dev/null +++ b/packages/electron/scripts/ensure-electron.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node +/** + * Ensure the installed `electron` package has its binary fully installed and + * matches the host architecture. + * + * Why this exists: `bun install` runs the electron package's postinstall + * (`node install.js`) with the system Node. Under Node 24, + * `extract-zip@2.0.1` silently unpacks only the first entry of the electron + * zip and then resolves without error, leaving `dist/` without the binary + * and `path.txt` missing. Running the same postinstall with Bun extracts + * correctly. This script detects an incomplete (or wrong-architecture) + * install and repairs it by re-running the postinstall under Bun (falling + * back to Node). + * + * Test hooks (env, never used in normal operation): + * OPENCHAMBER_ELECTRON_PKG_DIR - resolve the electron package here. + * OPENCHAMBER_ELECTRON_INSTALL_COMMANDS - JSON array of [bin, args] repair + * commands, e.g. + * `[["bun",["install.js"]],["node",["install.js"]]]`. + * + * Exit codes: + * 0 - electron is complete (or was repaired; or `--best-effort` and repair + * was not possible but should not block the caller). + * 1 - electron is incomplete and could not be repaired. + */ +import { spawnSync, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoElectronDir = path.resolve(__dirname, '..'); +const require = createRequire(import.meta.url); + +// cputype / e_machine / PE machine values -> Node-style architecture. +const MACHO_CPU_TO_ARCH = { + 0x00000007: 'ia32', + 0x01000007: 'x64', + 0x0000000c: 'arm', + 0x0100000c: 'arm64', +}; +const ELF_MACHINE_TO_ARCH = { + 3: 'ia32', + 40: 'arm', + 62: 'x64', + 183: 'arm64', +}; +const PE_MACHINE_TO_ARCH = { + 0x014c: 'ia32', + 0x8664: 'x64', + 0xaa64: 'arm64', +}; + +export function platformPath() { + const platform = process.env.npm_config_platform || process.platform; + switch (platform) { + case 'mas': + case 'darwin': + return 'Electron.app/Contents/MacOS/Electron'; + case 'freebsd': + case 'openbsd': + case 'linux': + return 'electron'; + case 'win32': + return 'electron.exe'; + default: + throw new Error(`Electron builds are not available on platform: ${platform}`); + } +} + +/** + * Architecture that the installed Electron binary should match, mirroring the + * logic in electron's own install.js (including the macOS Rosetta fallback). + */ +export function expectedArch() { + const platform = process.env.npm_config_platform || process.platform; + let arch = process.env.npm_config_arch || process.arch; + if ( + platform === 'darwin' && + process.platform === 'darwin' && + arch === 'x64' && + process.env.npm_config_arch === undefined + ) { + try { + const out = execSync('sysctl -in sysctl.proc_translated', { + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (String(out).trim() === '1') { + arch = 'arm64'; + } + } catch { + // Ignore failure: treat as a native x64 host. + } + } + return arch; +} + +/** + * Read the executable header of a Mach-O (macOS), ELF (Linux), or PE + * (Windows) binary and return its architecture as a Node-style string + * ('x64', 'arm64', 'ia32', 'arm') or null when it cannot be determined. + */ +export function detectExecutableArch(executablePath) { + let fd; + try { + fd = fs.openSync(executablePath, 'r'); + } catch { + return null; + } + try { + const header = Buffer.alloc(512); + const bytesRead = fs.readSync(fd, header, 0, header.length, 0); + return archFromHeader(header.subarray(0, bytesRead)); + } catch { + return null; + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + +function archFromHeader(buf) { + if (buf.length < 4) return null; + + // ELF: e_machine at offset 18. + if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) { + if (buf.length < 20) return null; + return ELF_MACHINE_TO_ARCH[buf.readUInt16LE(18)] ?? null; + } + + // Thin Mach-O: magic (LE) then cputype at offset 4. + const magicLE = buf.readUInt32LE(0); + if (magicLE === 0xfeedface || magicLE === 0xfeedfacf) { + if (buf.length < 8) return null; + return MACHO_CPU_TO_ARCH[buf.readUInt32LE(4)] ?? null; + } + + // Fat/universal Mach-O: magic (BE) then a list of fat_arch entries. + const magicBE = buf.readUInt32BE(0); + if (magicBE === 0xcafebabe || magicBE === 0xbebafeca) { + if (buf.length < 8) return null; + const count = buf.readUInt32BE(4); + for (let i = 0; i < count; i += 1) { + const offset = 8 + i * 20; + if (buf.length < offset + 4) break; + const arch = MACHO_CPU_TO_ARCH[buf.readUInt32BE(offset)]; + if (arch) return arch; + } + return null; + } + + // PE: e_lfanew at offset 0x3c, machine at PE header + 4. + if (buf[0] === 0x4d && buf[1] === 0x5a) { + if (buf.length < 0x40) return null; + const peOffset = buf.readUInt32LE(0x3c); + if (buf.length < peOffset + 6) return null; + if (buf.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') return null; + return PE_MACHINE_TO_ARCH[buf.readUInt16LE(peOffset + 4)] ?? null; + } + + return null; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +export function resolveElectronPackageDir(baseDir = repoElectronDir) { + try { + const pkgJson = require.resolve('electron/package.json', { paths: [baseDir] }); + return path.dirname(pkgJson); + } catch { + // Fall back to the standard monorepo layout (Bun and npm hoist electron + // somewhere under /node_modules). + const candidates = [ + path.resolve(baseDir, 'node_modules/electron'), + path.resolve(baseDir, '../../node_modules/electron'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + return null; + } +} + +export function isComplete(electronDir, expected = expectedArch()) { + const pkg = readJson(path.join(electronDir, 'package.json')); + if (!pkg || !pkg.version) return false; + + try { + const distVersion = fs + .readFileSync(path.join(electronDir, 'dist', 'version'), 'utf8') + .trim() + .replace(/^v/, ''); + if (distVersion !== pkg.version) return false; + } catch { + return false; + } + + let executablePath; + try { + executablePath = fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf8').trim(); + } catch { + return false; + } + if (executablePath !== platformPath()) return false; + + const executable = path.join(electronDir, 'dist', executablePath); + if (!fs.existsSync(executable)) return false; + + // A same-version binary built for another architecture satisfies all of the + // checks above but still fails at launch, so verify the real header. + const detected = detectExecutableArch(executable); + if (detected === null || detected !== expected) return false; + + return true; +} + +function resolveInstallCommands(env) { + if (env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS) { + try { + const parsed = JSON.parse(env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS); + if ( + Array.isArray(parsed) && + parsed.every((command) => Array.isArray(command) && typeof command[0] === 'string') + ) { + return parsed; + } + } catch { + // Fall through to the default commands. + } + } + return [ + ['bun', ['install.js']], + ['node', ['install.js']], + ]; +} + +export function repair(electronDir, options = {}) { + const env = options.env ?? process.env; + const runner = options.runner ?? spawnSync; + const commands = options.commands ?? resolveInstallCommands(env); + + // A partial extraction can leave stale entries (and a stale path.txt) that + // a re-run would merge with. Start clean so the repair is deterministic. + fs.rmSync(path.join(electronDir, 'dist'), { recursive: true, force: true }); + fs.rmSync(path.join(electronDir, 'path.txt'), { force: true }); + + // Running the postinstall under Bun extracts the full zip on every Node + // version, including Node 24 where the Node-based extract-zip is broken. + // Fall back to `node install.js` only when Bun is unavailable (older Node + // versions extract fine with Node). + for (const [bin, args] of commands) { + const label = `${bin} ${args.join(' ')}`; + const result = runner(bin, args, { + cwd: electronDir, + stdio: options.stdio ?? 'inherit', + env: { ...env, ELECTRON_SKIP_BINARY_DOWNLOAD: undefined }, + }); + if (result.error) { + console.warn(`[electron:ensure] could not run \`${label}\`: ${result.error.message}`); + continue; + } + if (result.status === 0 && isComplete(electronDir)) { + console.log(`[electron:ensure] repaired electron install at ${electronDir}`); + return true; + } + console.warn(`[electron:ensure] \`${label}\` exited with code ${result.status ?? 'null'}`); + } + return false; +} + +export async function main(argv = process.argv.slice(2), env = process.env) { + const bestEffort = argv.includes('--best-effort'); + + const overrideDir = env.OPENCHAMBER_ELECTRON_PKG_DIR; + const electronDir = overrideDir + ? fs.existsSync(path.join(overrideDir, 'package.json')) + ? overrideDir + : null + : resolveElectronPackageDir(); + + if (!electronDir) { + const message = '[electron:ensure] could not locate the installed `electron` package'; + if (bestEffort) { + console.warn(message); + return 0; + } + console.error(message); + return 1; + } + + if (isComplete(electronDir)) { + return 0; + } + + if (env.ELECTRON_SKIP_BINARY_DOWNLOAD) { + console.warn( + '[electron:ensure] electron binary is missing but ELECTRON_SKIP_BINARY_DOWNLOAD is set; skipping repair.', + ); + return bestEffort ? 0 : 1; + } + + console.warn( + `[electron:ensure] electron install at ${electronDir} is incomplete ` + + '(missing binary, path.txt, version, or architecture mismatch); repairing…', + ); + + if (repair(electronDir, { env })) { + return 0; + } + + const message = + '[electron:ensure] electron is still incomplete after repair; ' + + 'run `bun run --cwd packages/electron ensure:electron` with a network connection.'; + if (bestEffort) { + console.warn(message); + return 0; + } + console.error(message); + return 1; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + try { + process.exitCode = await main(); + } catch (error) { + console.error('[electron:ensure] unexpected error:', error); + process.exitCode = 1; + } +} diff --git a/packages/electron/scripts/ensure-electron.test.mjs b/packages/electron/scripts/ensure-electron.test.mjs new file mode 100644 index 00000000..0bc3aa08 --- /dev/null +++ b/packages/electron/scripts/ensure-electron.test.mjs @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + detectExecutableArch, + expectedArch, + isComplete, + main, + platformPath, + repair, + resolveElectronPackageDir, +} from './ensure-electron.mjs'; + +const FAIL_SCRIPT = 'process.exit(1);\n'; + +function headerBytesForArch(arch) { + const platform = process.platform; + if (platform === 'darwin') { + const cputype = { arm64: 0x0100000c, x64: 0x01000007, ia32: 0x00000007, arm: 0x0000000c }[arch]; + const buf = Buffer.alloc(8); + buf.writeUInt32LE(0xfeedfacf, 0); + buf.writeUInt32LE(cputype, 4); + return buf; + } + if (platform === 'linux') { + const machine = { arm64: 183, x64: 62, ia32: 3, arm: 40 }[arch]; + const buf = Buffer.alloc(20); + buf[0] = 0x7f; + buf[1] = 0x45; + buf[2] = 0x4c; + buf[3] = 0x46; + buf.writeUInt16LE(machine, 18); + return buf; + } + if (platform === 'win32') { + const peOffset = 0x80; + const machine = { arm64: 0xaa64, x64: 0x8664, ia32: 0x014c }[arch]; + const buf = Buffer.alloc(peOffset + 6); + buf.write('MZ', 0, 'latin1'); + buf.writeUInt32LE(peOffset, 0x3c); + buf.write('PE\0\0', peOffset, 'latin1'); + buf.writeUInt16LE(machine, peOffset + 4); + return buf; + } + throw new Error(`unsupported test platform: ${platform}`); +} + +function installScriptContent({ + arch = expectedArch(), + platform = platformPath(), + version = '41.2.1', + exitCode = 0, +} = {}) { + const headerHex = headerBytesForArch(arch).toString('hex'); + return [ + "const fs = require('node:fs');", + "const path = require('node:path');", + `const platformPath = ${JSON.stringify(platform)};`, + `const header = Buffer.from('${headerHex}', 'hex');`, + "const exe = path.join(__dirname, 'dist', platformPath);", + 'fs.mkdirSync(path.dirname(exe), { recursive: true });', + `fs.writeFileSync(path.join(__dirname, 'dist', 'version'), ${JSON.stringify(version)});`, + "fs.writeFileSync(path.join(__dirname, 'path.txt'), platformPath);", + 'fs.writeFileSync(exe, header);', + `process.exit(${exitCode});`, + ].join('\n'); +} + +function makeFixture({ + version = '41.2.1', + complete = false, + distVersion, + arch, + installScripts = {}, +} = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'electron-ensure-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'electron', version })); + if (complete) { + const platform = platformPath(); + fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist', 'version'), distVersion ?? version); + fs.writeFileSync(path.join(dir, 'path.txt'), platform); + fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(arch ?? expectedArch())); + } + for (const [name, content] of Object.entries(installScripts)) { + fs.writeFileSync(path.join(dir, name), content); + } + return dir; +} + +function withFixture(t, options) { + const dir = makeFixture(options); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('isComplete accepts a healthy same-version, same-arch install', (t) => { + const dir = withFixture(t, { complete: true }); + assert.equal(isComplete(dir), true); +}); + +test('isComplete rejects a missing dist directory', (t) => { + const dir = withFixture(t); + assert.equal(isComplete(dir), false); +}); + +test('isComplete rejects a stale/mismatched version', (t) => { + const dir = withFixture(t, { complete: true, distVersion: '41.1.0' }); + assert.equal(isComplete(dir), false); +}); + +test('isComplete rejects a missing path.txt', (t) => { + const dir = withFixture(t, { complete: true }); + fs.rmSync(path.join(dir, 'path.txt')); + assert.equal(isComplete(dir), false); +}); + +test('isComplete rejects a binary of the wrong architecture', (t) => { + const hostArch = expectedArch(); + const otherArch = hostArch === 'arm64' ? 'x64' : 'arm64'; + const dir = withFixture(t, { complete: true, arch: otherArch }); + assert.equal(isComplete(dir), false); + // With an expected arch matching the fixture the same install passes. + assert.equal(isComplete(dir, otherArch), true); +}); + +test('detectExecutableArch reads the header for both architectures', (t) => { + const hostArch = expectedArch(); + const otherArch = hostArch === 'arm64' ? 'x64' : 'arm64'; + + const hostDir = withFixture(t, { complete: true }); + assert.equal(detectExecutableArch(path.join(hostDir, 'dist', platformPath())), hostArch); + + const otherDir = withFixture(t, { complete: true, arch: otherArch }); + assert.equal(detectExecutableArch(path.join(otherDir, 'dist', platformPath())), otherArch); +}); + +test('detectExecutableArch returns null for a non-executable file', (t) => { + const dir = withFixture(t); + const junk = path.join(dir, 'junk.bin'); + fs.writeFileSync(junk, 'not a binary'); + assert.equal(detectExecutableArch(junk), null); + assert.equal(detectExecutableArch(path.join(dir, 'missing')), null); +}); + +test('resolveElectronPackageDir locates the electron package in the monorepo', () => { + const dir = resolveElectronPackageDir(); + assert.ok(dir); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + assert.equal(pkg.name, 'electron'); +}); + +test('main returns 0 for a healthy install without attempting repair', async (t) => { + const dir = withFixture(t, { complete: true, installScripts: { 'install.js': FAIL_SCRIPT } }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]', + }; + assert.equal(await main([], env), 0); + // The failing install script never ran: dist was not rewritten. + assert.equal(fs.readFileSync(path.join(dir, 'path.txt'), 'utf8').trim(), platformPath()); +}); + +test('main repairs an incomplete install via the injected command', async (t) => { + const dir = withFixture(t, { installScripts: { 'install.js': installScriptContent() } }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]', + }; + assert.equal(await main([], env), 0); + assert.equal(isComplete(dir), true); +}); + +test('main falls back from a failing first command to a succeeding second', async (t) => { + const dir = withFixture(t, { + installScripts: { + 'install-fail.js': FAIL_SCRIPT, + 'install-ok.js': installScriptContent(), + }, + }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: + '[["bun",["install-fail.js"]],["node",["install-ok.js"]]]', + }; + assert.equal(await main([], env), 0); + assert.equal(isComplete(dir), true); +}); + +test('main returns 1 when every repair command fails', async (t) => { + const dir = withFixture(t, { + installScripts: { + 'install-fail-1.js': FAIL_SCRIPT, + 'install-fail-2.js': FAIL_SCRIPT, + }, + }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: + '[["node",["install-fail-1.js"]],["node",["install-fail-2.js"]]]', + }; + assert.equal(await main([], env), 1); +}); + +test('main --best-effort returns 0 when repair is impossible', async (t) => { + const dir = withFixture(t, { installScripts: { 'install-fail.js': FAIL_SCRIPT } }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install-fail.js"]]]', + }; + assert.equal(await main(['--best-effort'], env), 0); +}); + +test('main honors ELECTRON_SKIP_BINARY_DOWNLOAD and skips repair', async (t) => { + const dir = withFixture(t, { installScripts: { 'install.js': installScriptContent() } }); + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: dir, + OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]', + ELECTRON_SKIP_BINARY_DOWNLOAD: '1', + }; + assert.equal(await main([], env), 1); + assert.equal(fs.existsSync(path.join(dir, 'dist')), false); + + assert.equal(await main(['--best-effort'], env), 0); + assert.equal(fs.existsSync(path.join(dir, 'dist')), false); +}); + +test('main reports a missing electron package', async () => { + const env = { + ...process.env, + OPENCHAMBER_ELECTRON_PKG_DIR: path.join(os.tmpdir(), 'does-not-exist-electron-pkg'), + }; + assert.equal(await main([], env), 1); + assert.equal(await main(['--best-effort'], env), 0); +}); + +test('repair skips a command whose runner errors and keeps trying the rest', (t) => { + const dir = withFixture(t); + const calls = []; + const commands = [['bun', ['install.js']], ['node', ['install.js']]]; + const result = repair(dir, { + runner: (bin, args) => { + calls.push([bin, args]); + return { error: new Error('spawn failed') }; + }, + commands, + }); + assert.equal(result, false); + // Exactly the injected commands were invoked, in order. + assert.deepEqual(calls, commands); +}); + +test('repair returns false when the runner reports a failure status for every command', (t) => { + const dir = withFixture(t); + const calls = []; + const commands = [['bun', ['install.js']], ['node', ['install.js']]]; + const result = repair(dir, { + runner: (bin, args) => { + calls.push([bin, args]); + return { status: 1 }; + }, + commands, + }); + assert.equal(result, false); + assert.deepEqual(calls, commands); +}); + +test('repair runs injected commands in order and stops after the first success', (t) => { + const dir = withFixture(t); + const calls = []; + const commands = [ + ['bun', ['install-fail.js']], + ['node', ['install-ok.js']], + ['node', ['install-never.js']], + ]; + const result = repair(dir, { + runner: (bin, args) => { + calls.push([bin, args]); + if (args[0] === 'install-ok.js') { + // Simulate a successful postinstall: materialize a complete install so + // isComplete() sees a healthy dist right after the command. + const platform = platformPath(); + fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist', 'version'), '41.2.1'); + fs.writeFileSync(path.join(dir, 'path.txt'), platform); + fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(expectedArch())); + return { status: 0 }; + } + return { status: 1 }; + }, + commands, + }); + assert.equal(result, true); + // Only the failing and succeeding commands ran; the trailing one was skipped. + assert.deepEqual(calls, commands.slice(0, 2)); + assert.equal(isComplete(dir), true); +}); diff --git a/packages/electron/scripts/rebuild-native.mjs b/packages/electron/scripts/rebuild-native.mjs index 47039cc5..f57ee368 100644 --- a/packages/electron/scripts/rebuild-native.mjs +++ b/packages/electron/scripts/rebuild-native.mjs @@ -133,19 +133,6 @@ const ensureWindowsNodeAddonApiForNodePty = async (rebuildRootPath) => { console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`); -await rebuild({ - buildPath: electronDir, - electronVersion, - force: true, - arch: targetArchitecture.electronBuilder, - onlyModules: ['better-sqlite3'], -}); -const betterSqliteDir = path.dirname(require.resolve('better-sqlite3/package.json')); -const betterSqliteBinary = path.join(betterSqliteDir, 'build', 'Release', 'better_sqlite3.node'); -if (!existsSync(betterSqliteBinary)) { - throw new Error(`better-sqlite3 rebuild did not produce ${betterSqliteBinary}`); -} - // Rebuild against the hoisted root node_modules (bun workspace layout). // force=true re-links regardless of cached state; prebuild-install lookup is // bypassed by @electron/rebuild in favor of direct node-gyp builds. diff --git a/packages/electron/scripts/smoke-linux-app-discovery.mjs b/packages/electron/scripts/smoke-linux-app-discovery.mjs index 9a0ff13d..0f15827d 100644 --- a/packages/electron/scripts/smoke-linux-app-discovery.mjs +++ b/packages/electron/scripts/smoke-linux-app-discovery.mjs @@ -28,14 +28,22 @@ try { const iconsRoot = path.join(dataDir, 'icons'); const thunarIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'org.xfce.thunar.png'); const codeIcon = path.join(iconsRoot, 'hicolor', '32x32', 'apps', 'code.png'); + const terminalIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'utilities-terminal.png'); + const helperIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'helper-app.png'); await fs.mkdir(userApps, { recursive: true }); await fs.mkdir(systemApps, { recursive: true }); await fs.mkdir(path.dirname(thunarIcon), { recursive: true }); await fs.mkdir(path.dirname(codeIcon), { recursive: true }); + await fs.mkdir(path.dirname(terminalIcon), { recursive: true }); + await fs.mkdir(path.dirname(helperIcon), { recursive: true }); // Minimal valid 1x1 PNG. const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + // Distinct PNG so the helper-app icon is distinguishable from the terminal theme icon. + const helperPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBASZNV9oAAAAASUVORK5CYII=', 'base64'); await fs.writeFile(thunarIcon, png); await fs.writeFile(codeIcon, png); + await fs.writeFile(terminalIcon, png); + await fs.writeFile(helperIcon, helperPng); const codeDesktopPath = path.join(userApps, 'code.desktop'); await fs.writeFile(codeDesktopPath, [ @@ -52,6 +60,23 @@ try { await fs.writeFile(path.join(userApps, 'missing-name.desktop'), '[Desktop Entry]\nType=Application\nExec=missing %f\n', 'utf8'); await fs.writeFile(path.join(userApps, 'missing-exec.desktop'), '[Desktop Entry]\nType=Application\nName=Missing Exec\nIcon=missing\n', 'utf8'); await fs.writeFile(path.join(systemApps, 'ghostty.desktop'), '[Desktop Entry]\nType=Application\nName=Ghostty\nExec=ghostty --working-directory=%f --open-uri=%u\nIcon=ghostty\n', 'utf8'); + // A non-terminal app that launches itself via xdg-terminal-exec (e.g. a TUI + // helper). Its Exec line contains "xdg-terminal-exec", which the loose + // substring match in desktopEntryMatchesApp would mis-attribute to the + // generic "terminal" appId. Categories=Utility; (not TerminalEmulator) is + // the signal that this is NOT a terminal emulator. + await fs.writeFile(path.join(systemApps, 'helper-app.desktop'), [ + '[Desktop Entry]', + 'Type=Application', + 'NoDisplay=false', + 'Terminal=false', + 'StartupNotify=true', + 'Exec=/usr/bin/xdg-terminal-exec --app-id=helper-app --title="Helper App" -- /usr/bin/helper-script', + `Icon=${helperIcon}`, + 'Name=Helper App', + 'Categories=Utility;', + '', + ].join('\n'), 'utf8'); await fs.writeFile(path.join(systemApps, 'plain.desktop'), '[Desktop Entry]\nType=Application\nName=Plain Editor\nExec=plain-editor --flag\nIcon=plain\n', 'utf8'); await fs.writeFile(path.join(systemApps, 'thunar.desktop'), [ '[Desktop Entry]', @@ -69,7 +94,7 @@ try { assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included'); const entries = await readLinuxDesktopEntries({ applicationDirs: [userApps, systemApps], env, homeDir: tempRoot }); - assert(entries.length === 4, `expected 4 visible valid entries, got ${entries.length}`); + assert(entries.length === 5, `expected 5 visible valid entries, got ${entries.length}`); assert(entries.some((entry) => entry.name === 'Visual Studio Code'), 'valid desktop entry should be parsed'); assert(entries.some((entry) => entry.name === 'Ghostty'), 'system desktop entry should be parsed'); assert(entries.some((entry) => entry.name === 'Plain Editor'), 'no-placeholder entry should be parsed'); @@ -127,6 +152,28 @@ try { const codeInfo = appInfos.find((entry) => entry.name === 'Visual Studio Code'); assert(typeof codeInfo?.iconDataUrl === 'string' && codeInfo.iconDataUrl.startsWith('data:image/png;base64,'), 'desktop app should resolve Icon= theme PNG to data URL'); + const terminalEmulatorEntry = parseDesktopEntry([ + '[Desktop Entry]', + 'Type=Application', + 'Name=MyConsole', + 'Exec=myconsole --working-directory=%f', + 'Icon=utilities-terminal', + 'Categories=TerminalEmulator;', + '', + ].join('\n'), path.join(systemApps, 'myconsole.desktop')); + const helperEntry = entries.find((entry) => entry.name === 'Helper App'); + const terminalIconInfos = await buildLinuxInstalledApps(['Terminal'], { + entries: [helperEntry, terminalEmulatorEntry], + env, + homeDir: tempRoot, + execFileSyncImpl: () => 'thunar.desktop', + }); + const terminalInfo = terminalIconInfos.find((entry) => entry.name === 'Terminal'); + const expectedTerminalIconDataUrl = `data:image/png;base64,${png.toString('base64')}`; + const expectedHelperIconDataUrl = `data:image/png;base64,${helperPng.toString('base64')}`; + assert(terminalInfo?.iconDataUrl === expectedTerminalIconDataUrl, `Terminal should resolve the TerminalEmulator entry icon, got ${terminalInfo?.iconDataUrl}`); + assert(terminalInfo?.iconDataUrl !== expectedHelperIconDataUrl, 'Terminal icon must not resolve to a non-terminal entry whose Exec uses a terminal launcher (loose name/exec match regression)'); + const fetchedIcons = await fetchLinuxAppIcons(['Finder', 'Visual Studio Code'], { entries, env, @@ -151,6 +198,27 @@ try { assert(fallbackTerminalSpecs.length >= 1, 'missing terminal desktop entry should include xdg-terminal-exec fallback'); assert(fallbackTerminalSpecs[0]?.program === 'xdg-terminal-exec', 'missing terminal entry should use xdg-terminal-exec first'); assert(fallbackTerminalSpecs[0]?.args.join('|') === '--working-directory|/tmp/My Project', `xdg-terminal-exec fallback should keep working directory args, got ${fallbackTerminalSpecs[0]?.args.join('|')}`); + assert(!fallbackTerminalSpecs.some((spec) => (spec.args || []).some((arg) => arg.includes('helper-script'))), 'non-terminal entry using a terminal launcher must not be launched for the generic terminal appId'); + + const ptyxisEntry = parseDesktopEntry([ + '[Desktop Entry]', + 'Type=Application', + 'Name=Ptyxis', + 'Exec=ptyxis --working-directory=%f', + 'Categories=TerminalEmulator;', + '', + ].join('\n'), '/tmp/org.gnome.Ptyxis.desktop'); + const terminalEmulatorSpecs = buildLinuxOpenSpecs({ + targetPath: '/tmp/My Project', + appId: 'terminal', + appName: 'Terminal', + targetKind: 'project', + entries: [ptyxisEntry, ...entries], + env, + }); + assert(terminalEmulatorSpecs[0]?.program === 'ptyxis', `terminal emulator entry should be preferred, got ${terminalEmulatorSpecs[0]?.program}`); + assert(terminalEmulatorSpecs[0]?.args.join('|') === '--working-directory=/tmp/My Project', `ptyxis should receive working directory, got ${terminalEmulatorSpecs[0]?.args.join('|')}`); + assert(!terminalEmulatorSpecs.some((spec) => (spec.args || []).some((arg) => arg.includes('helper-script'))), 'non-terminal entry using a terminal launcher must not be launched when a real terminal emulator entry exists'); const defaultSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'finder', appName: 'Finder', targetKind: 'project', entries, env }); assert(defaultSpecs[0].kind === 'default', 'finder maps to safe default Linux opener spec'); @@ -169,6 +237,7 @@ try { specs, terminalFileSpecs, fallbackTerminalSpecs, + terminalEmulatorSpecs, defaultSpecs, }, null, 2)); } finally { diff --git a/packages/electron/scripts/verify-linux-appimage.mjs b/packages/electron/scripts/verify-linux-appimage.mjs index 97da9a05..697f7882 100644 --- a/packages/electron/scripts/verify-linux-appimage.mjs +++ b/packages/electron/scripts/verify-linux-appimage.mjs @@ -13,7 +13,7 @@ const ELF_MACHINE = { x64: 62, arm64: 183 }; // sherpa-onnx-node loads this Node-API addon from its platform-specific prebuilt // package in the separate server worker, so verify its architecture here rather // than Electron-rebuilding it with the source-built modules. -const REQUIRED_NATIVE_MODULES = ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']; +const REQUIRED_NATIVE_MODULES = ['pty.node', 'sherpa-onnx.node']; /** electron-builder AppImage arch token: x64 → x86_64, arm64 → arm64 */ export const linuxAppImageArchSuffix = (architecture) => ( diff --git a/packages/electron/scripts/verify-linux-appimage.test.mjs b/packages/electron/scripts/verify-linux-appimage.test.mjs index 3ba2084d..7ed98c90 100644 --- a/packages/electron/scripts/verify-linux-appimage.test.mjs +++ b/packages/electron/scripts/verify-linux-appimage.test.mjs @@ -21,7 +21,7 @@ const createPayload = () => { ].join('\n')); writeElf(path.join(root, 'openchamber'), 'x64'); writeElf(path.join(root, 'resources/opencode-cli/opencode'), 'x64'); - for (const name of ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']) { + for (const name of ['pty.node', 'sherpa-onnx.node']) { writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules', name), 'x64'); } return root; @@ -53,7 +53,7 @@ test('verifies identity, version, and native payload architecture', () => { expectedOpenCodeVersion: '1.17.18', runCliVersion: () => '1.17.18', }); - assert.equal(result.nativeModuleCount, 3); + assert.equal(result.nativeModuleCount, 2); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 1bd40a73..b5aab832 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.17.2", + "version": "1.18.0", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index fb85e607..5f93651b 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1800,26 +1800,28 @@ export const MobileSessionsSheet: React.FC = ({ open, style={{ paddingBottom: 'calc(0.375rem + var(--oc-safe-area-bottom, 0px))' }} > {footer.instanceLabel && footer.onOpenInstances ? ( - + + {footer.instanceLabel} + ) : (
)}
{footer.onOpenUpdate ? ( - + ) : null} - +
) : null} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index feda2c44..ba493f55 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -159,6 +159,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16; */ const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const EMPTY_QUEUE: QueuedMessage[] = []; +const EMPTY_SENDING_IDS: string[] = []; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { @@ -945,9 +946,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, } : getCurrentInputSnapshot(); - const queuedMessagesToSend = queuedMessageId + // A queued item stays in the queue until its own send resolves, so the + // auto-send hook may already be delivering one of these. Merging it here + // would send the same message twice (the window is seconds over a relay). + const sendingIds = messageQueueTarget + ? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS + : EMPTY_SENDING_IDS; + const queuedMessagesToSend = (queuedMessageId ? queuedMessages.filter((message) => message.id === queuedMessageId) - : queuedMessages; + : queuedMessages + ).filter((message) => !sendingIds.includes(message.id)); if (queuedOnly && autoReviewRunning) { return; diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index b8d1486c..c8ecc9f4 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -16,6 +16,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; interface TextSelectionMenuProps { containerRef: React.RefObject; @@ -309,6 +310,9 @@ export const TextSelectionMenu: React.FC = ({ containerR // Clear selection window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + focusChatInput(); + }); }, [selectedTextMarkdown, setPendingInputText, hideMenu]); const handleCreateNewSession = React.useCallback(async () => { diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 9a7e4a04..2736e675 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; +import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; type FileNode = { @@ -190,6 +191,7 @@ interface FileRowProps { root: string; isExpanded: boolean; isActive: boolean; + isBrowserClient: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; permissions: { @@ -211,6 +213,7 @@ const FileRow: React.FC = ({ root, isExpanded, isActive, + isBrowserClient, status, badge, permissions, @@ -223,6 +226,9 @@ const FileRow: React.FC = ({ const { t } = useI18n(); const isDir = node.type === 'directory'; const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; + const canDownload = !isDir && Boolean(downloadFile); + const canRevealPath = canReveal && !isBrowserClient; + const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath; // Menu open state is local to each row so opening a menu in one row // never re-renders its siblings. Previously this state lived on the @@ -231,10 +237,10 @@ const FileRow: React.FC = ({ const [rightClickOpen, setRightClickOpen] = React.useState(false); const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return; + if (!hasMenuActions) return; event?.preventDefault(); setRightClickOpen(true); - }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal]); + }, [hasMenuActions]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -283,10 +289,10 @@ const FileRow: React.FC = ({ toast.error(t('sidebarFilesTree.toast.operationFailed')); }); }}> - {t('sidebarFilesTree.menu.save')} + {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')} )} - {canReveal && ( + {canRevealPath && ( { e.stopPropagation(); onRevealPath(node.path); }}> {t(getRevealLabelKey())} @@ -362,7 +368,7 @@ const FileRow: React.FC = ({ )} - {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( + {hasMenuActions && (
{ const { t } = useI18n(); - const { files } = useRuntimeAPIs(); + const { files, runtime } = useRuntimeAPIs(); + const isBrowserClient = isBrowserClientRuntime(runtime.platform); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const showHidden = useDirectoryShowHidden(); @@ -1045,6 +1053,7 @@ export const SidebarFilesTree: React.FC = () => { root={root} isExpanded={isExpanded} isActive={isActive} + isBrowserClient={isBrowserClient} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} permissions={fileRowPermissions} diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index afb9775a..448dcc6e 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability'; -import { listOAuthMethods, normalizeAuthType } from './providerAuthMethods'; +import { shouldLoadAvailableProviders } from './providerAvailability'; +import { + getOAuthAuthMethods, + normalizeAuthType, + parseAuthPayload, + shouldShowApiKeyAuth, +} from './providerAuth'; describe('ProvidersPage available provider loading', () => { test('loads available providers only in add-provider mode', () => { @@ -9,35 +14,47 @@ describe('ProvidersPage available provider loading', () => { }); }); -describe('ProvidersPage auth method loading', () => { - test('loads auth methods for add mode and reconnect panel', () => { - expect(shouldLoadProviderAuthMethods(false, false)).toBe(false); - expect(shouldLoadProviderAuthMethods(true, false)).toBe(true); - expect(shouldLoadProviderAuthMethods(false, true)).toBe(true); - expect(shouldLoadProviderAuthMethods(true, true)).toBe(true); - }); -}); - -describe('ProvidersPage OAuth method indexes', () => { - test('preserves the original provider.auth() index after filtering', () => { - const methods = listOAuthMethods([ - { type: 'api' }, - { type: 'oauth', label: 'Browser' }, - ]); - expect(methods).toEqual([{ method: { type: 'oauth', label: 'Browser' }, methodIndex: 1 }]); - }); - - test('keeps multiple OAuth indexes relative to the full methods array', () => { - const methods = listOAuthMethods([ - { type: 'oauth', label: 'First' }, - { type: 'api' }, - { type: 'oauth', label: 'Second' }, - ]); - expect(methods.map((entry) => entry.methodIndex)).toEqual([0, 2]); - }); - - test('detects oauth from labels when type is missing', () => { - expect(normalizeAuthType({ label: 'Sign in with OAuth' })).toBe('oauth'); - expect(normalizeAuthType({ name: 'API Key' })).toBe('api'); +describe('provider auth method helpers', () => { + test('normalizeAuthType recognizes oauth and api labels', () => { + expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth'); + expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api'); + expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth'); + expect(normalizeAuthType({ name: 'API key' })).toBe('api'); + }); + + test('parseAuthPayload keeps only object auth method entries', () => { + expect(parseAuthPayload({ + cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'], + openai: null, + })).toEqual({ + cursor: [{ type: 'oauth', label: 'Cursor' }], + }); + expect(parseAuthPayload(null)).toEqual({}); + }); + + test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => { + expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false); + expect(shouldShowApiKeyAuth([ + { type: 'api', label: 'API Key' }, + { type: 'oauth', label: 'ChatGPT' }, + ])).toBe(true); + expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true); + // Unknown / unloaded methods keep the legacy API key fallback. + expect(shouldShowApiKeyAuth([])).toBe(true); + }); + + test('getOAuthAuthMethods preserves original method indexes', () => { + const methods = [ + { type: 'api', label: 'API Key' }, + { type: 'oauth', label: 'OAuth' }, + { type: 'oauth', label: 'Device' }, + ]; + expect(getOAuthAuthMethods(methods)).toEqual([ + { method: methods[1], methodIndex: 1 }, + { method: methods[2], methodIndex: 2 }, + ]); + expect(getOAuthAuthMethods([{ type: 'oauth', label: 'Cursor' }])).toEqual([ + { method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 }, + ]); }); }); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index d7591b14..a186ad88 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -25,8 +25,13 @@ import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; -import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability'; -import { listOAuthMethods } from './providerAuthMethods'; +import { shouldLoadAvailableProviders } from './providerAvailability'; +import { + getOAuthAuthMethods, + parseAuthPayload, + shouldShowApiKeyAuth, + type AuthMethod, +} from './providerAuth'; import { CustomProviderForm } from './CustomProviderForm'; import { buildAuthSetRequest, @@ -60,16 +65,6 @@ const formatTokens = (value?: number | null) => { const ADD_PROVIDER_ID = '__add_provider__'; -interface AuthMethod { - type?: string; - name?: string; - label?: string; - description?: string; - help?: string; - method?: number; - [key: string]: unknown; -} - interface ProviderOption { id: string; name?: string; @@ -90,19 +85,6 @@ interface ProviderSources { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const parseAuthPayload = (payload: unknown): Record => { - if (!isRecord(payload)) { - return {}; - } - const result: Record = {}; - for (const [providerId, value] of Object.entries(payload)) { - if (Array.isArray(value)) { - result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; - } - } - return result; -}; - const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { if (typeof entry === 'string') { return { id: entry }; @@ -182,7 +164,6 @@ export const ProvidersPage: React.FC = () => { const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState(null); const [lastCustomPersistId, setLastCustomPersistId] = React.useState(null); const isAddMode = selectedProviderId === ADD_PROVIDER_ID; - const loadAuthMethods = shouldLoadProviderAuthMethods(isAddMode, showAuthPanel); const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID; const isCustomEditMode = Boolean( editingCustomProviderId @@ -198,13 +179,16 @@ export const ProvidersPage: React.FC = () => { }, [providers, selectedProviderId, setSelectedProvider]); React.useEffect(() => { - if (!loadAuthMethods) { + // Auth methods drive which credential UI to show (API key vs OAuth). Keep + // them loaded for the active provider view so OAuth-only plugins never fall + // back to an API key form merely because methods were never fetched. + if (!selectedProviderId) { return; } let isMounted = true; - const fetchAuthMethods = async () => { + const loadAuthMethods = async () => { setAuthLoading(true); try { const result = await opencodeClient.getSdkClient().provider.auth(); @@ -224,12 +208,12 @@ export const ProvidersPage: React.FC = () => { } }; - void fetchAuthMethods(); + loadAuthMethods(); return () => { isMounted = false; }; - }, [loadAuthMethods, t]); + }, [selectedProviderId, t]); React.useEffect(() => { if (!shouldLoadAvailableProviders(isAddMode)) { @@ -316,6 +300,26 @@ export const ProvidersPage: React.FC = () => { } }, [selectedProviderId, editingCustomProviderId]); + // Unauthenticated providers (OAuth-only plugins before login) should open the + // auth panel instead of a false "Connected" summary. + React.useEffect(() => { + if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { + return; + } + const sources = providerSources[selectedProviderId]; + if (!sources) { + return; + } + const provider = providers.find((entry) => entry.id === selectedProviderId); + const envEntries = Array.isArray(provider?.env) + ? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0; + if (!hasCreds) { + setShowAuthPanel(true); + } + }, [selectedProviderId, providerSources, providers]); + React.useEffect(() => { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { return; @@ -773,123 +777,126 @@ export const ProvidersPage: React.FC = () => {

{t('settings.providers.page.auth.loadingMethods')}

) : ( <> -
- -
- - setApiKeyInputs((prev) => ({ - ...prev, - [candidateProviderId]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} - className="flex-1 font-mono text-xs" - /> - -
-
- {(() => { const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; - const candidateOAuthMethods = listOAuthMethods(candidateAuthMethods); - - if (candidateOAuthMethods.length === 0) { - return null; - } + const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods); + const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods); return ( -
- {candidateOAuthMethods.map(({ method, methodIndex }) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); - const codeKey = `${candidateProviderId}:${methodIndex}`; - const isPending = - pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; + <> + {showApiKey ? ( +
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [candidateProviderId]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} + className="flex-1 font-mono text-xs" + /> + +
+
+ ) : null} - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} + {candidateOAuthMethods.length > 0 ? ( +
+ {candidateOAuthMethods.map(({ method, methodIndex }) => { + const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); + const codeKey = `${candidateProviderId}:${methodIndex}`; + const isPending = + pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; + + return ( +
+
+
+
{methodLabel}
+ {(method.description || method.help) && ( +
+ {String(method.description || method.help)} +
+ )} +
+ +
+ + {oauthDetails[codeKey]?.instructions && ( +

+ {oauthDetails[codeKey]?.instructions} +

+ )} + + {oauthDetails[codeKey]?.userCode && ( +
+ + +
+ )} + + {oauthDetails[codeKey]?.url && ( +
+ +
+ + +
+
+ )} + + {isPending && ( +
+ + setOauthCodes((prev) => ({ + ...prev, + [codeKey]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} + className="font-mono text-xs" + /> +
)}
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ ); + })} +
+ ) : null} + ); })()} @@ -914,7 +921,8 @@ export const ProvidersPage: React.FC = () => { const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; - const oauthAuthMethods = listOAuthMethods(providerAuthMethods); + const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods); + const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods); const sourcesLoaded = Boolean(selectedSources); const isEditableCustomProvider = sourcesLoaded && isConfigDefinedCustomProvider(selectedProvider, selectedSources); @@ -924,7 +932,11 @@ export const ProvidersPage: React.FC = () => { const hasStoredAuth = Boolean(selectedSources?.auth.exists); const hasEnvCredentials = providerEnv.length > 0; const hasCredentials = hasStoredAuth || hasEnvCredentials; - const authStatusIncomplete = isEditableCustomProvider && !hasCredentials; + const authStatusIncomplete = sourcesLoaded && !hasCredentials; + const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials); + const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0 + ? t('settings.providers.page.auth.useReconnectHint') + : t('settings.providers.page.auth.incompleteHint'); const filteredModels = providerModels.filter((model) => { const name = typeof model?.name === 'string' ? model.name : ''; @@ -1007,7 +1019,7 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.incomplete')} - {t('settings.providers.page.auth.incompleteHint')} + {incompleteAuthHint}
) : (
@@ -1020,37 +1032,39 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.loadingMethods')}
) : (
-
- -
- - setApiKeyInputs((prev) => ({ - ...prev, - [selectedProvider.id]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} - className="flex-1 font-mono text-xs" - /> - + {showApiKeyAuth ? ( +
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [selectedProvider.id]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} + className="flex-1 font-mono text-xs" + /> + +
-
+ ) : null} {oauthAuthMethods.length > 0 && ( -
+
{oauthAuthMethods.map(({ method, methodIndex }) => { const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); const codeKey = `${selectedProvider.id}:${methodIndex}`; @@ -1058,7 +1072,7 @@ export const ProvidersPage: React.FC = () => { pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex; return ( -
+
{methodLabel}
@@ -1168,14 +1182,13 @@ export const ProvidersPage: React.FC = () => {
+ {showModelsSection ? ( 0 ? ( - - ({providerModels.length}) - - ) : null + + ({providerModels.length}) + } headerAction={(
@@ -1284,6 +1297,7 @@ export const ProvidersPage: React.FC = () => {
)}
+ ) : null} ); }; diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts new file mode 100644 index 00000000..5d253e0b --- /dev/null +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -0,0 +1,57 @@ +export interface AuthMethod { + type?: string; + name?: string; + label?: string; + description?: string; + help?: string; + method?: number; + [key: string]: unknown; +} + +export interface OAuthAuthMethodEntry { + method: AuthMethod; + /** Index in the full provider auth-methods array (passed to oauth authorize/callback). */ + methodIndex: number; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const normalizeAuthType = (method: AuthMethod): string => { + const raw = typeof method.type === 'string' ? method.type : ''; + const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); + const merged = `${raw} ${label}`.toLowerCase(); + if (merged.includes('oauth')) return 'oauth'; + if (merged.includes('api')) return 'api'; + return raw.toLowerCase(); +}; + +export const parseAuthPayload = (payload: unknown): Record => { + if (!isRecord(payload)) { + return {}; + } + const result: Record = {}; + for (const [providerId, value] of Object.entries(payload)) { + if (Array.isArray(value)) { + result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; + } + } + return result; +}; + +/** + * Show the API key form when the provider declares API auth, or when auth + * methods are still unknown (empty). OAuth-only providers must not get an + * API key prompt. + */ +export const shouldShowApiKeyAuth = (methods: AuthMethod[]): boolean => { + if (methods.length === 0) { + return true; + } + return methods.some((method) => normalizeAuthType(method) === 'api'); +}; + +export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry[] => + methods + .map((method, methodIndex) => ({ method, methodIndex })) + .filter(({ method }) => normalizeAuthType(method) === 'oauth'); diff --git a/packages/ui/src/components/sections/providers/providerAuthMethods.ts b/packages/ui/src/components/sections/providers/providerAuthMethods.ts deleted file mode 100644 index cac233b0..00000000 --- a/packages/ui/src/components/sections/providers/providerAuthMethods.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type ProviderAuthMethod = { - type?: string; - name?: string; - label?: string; - description?: string; - help?: string; -}; - -export const normalizeAuthType = (method: ProviderAuthMethod): string => { - const raw = typeof method.type === 'string' ? method.type : ''; - const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); - const merged = `${raw} ${label}`.toLowerCase(); - if (merged.includes('oauth')) return 'oauth'; - if (merged.includes('api')) return 'api'; - return raw.toLowerCase(); -}; - -/** OAuth methods with the original provider.auth() method index OpenCode expects. */ -export const listOAuthMethods = ( - methods: ProviderAuthMethod[], -): Array<{ method: ProviderAuthMethod; methodIndex: number }> => - methods - .map((method, methodIndex) => ({ method, methodIndex })) - .filter(({ method }) => normalizeAuthType(method) === 'oauth'); diff --git a/packages/ui/src/components/sections/providers/providerAvailability.ts b/packages/ui/src/components/sections/providers/providerAvailability.ts index c98db78c..ea6f0386 100644 --- a/packages/ui/src/components/sections/providers/providerAvailability.ts +++ b/packages/ui/src/components/sections/providers/providerAvailability.ts @@ -1,5 +1 @@ export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode; - -/** Auth methods are needed when adding a provider or reconnecting an existing one. */ -export const shouldLoadProviderAuthMethods = (isAddMode: boolean, showAuthPanel: boolean): boolean => - isAddMode || showAuthPanel; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index d92659d8..acd52fad 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC = ({ const deleteSessions = useSessionUIStore((state) => state.deleteSessions); const archiveSession = useSessionUIStore((state) => state.archiveSession); const archiveSessions = useSessionUIStore((state) => state.archiveSessions); + const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession); + const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions); const { copiedSessionId, @@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC = ({ handleCopySessionId, handleUnshareSession, handleDeleteSession, + handleRestoreSession, confirmDeleteSession, } = useSessionActions({ mobileVariant, @@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC = ({ deleteSessions, archiveSession, archiveSessions, + unarchiveSession, childrenMap, showDeletionDialog, setDeleteSessionConfirm, @@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC = ({ const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId); const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession); const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession); + const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession); const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename); const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => { @@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC = ({ createFolderAndStartRename={stableCreateFolderAndStartRename} openContextPanelTab={openContextPanelTab} handleDeleteSession={stableHandleDeleteSession} + handleRestoreSession={stableHandleRestoreSession} mobileVariant={mobileVariant} alwaysShowActions={alwaysShowSidebarActions} renderSessionNode={renderSessionNode} @@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC = ({ handleBulkCreateFolderAndMove, handleBulkRemoveFromFolder, handleBulkDelete, + handleBulkRestore, confirmBulkDelete, } = useSidebarBulkActions({ isInlineEditing, @@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC = ({ removeSessionsFromFolders, createFolderAndStartRename, archiveSessions, + unarchiveSessions, deleteSessions, setBulkDeleteConfirm, }); @@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC = ({ onCreateFolderAndMove={handleBulkCreateFolderAndMove} onRemoveFromFolder={handleBulkRemoveFromFolder} canRemoveFromFolder={bulkCanRemoveFromFolder} + onRestore={handleBulkRestore} onDelete={handleBulkDelete} onDone={handleExitSelectionMode} /> diff --git a/packages/ui/src/components/session/sidebar/BulkActionBar.tsx b/packages/ui/src/components/session/sidebar/BulkActionBar.tsx index f42e3b82..d58e7034 100644 --- a/packages/ui/src/components/session/sidebar/BulkActionBar.tsx +++ b/packages/ui/src/components/session/sidebar/BulkActionBar.tsx @@ -21,6 +21,7 @@ type Props = { onCreateFolderAndMove: () => void; onRemoveFromFolder: () => void; canRemoveFromFolder: boolean; + onRestore: () => void; onDelete: () => void; onDone: () => void; }; @@ -34,6 +35,7 @@ export const BulkActionBar: React.FC = ({ onCreateFolderAndMove, onRemoveFromFolder, canRemoveFromFolder, + onRestore, onDelete, onDone, }) => { @@ -98,6 +100,22 @@ export const BulkActionBar: React.FC = ({ ) : null} + {archivedBucket ? ( + + + + +

{t('sessions.sidebar.bulkActions.restore')}

+
+ ) : null} + - {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( + {hasMenuActions && (
= ({ mode = 'full' }) => { const { files, runtime } = useRuntimeAPIs(); const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); const { isMobile, isTablet, screenWidth } = useDeviceInfo(); + const isBrowserClient = isBrowserClientRuntime(runtime.platform); const alwaysShowActions = isMobile || isTablet; const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); @@ -2302,6 +2308,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { isExpanded={isExpanded} isActive={isActive} isMobile={isMobile} + isBrowserClient={isBrowserClient} alwaysShowActions={alwaysShowActions} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index c3f5a6f4..87cabf01 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { addSelectionToChat } from '@/lib/addSelectionToChat'; import { hasOpenDropdown } from './keyboard-shortcut-dom'; export const useKeyboardShortcuts = () => { @@ -337,6 +338,12 @@ export const useKeyboardShortcuts = () => { return; } + if (eventMatchesShortcut(e, combo('add_selection_to_chat'))) { + e.preventDefault(); + addSelectionToChat(); + return; + } + if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { e.preventDefault(); const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 6b6abc09..d74243ec 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { sessionEvents } from '@/lib/sessionEvents'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { showOpenCodeStatus } from '@/lib/openCodeStatus'; +import { addSelectionToChat } from '@/lib/addSelectionToChat'; const getActiveElementSelectedText = (): string => { if (typeof document === 'undefined') { @@ -77,6 +78,7 @@ type MenuAction = | 'toggle-terminal' | 'toggle-terminal-expanded' | 'copy' + | 'add-selection-to-chat' | 'theme-light' | 'theme-dark' | 'theme-system' @@ -278,6 +280,10 @@ export const useMenuActions = ( setThemeMode('system'); break; + case 'add-selection-to-chat': + addSelectionToChat(); + break; + case 'toggle-sidebar': toggleSidebar(); break; diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index bc327ebe..e7b39c97 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -221,7 +221,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? return; } - const payload = buildQueuedAutoSendPayload(queueSnapshot); + // Read the queue back at dispatch time and skip anything already being + // delivered, rather than trusting the render-time snapshot. + const payload = buildQueuedAutoSendPayload(useMessageQueueStore.getState().getSendableQueue(target)); if (!payload) { return; } @@ -248,6 +250,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? } inFlightSessionsRef.current.add(targetKey); + // The ref only guards this hook. Publish the dispatch to the store so the + // composer cannot merge the same item into a parallel send while this one + // is still awaiting the server. + useMessageQueueStore.getState().markSending(target, payload.queuedMessageId); try { await sendQueuedAutoSendPayload(sessionId, target.directory, payload, { @@ -271,6 +277,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? retryScheduler.schedule(nextAttemptAt); } finally { inFlightSessionsRef.current.delete(targetKey); + useMessageQueueStore.getState().clearSending(target, payload.queuedMessageId); } }; diff --git a/packages/ui/src/lib/addSelectionToChat.test.ts b/packages/ui/src/lib/addSelectionToChat.test.ts new file mode 100644 index 00000000..6acc225a --- /dev/null +++ b/packages/ui/src/lib/addSelectionToChat.test.ts @@ -0,0 +1,298 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +const focusChatInputCalls: number[] = []; +const pendingInputCalls: Array<{ text: string | null; mode?: string }> = []; +const activeMainTabCalls: string[] = []; +const sessionSwitcherCalls: boolean[] = []; +const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = []; + +type MockCodeMirrorView = { + state: { + selection: { main: { from: number; to: number } }; + sliceDoc: (from: number, to: number) => string; + }; + dispatch: (transaction: { selection: { anchor: number } }) => void; +}; + +let codeMirrorView: MockCodeMirrorView | null = null; + +mock.module('@codemirror/view', () => ({ + EditorView: { + findFromDOM: () => codeMirrorView, + }, +})); + +mock.module('@/components/chat/composer/editor/dom', () => ({ + focusChatInput: () => { + focusChatInputCalls.push(1); + }, +})); + +mock.module('@/sync/input-store', () => ({ + useInputStore: { + getState: () => ({ + setPendingInputText: (text: string | null, mode?: string) => { + pendingInputCalls.push({ text, mode }); + }, + }), + }, +})); + +mock.module('@/stores/useUIStore', () => ({ + useUIStore: { + getState: () => ({ + setActiveMainTab: (tab: string) => { + activeMainTabCalls.push(tab); + }, + setSessionSwitcherOpen: (open: boolean) => { + sessionSwitcherCalls.push(open); + }, + }), + }, +})); + +const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./addSelectionToChat'); + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +const installSelectionEnvironment = (options: { + activeElement?: Element | null; + focusedCodeMirror?: Element | null; + selection?: Selection | null; +} = {}) => { + const { + activeElement = null, + focusedCodeMirror = null, + selection = null, + } = options; + + const documentLike = { + activeElement, + querySelector: (selector: string) => { + if (selector === '.cm-editor.cm-focused') { + return focusedCodeMirror; + } + return null; + }, + }; + const windowLike = { + getSelection: () => selection, + }; + Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true }); + Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true }); +}; + +const clearCalls = () => { + focusChatInputCalls.length = 0; + pendingInputCalls.length = 0; + activeMainTabCalls.length = 0; + sessionSwitcherCalls.length = 0; + codeMirrorDispatches.length = 0; + codeMirrorView = null; +}; + +afterEach(() => { + Object.defineProperty(globalThis, 'document', { value: originalDocument, configurable: true }); + Object.defineProperty(globalThis, 'window', { value: originalWindow, configurable: true }); +}); + +describe('captureSelectionMarkdownForChat', () => { + beforeEach(() => { + clearCalls(); + }); + + test('returns null when nothing is selected', () => { + installSelectionEnvironment(); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); + + test('captures a textarea selection outside the composer and collapses it', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'alpha beta gamma', + selectionStart: 6, + selectionEnd: 10, + closest: () => null, + } as unknown as HTMLTextAreaElement; + + installSelectionEnvironment({ activeElement: textarea }); + expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```'); + expect(textarea.selectionStart).toBe(10); + expect(textarea.selectionEnd).toBe(10); + }); + + test('ignores selections inside the chat composer', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'draft text', + selectionStart: 0, + selectionEnd: 5, + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null), + } as unknown as HTMLTextAreaElement; + + installSelectionEnvironment({ activeElement: textarea }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); + + test('captures a focused CodeMirror selection outside the composer and collapses it', () => { + const focusedEditor = { + closest: () => null, + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 4, to: 11 } }, + sliceDoc: (from: number, to: number) => 'const x'.slice(0, to - from), + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + codeMirrorView!.state.selection.main = { + from: transaction.selection.anchor, + to: transaction.selection.anchor, + }; + }, + }; + + // sliceDoc should return the selected slice; use explicit text instead of slice math. + codeMirrorView.state.sliceDoc = () => 'const x'; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBe('```\nconst x\n```'); + expect(codeMirrorDispatches).toEqual([{ selection: { anchor: 11 } }]); + expect(codeMirrorView.state.selection.main).toEqual({ from: 11, to: 11 }); + }); + + test('ignores a focused CodeMirror editor inside the chat composer', () => { + const focusedEditor = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? focusedEditor : null), + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 0, to: 5 } }, + sliceDoc: () => 'draft', + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + }, + }; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + expect(codeMirrorDispatches).toEqual([]); + }); + + test('captures a DOM selection from chat-message content and clears it', () => { + const parent = { + closest: (selector: string) => (selector === 'pre code' ? null : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + let rangeCount = 1; + let collapsed = false; + const selection = { + get rangeCount() { + return rangeCount; + }, + get isCollapsed() { + return collapsed; + }, + toString: () => 'Hello world', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => { + rangeCount = 0; + collapsed = true; + }, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); + expect(captureSelectionMarkdownForChat()).toBe('```md\nHello world\n```'); + expect(selection.rangeCount).toBe(0); + expect(selection.isCollapsed).toBe(true); + }); + + test('ignores a DOM selection inside the chat composer', () => { + const composerHost = {}; + const parent = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? composerHost : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + const selection = { + rangeCount: 1, + isCollapsed: false, + toString: () => 'draft', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => undefined, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); +}); + +describe('addSelectionToChat', () => { + beforeEach(() => { + clearCalls(); + }); + + test('appends captured selection and focuses chat input', async () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'selected', + selectionStart: 0, + selectionEnd: 8, + closest: () => null, + } as unknown as HTMLTextAreaElement; + installSelectionEnvironment({ activeElement: textarea }); + + expect(addSelectionToChat()).toBe(true); + expect(activeMainTabCalls).toEqual(['chat']); + expect(sessionSwitcherCalls).toEqual([false]); + expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); + + await Promise.resolve(); + expect(focusChatInputCalls.length).toBe(1); + }); + + test('second capture after textarea collapse does not append again', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'selected', + selectionStart: 0, + selectionEnd: 8, + closest: () => null, + } as unknown as HTMLTextAreaElement; + installSelectionEnvironment({ activeElement: textarea }); + + expect(addSelectionToChat()).toBe(true); + expect(addSelectionToChat()).toBe(false); + expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); + }); + + test('focuses chat input when nothing is selected', async () => { + installSelectionEnvironment(); + + expect(addSelectionToChat()).toBe(false); + expect(pendingInputCalls).toEqual([]); + expect(activeMainTabCalls).toEqual(['chat']); + + await Promise.resolve(); + expect(focusChatInputCalls.length).toBe(1); + }); +}); diff --git a/packages/ui/src/lib/addSelectionToChat.ts b/packages/ui/src/lib/addSelectionToChat.ts new file mode 100644 index 00000000..ce296569 --- /dev/null +++ b/packages/ui/src/lib/addSelectionToChat.ts @@ -0,0 +1,166 @@ +import { EditorView } from '@codemirror/view'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { + formatCodeSelectionMarkdown, + rangeToMarkdown, + trimSelectionValue, + wrapMarkdownSelectionForChat, +} from '@/components/chat/message/selectionMarkdown'; +import { useInputStore } from '@/sync/input-store'; +import { useUIStore } from '@/stores/useUIStore'; + +const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]'; + +const isInsideChatComposer = (node: Node | null): boolean => { + if (!node) { + return false; + } + const asElement = node as Element; + const element = typeof asElement.closest === 'function' + ? asElement + : (node as Node).parentElement; + return Boolean(element?.closest(CHAT_INPUT_HOST_SELECTOR)); +}; + +const readTextControlSelection = (element: Element): string | null => { + if (isInsideChatComposer(element)) { + return null; + } + + const tag = element.tagName?.toLowerCase(); + if (tag === 'textarea') { + const control = element as HTMLTextAreaElement; + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + // Collapse so a duplicate menu delivery cannot append the same range twice. + control.selectionStart = end; + control.selectionEnd = end; + return text; + } + + if (tag === 'input') { + const control = element as HTMLInputElement; + const type = control.type?.toLowerCase() ?? 'text'; + if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) { + return null; + } + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + control.selectionStart = end; + control.selectionEnd = end; + return text; + } + + return null; +}; + +const captureActiveElementSelection = (): string | null => { + if (typeof document === 'undefined') { + return null; + } + + const activeElement = document.activeElement; + if (!activeElement || typeof (activeElement as Element).tagName !== 'string') { + return null; + } + + const text = readTextControlSelection(activeElement as Element); + return text ? wrapMarkdownSelectionForChat(text) : null; +}; + +const captureCodeMirrorSelection = (): string | null => { + if (typeof document === 'undefined') { + return null; + } + + const focusedEditor = document.querySelector('.cm-editor.cm-focused'); + if (!focusedEditor || isInsideChatComposer(focusedEditor)) { + return null; + } + + const view = EditorView.findFromDOM(focusedEditor); + if (!view) { + return null; + } + + const { from, to } = view.state.selection.main; + if (from === to) { + return null; + } + + const text = trimSelectionValue(view.state.sliceDoc(from, to)); + if (!text) { + return null; + } + + view.dispatch({ + selection: { anchor: to }, + }); + + return formatCodeSelectionMarkdown(text); +}; + +const captureDomSelection = (): string | null => { + if (typeof window === 'undefined') { + return null; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + + const range = selection.getRangeAt(0); + if (isInsideChatComposer(range.commonAncestorContainer)) { + return null; + } + + const plainText = trimSelectionValue(selection.toString()); + if (!plainText) { + return null; + } + + const markdown = rangeToMarkdown(range, plainText); + selection.removeAllRanges(); + return wrapMarkdownSelectionForChat(markdown); +}; + +/** + * Capture the current non-composer selection as chat-ready markdown. + * Returns null when nothing usable is selected. + */ +export const captureSelectionMarkdownForChat = (): string | null => { + return captureCodeMirrorSelection() + ?? captureActiveElementSelection() + ?? captureDomSelection(); +}; + +/** + * Append the current selection to the chat composer. + * When nothing is selected, focuses the chat input (Cursor-style Ctrl/Cmd+L). + * Returns true when selected text was appended. + */ +export const addSelectionToChat = (): boolean => { + const markdown = captureSelectionMarkdownForChat(); + + useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setSessionSwitcherOpen(false); + + if (markdown) { + useInputStore.getState().setPendingInputText(markdown, 'append'); + } + + queueMicrotask(() => { + focusChatInput(); + }); + + return markdown !== null; +}; diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index cece345d..a455d61c 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -441,7 +441,11 @@ export const debugUtils = { const sources = { attachment, worktreeMetadata, - authoritative: owningStoreDirectory ?? recordDirectory, + // Record first, matching the resolver: holding a session proves + // containment, not ownership, so the parent repository holds its + // worktrees' sessions too. Reporting membership first made this + // diagnostic contradict the routing it exists to explain. + authoritative: recordDirectory ?? owningStoreDirectory, selected, remembered: remembered.runtime, }; diff --git a/packages/ui/src/lib/desktop.test.ts b/packages/ui/src/lib/desktop.test.ts new file mode 100644 index 00000000..963e1500 --- /dev/null +++ b/packages/ui/src/lib/desktop.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; + +import { isBrowserClientRuntime } from './desktop'; + +describe('browser client runtime', () => { + test('uses browser file behavior only outside the Electron shell', () => { + expect(isBrowserClientRuntime('web', false)).toBe(true); + expect(isBrowserClientRuntime('web', true)).toBe(false); + }); + + test('keeps desktop and VS Code runtime behavior out of browser-only flows', () => { + expect(isBrowserClientRuntime('desktop', false)).toBe(false); + expect(isBrowserClientRuntime('vscode', false)).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 1ec9053d..a1614127 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,4 +1,4 @@ -import type { ProjectEntry, TerminalShell } from '@/lib/api/types'; +import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types'; import { getInjectedBootOutcome } from '@/lib/desktopBoot'; import type { DraftStarterRef } from '@/lib/draftStarters'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; @@ -562,6 +562,15 @@ export const isWebRuntime = (): boolean => { return !isVSCodeRuntime(); }; +/** + * Electron reuses the web RuntimeAPIs implementation, so distinguish a browser + * client from an Electron renderer with both the runtime descriptor and shell. + */ +export const isBrowserClientRuntime = ( + platform: RuntimeAPIs['runtime']['platform'], + desktopShell = isDesktopShell(), +): boolean => platform === 'web' && !desktopShell; + export const getDesktopHomeDirectory = async (): Promise => { if (typeof window !== 'undefined') { const embedded = window.__OPENCHAMBER_HOME__; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 7e7dd7ab..3eee82cc 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1050,6 +1050,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Einstellungen öffnen', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Terminal-Dock umschalten', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index f9d325b9..0db76c86 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -426,6 +426,11 @@ export const dict = { 'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung', 'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen', + 'sessions.sidebar.bulkActions.restore': 'Wiederherstellen', + 'sessions.sidebar.bulkActions.restoredSingle': '{count} Sitzung wiederhergestellt', + 'sessions.sidebar.bulkActions.restoredPlural': '{count} Sitzungen wiederhergestellt', + 'sessions.sidebar.bulkActions.failedRestoreSingle': 'Fehler beim Wiederherstellen von {count} Sitzung', + 'sessions.sidebar.bulkActions.failedRestorePlural': 'Fehler beim Wiederherstellen von {count} Sitzungen', 'sessions.sidebar.folders.none': 'Noch keine Ordner', 'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...', 'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen', @@ -517,6 +522,8 @@ export const dict = { 'sessions.sidebar.session.delete.error': 'Fehler beim Löschen der Sitzung', 'sessions.sidebar.session.archive.success': 'Sitzung archiviert', 'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung', + 'sessions.sidebar.session.restore.success': 'Sitzung wiederhergestellt', + 'sessions.sidebar.session.restore.error': 'Fehler beim Wiederherstellen der Sitzung', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} Checks bestanden', 'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen', 'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend', @@ -1085,6 +1092,7 @@ export const dict = { 'sidebarFilesTree.menu.rename': 'Umbenennen', 'sidebarFilesTree.menu.copyPath': 'Pfad kopieren', 'sidebarFilesTree.menu.save': 'Speichern', + 'sidebarFilesTree.menu.download': 'Herunterladen', 'sidebarFilesTree.menu.newFile': 'Neue Datei', 'sidebarFilesTree.menu.newFolder': 'Neuer Ordner', 'sidebarFilesTree.menu.delete': 'Löschen', @@ -1531,6 +1539,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen', 'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)', 'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten', + 'helpDialog.item.addSelectionToChat': 'Auswahl zum Chat hinzufügen', 'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)', 'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen', 'helpDialog.item.navigateModels': 'Modelle navigieren (in Auswahl)', @@ -2785,6 +2794,7 @@ export const dict = { 'sessions.archivePage.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen', 'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen', 'sessions.archivePage.deleteSessionAria': '{title} löschen', + 'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen', 'header.sessionActions.openAria': 'Sitzungsaktionen öffnen', 'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren', 'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 825e4f17..93b46ad8 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1115,6 +1115,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Open settings', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Toggle terminal dock', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 7b63e05e..17d137e5 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -449,6 +449,7 @@ export const dict = { 'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project', 'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}', 'sessions.archivePage.deleteSessionAria': 'Delete {title}', + 'sessions.archivePage.restoreSessionAria': 'Restore {title}', 'sessions.switcher.openAria': 'Open session switcher', 'sessions.switcher.empty': 'No recent sessions', 'sessions.switcher.draftTitle': 'New session', @@ -470,6 +471,11 @@ export const dict = { 'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session', 'sessions.sidebar.bulkActions.failedArchivePlural': 'Failed to archive {count} sessions', + 'sessions.sidebar.bulkActions.restore': 'Restore', + 'sessions.sidebar.bulkActions.restoredSingle': 'Restored {count} session', + 'sessions.sidebar.bulkActions.restoredPlural': 'Restored {count} sessions', + 'sessions.sidebar.bulkActions.failedRestoreSingle': 'Failed to restore {count} session', + 'sessions.sidebar.bulkActions.failedRestorePlural': 'Failed to restore {count} sessions', 'sessions.sidebar.folders.none': 'No folders yet', 'sessions.sidebar.folders.newFolderEllipsis': 'New folder...', 'sessions.sidebar.folders.removeFromFolder': 'Remove from folder', @@ -573,6 +579,8 @@ export const dict = { 'sessions.sidebar.session.delete.error': 'Failed to delete session', 'sessions.sidebar.session.archive.success': 'Session archived', 'sessions.sidebar.session.archive.error': 'Failed to archive session', + 'sessions.sidebar.session.restore.success': 'Session restored', + 'sessions.sidebar.session.restore.error': 'Failed to restore session', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} checks passed', 'sessions.sidebar.group.pr.failingCount': '{count} failing', 'sessions.sidebar.group.pr.pendingCount': '{count} pending', @@ -1225,6 +1233,7 @@ export const dict = { 'sidebarFilesTree.menu.rename': 'Rename', 'sidebarFilesTree.menu.copyPath': 'Copy Path', 'sidebarFilesTree.menu.save': 'Save', + 'sidebarFilesTree.menu.download': 'Download', 'sidebarFilesTree.menu.newFile': 'New File', 'sidebarFilesTree.menu.newFolder': 'New Folder', 'sidebarFilesTree.menu.delete': 'Delete', @@ -1678,6 +1687,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Open Command Palette', 'helpDialog.item.showKeyboardShortcuts': 'Show Keyboard Shortcuts (this dialog)', 'helpDialog.item.toggleSessionSidebar': 'Toggle Session Sidebar', + 'helpDialog.item.addSelectionToChat': 'Add Selection to Chat', 'helpDialog.item.cycleAgent': 'Cycle Agent (chat input)', 'helpDialog.item.openModelSelector': 'Open Model Selector', 'helpDialog.item.navigateModels': 'Navigate Models (in picker)', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 4fdbda7d..a34134f6 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1083,6 +1083,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configuración", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar u ocultar panel de terminal", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 08a3f727..ea5086d0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -450,6 +450,7 @@ export const dict: Record = { "sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto", "sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}", "sessions.archivePage.deleteSessionAria": "Eliminar {title}", + "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir selector de sesiones", "sessions.switcher.empty": "No hay sesiones recientes", "sessions.switcher.draftTitle": "Nueva sesión", @@ -471,6 +472,11 @@ export const dict: Record = { "sessions.sidebar.bulkActions.archivedPlural": "Se archivaron {count} sesiones", "sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión", "sessions.sidebar.bulkActions.failedArchivePlural": "No se pudo archivar {count} sesiones", + "sessions.sidebar.bulkActions.restore": "Restaurar", + "sessions.sidebar.bulkActions.restoredSingle": "Se restauró {count} sesión", + "sessions.sidebar.bulkActions.restoredPlural": "Se restauraron {count} sesiones", + "sessions.sidebar.bulkActions.failedRestoreSingle": "No se pudo restaurar {count} sesión", + "sessions.sidebar.bulkActions.failedRestorePlural": "No se pudo restaurar {count} sesiones", "sessions.sidebar.folders.none": "No hay carpetas aún", "sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...", "sessions.sidebar.folders.removeFromFolder": "Quitar de carpeta", @@ -574,6 +580,8 @@ export const dict: Record = { "sessions.sidebar.session.delete.error": "No se pudo eliminar la sesión", "sessions.sidebar.session.archive.success": "Sesión archivada", "sessions.sidebar.session.archive.error": "No se pudo archivar la sesión", + "sessions.sidebar.session.restore.success": "Sesión restaurada", + "sessions.sidebar.session.restore.error": "No se pudo restaurar la sesión", "sessions.sidebar.group.pr.checksPassed": "{success}/{total} comprobaciones aprobadas", "sessions.sidebar.group.pr.failingCount": "{count} con fallos", "sessions.sidebar.group.pr.pendingCount": "{count} pendientes", @@ -1191,6 +1199,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Cambiar nombre", "sidebarFilesTree.menu.copyPath": "Copiar ruta", "sidebarFilesTree.menu.save": "Guardar", + "sidebarFilesTree.menu.download": "Descargar", "sidebarFilesTree.menu.newFile": "Nuevo archivo", "sidebarFilesTree.menu.newFolder": "Nueva carpeta", "sidebarFilesTree.menu.delete": "Eliminar", @@ -1656,6 +1665,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.showKeyboardShortcuts": "Mostrar atajos de teclado (este diálogo)", "helpDialog.item.toggleSessionSidebar": "Mostrar u ocultar barra lateral de sesión", + "helpDialog.item.addSelectionToChat": "Agregar selección al chat", "helpDialog.item.cycleAgent": "Cambiar agente (entrada de chat)", "helpDialog.item.openModelSelector": "Abrir selector de modelos", "helpDialog.item.navigateModels": "Navegar modelos (en selector)", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 333ba9cd..3e7c2371 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1004,6 +1004,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Ouvrir les paramètres', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Basculer la station d\'accueil du terminal', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2f99e4f4..3a625450 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -285,6 +285,7 @@ export const dict = { 'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet', 'sessions.archivePage.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}', 'sessions.archivePage.deleteSessionAria': 'Supprimer {title}', + 'sessions.archivePage.restoreSessionAria': 'Restaurer {title}', 'sessions.switcher.openAria': 'Sélecteur de session ouvert', 'sessions.switcher.empty': 'Aucune session récente', 'sessions.switcher.draftTitle': 'Nouvelle session', @@ -306,6 +307,11 @@ export const dict = { 'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Échec de l\'archivage de la session {count}', 'sessions.sidebar.bulkActions.failedArchivePlural': 'Échec de l\'archivage des sessions {count}', + 'sessions.sidebar.bulkActions.restore': 'Restaurer', + 'sessions.sidebar.bulkActions.restoredSingle': 'Session {count} restaurée', + 'sessions.sidebar.bulkActions.restoredPlural': 'Sessions {count} restaurées', + 'sessions.sidebar.bulkActions.failedRestoreSingle': 'Échec de la restauration de la session {count}', + 'sessions.sidebar.bulkActions.failedRestorePlural': 'Échec de la restauration des sessions {count}', 'sessions.sidebar.folders.none': 'Aucun dossier pour l\'instant', 'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...', 'sessions.sidebar.folders.removeFromFolder': 'Supprimer du dossier', @@ -409,6 +415,8 @@ export const dict = { 'sessions.sidebar.session.delete.error': 'Échec de la suppression de la session', 'sessions.sidebar.session.archive.success': 'Session archivée', 'sessions.sidebar.session.archive.error': 'Échec de l\'archivage de la session', + 'sessions.sidebar.session.restore.success': 'Session restaurée', + 'sessions.sidebar.session.restore.error': 'Échec de la restauration de la session', 'sessions.sidebar.group.pr.checksPassed': 'Contrôles {success}/{total} réussis', 'sessions.sidebar.group.pr.failingCount': 'Échec de {count}', 'sessions.sidebar.group.pr.pendingCount': '{count} en attente', @@ -1047,6 +1055,7 @@ export const dict = { 'sidebarFilesTree.menu.rename': 'Rebaptiser', 'sidebarFilesTree.menu.copyPath': 'Copier le chemin', 'sidebarFilesTree.menu.save': 'Sauvegarder', + 'sidebarFilesTree.menu.download': 'Télécharger', 'sidebarFilesTree.menu.newFile': 'Nouveau fichier', 'sidebarFilesTree.menu.newFolder': 'Nouveau dossier', 'sidebarFilesTree.menu.delete': 'Supprimer', @@ -1491,6 +1500,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Ouvrir la palette de commandes', 'helpDialog.item.showKeyboardShortcuts': 'Afficher les raccourcis clavier (cette boîte de dialogue)', 'helpDialog.item.toggleSessionSidebar': 'Toggle la barre latérale de la session', + 'helpDialog.item.addSelectionToChat': 'Ajouter la sélection au chat', 'helpDialog.item.cycleAgent': 'Agent de cycle (entrée de chat)', 'helpDialog.item.openModelSelector': 'Ouvrir le sélecteur de modèle', 'helpDialog.item.navigateModels': 'Naviguer dans les modèles (dans le sélecteur)', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 31b7ce30..70dd4303 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1116,6 +1116,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '設定を開く', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index e7c7d111..181ec3e9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -450,6 +450,7 @@ export const dict: Record = { 'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteSessionAria': '{title} を削除', + 'sessions.archivePage.restoreSessionAria': '{title} を復元', 'sessions.switcher.openAria': 'セッションスイッチャーを開く', 'sessions.switcher.empty': '最近のセッションはありません', 'sessions.switcher.draftTitle': '新しいセッション', @@ -471,6 +472,11 @@ export const dict: Record = { 'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました', 'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました', 'sessions.sidebar.bulkActions.failedArchivePlural': '{count}セッションのアーカイブに失敗しました', + 'sessions.sidebar.bulkActions.restore': '復元', + 'sessions.sidebar.bulkActions.restoredSingle': '{count}セッションを復元しました', + 'sessions.sidebar.bulkActions.restoredPlural': '{count}セッションを復元しました', + 'sessions.sidebar.bulkActions.failedRestoreSingle': '{count}セッションの復元に失敗しました', + 'sessions.sidebar.bulkActions.failedRestorePlural': '{count}セッションの復元に失敗しました', 'sessions.sidebar.folders.none': 'まだフォルダがありません', 'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...', 'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除', @@ -574,6 +580,8 @@ export const dict: Record = { 'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました', 'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました', 'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました', + 'sessions.sidebar.session.restore.success': 'セッションを復元しました', + 'sessions.sidebar.session.restore.error': 'セッションの復元に失敗しました', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total}のチェックに合格', 'sessions.sidebar.group.pr.failingCount': '{count}件失敗', 'sessions.sidebar.group.pr.pendingCount': '{count}件保留中', @@ -1221,6 +1229,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '名前の変更', 'sidebarFilesTree.menu.copyPath': 'パスをコピー', 'sidebarFilesTree.menu.save': '保存', + 'sidebarFilesTree.menu.download': 'ダウンロード', 'sidebarFilesTree.menu.newFile': '新しいファイル', 'sidebarFilesTree.menu.newFolder': '新しいフォルダ', 'sidebarFilesTree.menu.delete': '削除', @@ -1674,6 +1683,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': 'コマンドパレットを開く', 'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)', 'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え', + 'helpDialog.item.addSelectionToChat': '選択範囲をチャットに追加', 'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)', 'helpDialog.item.openModelSelector': 'モデルセレクターを開く', 'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 32297cc5..1b7d9487 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1083,6 +1083,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '설정 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2983be..150a56ce 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -450,6 +450,7 @@ export const dict: Record = { 'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteSessionAria': '{title} 삭제', + 'sessions.archivePage.restoreSessionAria': '{title} 복원', 'sessions.switcher.openAria': '세션 전환기 열기', 'sessions.switcher.empty': '최근 세션 없음', 'sessions.switcher.draftTitle': '새 세션', @@ -471,6 +472,11 @@ export const dict: Record = { 'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨', 'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패', 'sessions.sidebar.bulkActions.failedArchivePlural': '세션 {count}개 보관 실패', + 'sessions.sidebar.bulkActions.restore': '복원', + 'sessions.sidebar.bulkActions.restoredSingle': '세션 {count}개 복원됨', + 'sessions.sidebar.bulkActions.restoredPlural': '세션 {count}개 복원됨', + 'sessions.sidebar.bulkActions.failedRestoreSingle': '세션 {count}개 복원 실패', + 'sessions.sidebar.bulkActions.failedRestorePlural': '세션 {count}개 복원 실패', 'sessions.sidebar.folders.none': '아직 폴더 없음', 'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…', 'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거', @@ -574,6 +580,8 @@ export const dict: Record = { 'sessions.sidebar.session.delete.error': '세션 삭제 실패', 'sessions.sidebar.session.archive.success': '세션 보관됨', 'sessions.sidebar.session.archive.error': '세션 보관 실패', + 'sessions.sidebar.session.restore.success': '세션 복원됨', + 'sessions.sidebar.session.restore.error': '세션 복원 실패', 'sessions.sidebar.group.pr.checksPassed': '검사 통과: {success}/{total}', 'sessions.sidebar.group.pr.failingCount': '실패 {count}개', 'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중', @@ -1228,6 +1236,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '이름 변경', 'sidebarFilesTree.menu.copyPath': '경로 복사', 'sidebarFilesTree.menu.save': '저장', + 'sidebarFilesTree.menu.download': '다운로드', 'sidebarFilesTree.menu.newFile': '새 파일', 'sidebarFilesTree.menu.newFolder': '새 폴더', 'sidebarFilesTree.menu.delete': '삭제', @@ -1680,6 +1689,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '명령 팔레트 열기', 'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)', 'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바', + 'helpDialog.item.addSelectionToChat': '선택 내용을 채팅에 추가', 'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)', 'helpDialog.item.openModelSelector': '모델 선택기 열기', 'helpDialog.item.navigateModels': '모델 이동(선택기)', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1187698d..a7d3114a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -825,6 +825,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Przełącz rozszerzony terminal', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f4129c47..8f93e1f6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -266,6 +266,7 @@ export const dict: Record = { 'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu', 'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}', 'sessions.archivePage.deleteSessionAria': 'Usuń {title}', + 'sessions.archivePage.restoreSessionAria': 'Przywróć {title}', 'sessions.switcher.openAria': 'Otwórz przełącznik sesji', 'sessions.switcher.empty': 'Brak ostatnich sesji', 'sessions.switcher.draftTitle': 'Nowa sesja', @@ -333,6 +334,11 @@ export const dict: Record = { 'sessions.sidebar.bulkActions.archivedPlural': 'Zarchiwizowano {count} sesji', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Nie udało się zarchiwizować {count} sesji', 'sessions.sidebar.bulkActions.failedArchivePlural': 'Nie udało się zarchiwizować {count} sesji', + 'sessions.sidebar.bulkActions.restore': 'Przywróć', + 'sessions.sidebar.bulkActions.restoredSingle': 'Przywrócono {count} sesję', + 'sessions.sidebar.bulkActions.restoredPlural': 'Przywrócono {count} sesji', + 'sessions.sidebar.bulkActions.failedRestoreSingle': 'Nie udało się przywrócić {count} sesji', + 'sessions.sidebar.bulkActions.failedRestorePlural': 'Nie udało się przywrócić {count} sesji', 'sessions.scheduledTasks.dialog.title': 'Zaplanowane zadania', 'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.', 'sessions.scheduledTasks.dialog.project.label': 'Projekt', @@ -574,6 +580,8 @@ export const dict: Record = { 'sessions.sidebar.session.delete.error': 'Nie udało się usunąć sesji', 'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana', 'sessions.sidebar.session.archive.error': 'Nie udało się zarchiwizować sesji', + 'sessions.sidebar.session.restore.success': 'Sesja przywrócona', + 'sessions.sidebar.session.restore.error': 'Nie udało się przywrócić sesji', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} testów przeszło', 'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń', 'sessions.sidebar.group.pr.pendingCount': '{count} oczekujących', @@ -2325,6 +2333,7 @@ export const dict: Record = { 'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu', 'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług', 'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji', + 'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu', 'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal', 'helpDialog.item.toggleTerminalExpanded': 'Przełącz rozszerzenie terminala', 'helpDialog.keyCombiner.or': 'lub', @@ -2708,6 +2717,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.newFolder': 'Nowy folder', 'sidebarFilesTree.menu.rename': 'Zmień nazwę', 'sidebarFilesTree.menu.save': 'Zapisz', + 'sidebarFilesTree.menu.download': 'Pobierz', 'sidebarFilesTree.search.clearAria': 'Wyczyść wyszukiwanie', 'sidebarFilesTree.search.placeholder': 'Szukaj plików...', 'sidebarFilesTree.state.loading': 'Ładowanie...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 76ff416f..1e6dd60e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1083,6 +1083,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configurações", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar ou ocultar painel de terminal", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a9c3bca9..44fef67a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -450,6 +450,7 @@ export const dict: Record = { "sessions.archivePage.deleteProject": "Excluir todas as sessões arquivadas deste projeto", "sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}", "sessions.archivePage.deleteSessionAria": "Excluir {title}", + "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir seletor de sessões", "sessions.switcher.empty": "Nenhuma sessão recente", "sessions.switcher.draftTitle": "Nova sessão", @@ -471,6 +472,11 @@ export const dict: Record = { "sessions.sidebar.bulkActions.archivedPlural": "{count} sessões arquivadas", "sessions.sidebar.bulkActions.failedArchiveSingle": "Não foi possível arquivar {count} sessão", "sessions.sidebar.bulkActions.failedArchivePlural": "Não foi possível arquivar {count} sessões", + "sessions.sidebar.bulkActions.restore": "Restaurar", + "sessions.sidebar.bulkActions.restoredSingle": "{count} sessão restaurada", + "sessions.sidebar.bulkActions.restoredPlural": "{count} sessões restauradas", + "sessions.sidebar.bulkActions.failedRestoreSingle": "Não foi possível restaurar {count} sessão", + "sessions.sidebar.bulkActions.failedRestorePlural": "Não foi possível restaurar {count} sessões", "sessions.sidebar.folders.none": "Não há pastas ainda", "sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...", "sessions.sidebar.folders.removeFromFolder": "Remover da pasta", @@ -574,6 +580,8 @@ export const dict: Record = { "sessions.sidebar.session.delete.error": "Não foi possível excluir a sessão", "sessions.sidebar.session.archive.success": "Sessão archivada", "sessions.sidebar.session.archive.error": "Não foi possível arquivar a sessão", + "sessions.sidebar.session.restore.success": "Sessão restaurada", + "sessions.sidebar.session.restore.error": "Não foi possível restaurar a sessão", "sessions.sidebar.group.pr.checksPassed": "{success}/{total} checks pasadas", "sessions.sidebar.group.pr.failingCount": "{count} com fallos", "sessions.sidebar.group.pr.pendingCount": "{count} pendentes", @@ -1191,6 +1199,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Renomear", "sidebarFilesTree.menu.copyPath": "Copiar caminho", "sidebarFilesTree.menu.save": "Salvar", + "sidebarFilesTree.menu.download": "Baixar", "sidebarFilesTree.menu.newFile": "Novo arquivo", "sidebarFilesTree.menu.newFolder": "Nova pasta", "sidebarFilesTree.menu.delete": "Excluir", @@ -1656,6 +1665,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.showKeyboardShortcuts": "Mostrar atalhos de teclado (este diálogo)", "helpDialog.item.toggleSessionSidebar": "Mostrar ou ocultar barra lateral de sessão", + "helpDialog.item.addSelectionToChat": "Adicionar seleção ao chat", "helpDialog.item.cycleAgent": "Alternar agente (entrada do chat)", "helpDialog.item.openModelSelector": "Abrir seletor de modelos", "helpDialog.item.navigateModels": "Navegar por modelos (no seletor)", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d56cce54..75e31d9e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1083,6 +1083,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Відкрити налаштування", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c1c3d7cc..c8663cea 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -450,6 +450,7 @@ export const dict: Record = { "sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту", "sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}", "sessions.archivePage.deleteSessionAria": "Видалити {title}", + "sessions.archivePage.restoreSessionAria": "Відновити {title}", "sessions.switcher.openAria": "Відкрити перемикач сесій", "sessions.switcher.empty": "Немає недавніх сесій", "sessions.switcher.draftTitle": "Нова сесія", @@ -471,6 +472,11 @@ export const dict: Record = { "sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}", "sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}", "sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}", + "sessions.sidebar.bulkActions.restore": "Відновити", + "sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}", + "sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}", + "sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}", + "sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}", "sessions.sidebar.folders.none": "Папок ще немає", "sessions.sidebar.folders.newFolderEllipsis": "Нова папка...", "sessions.sidebar.folders.removeFromFolder": "Видалити з папки", @@ -574,6 +580,8 @@ export const dict: Record = { "sessions.sidebar.session.delete.error": "Не вдалося видалити сесію", "sessions.sidebar.session.archive.success": "Сесію заархівовано", "sessions.sidebar.session.archive.error": "Не вдалося заархівувати сесію", + "sessions.sidebar.session.restore.success": "Сесію відновлено", + "sessions.sidebar.session.restore.error": "Не вдалося відновити сесію", "sessions.sidebar.group.pr.checksPassed": "Перевірки {success}/{total} пройдено", "sessions.sidebar.group.pr.failingCount": "{count} з помилкою", "sessions.sidebar.group.pr.pendingCount": "{count} очікує", @@ -1191,6 +1199,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Перейменувати", "sidebarFilesTree.menu.copyPath": "Копіювати шлях", "sidebarFilesTree.menu.save": "Зберегти", + "sidebarFilesTree.menu.download": "Завантажити", "sidebarFilesTree.menu.newFile": "Новий файл", "sidebarFilesTree.menu.newFolder": "Нова папка", "sidebarFilesTree.menu.delete": "Видалити", @@ -1656,6 +1665,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Відкрити палітру команд", "helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)", "helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій", + "helpDialog.item.addSelectionToChat": "Додати виділення в чат", "helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)", "helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі", "helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 3ac73b6f..8087fa9b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1083,6 +1083,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '打开设置', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9fef7f48..76ffac2f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -450,6 +450,7 @@ export const dict: Record = { 'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话', 'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话', 'sessions.archivePage.deleteSessionAria': '删除 {title}', + 'sessions.archivePage.restoreSessionAria': '还原 {title}', 'sessions.switcher.openAria': '打开会话切换器', 'sessions.switcher.empty': '没有最近会话', 'sessions.switcher.draftTitle': '新会话', @@ -471,6 +472,11 @@ export const dict: Record = { 'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话', 'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败', 'sessions.sidebar.bulkActions.failedArchivePlural': '归档 {count} 个会话失败', + 'sessions.sidebar.bulkActions.restore': '还原', + 'sessions.sidebar.bulkActions.restoredSingle': '已还原 {count} 个会话', + 'sessions.sidebar.bulkActions.restoredPlural': '已还原 {count} 个会话', + 'sessions.sidebar.bulkActions.failedRestoreSingle': '还原 {count} 个会话失败', + 'sessions.sidebar.bulkActions.failedRestorePlural': '还原 {count} 个会话失败', 'sessions.sidebar.folders.none': '暂无文件夹', 'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...', 'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除', @@ -574,6 +580,8 @@ export const dict: Record = { 'sessions.sidebar.session.delete.error': '删除会话失败', 'sessions.sidebar.session.archive.success': '会话已归档', 'sessions.sidebar.session.archive.error': '归档会话失败', + 'sessions.sidebar.session.restore.success': '会话已还原', + 'sessions.sidebar.session.restore.error': '还原会话失败', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 项检查已通过', 'sessions.sidebar.group.pr.failingCount': '{count} 项失败', 'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中', @@ -1191,6 +1199,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '重命名', 'sidebarFilesTree.menu.copyPath': '复制路径', 'sidebarFilesTree.menu.save': '保存', + 'sidebarFilesTree.menu.download': '下载', 'sidebarFilesTree.menu.newFile': '新建文件', 'sidebarFilesTree.menu.newFolder': '新建文件夹', 'sidebarFilesTree.menu.delete': '删除', @@ -1644,6 +1653,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '打开命令面板', 'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)', 'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏', + 'helpDialog.item.addSelectionToChat': '将选中内容添加到聊天', 'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)', 'helpDialog.item.openModelSelector': '打开模型选择器', 'helpDialog.item.navigateModels': '导航模型(选择器中)', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a4302cfa..a6926703 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -990,6 +990,7 @@ 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '開啟設定', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2615292a..3538d59c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -463,6 +463,7 @@ export const dict: Record = { 'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段', 'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段', 'sessions.archivePage.deleteSessionAria': '刪除 {title}', + 'sessions.archivePage.restoreSessionAria': '還原 {title}', 'sessions.switcher.openAria': '開啟會話切換器', 'sessions.switcher.empty': '沒有最近會話', 'sessions.switcher.draftTitle': '新會話', @@ -484,6 +485,11 @@ export const dict: Record = { 'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話', 'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗', 'sessions.sidebar.bulkActions.failedArchivePlural': '封存 {count} 個會話失敗', + 'sessions.sidebar.bulkActions.restore': '還原', + 'sessions.sidebar.bulkActions.restoredSingle': '已還原 {count} 個會話', + 'sessions.sidebar.bulkActions.restoredPlural': '已還原 {count} 個會話', + 'sessions.sidebar.bulkActions.failedRestoreSingle': '還原 {count} 個會話失敗', + 'sessions.sidebar.bulkActions.failedRestorePlural': '還原 {count} 個會話失敗', 'sessions.sidebar.folders.none': '暫無資料夾', 'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...', 'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除', @@ -587,6 +593,8 @@ export const dict: Record = { 'sessions.sidebar.session.delete.error': '刪除會話失敗', 'sessions.sidebar.session.archive.success': '會話已封存', 'sessions.sidebar.session.archive.error': '封存會話失敗', + 'sessions.sidebar.session.restore.success': '會話已還原', + 'sessions.sidebar.session.restore.error': '還原會話失敗', 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 項檢查已通過', 'sessions.sidebar.group.pr.failingCount': '{count} 項失敗', 'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中', @@ -1203,6 +1211,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '重新命名', 'sidebarFilesTree.menu.copyPath': '複製路徑', 'sidebarFilesTree.menu.save': '儲存', + 'sidebarFilesTree.menu.download': '下載', 'sidebarFilesTree.menu.newFile': '新增檔案', 'sidebarFilesTree.menu.newFolder': '新增資料夾', 'sidebarFilesTree.menu.delete': '刪除', @@ -1648,6 +1657,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '開啟命令面板', 'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)', 'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄', + 'helpDialog.item.addSelectionToChat': '將選取內容加入聊天', 'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)', 'helpDialog.item.openModelSelector': '開啟模型選擇器', 'helpDialog.item.navigateModels': '導覽模型(選擇器中)', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 3e8ab753..7b618f4a 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -12,6 +12,7 @@ import type { TextPartInput, FilePartInput, } from "@opencode-ai/sdk/v2"; +import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error"; import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; @@ -878,7 +879,13 @@ class OpencodeService { // failure) — there is no HTTP response to report. Never fabricate a // status: surface it as a transport error so callers treat it like // any other network failure instead of a server 500. - throw new Error(`Message send transport failure: ${formatSdkError(result.error)}`); + // Preserve the transport's "dispatched, outcome unknown" tag through + // the wrap: without it the caller cannot tell a lost response from a + // send that never reached the server, and re-sends a running prompt. + const transportError = new Error(`Message send transport failure: ${formatSdkError(result.error)}`); + throw isAmbiguousTransportFailure(result.error) + ? markAmbiguousTransportFailure(transportError) + : transportError; } response = new Response(JSON.stringify(result.error), { status }); } else { diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 7320ba06..5d1798c3 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -22,5 +22,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'wafer', name: 'Wafer.ai' }, { id: 'opencode-go', name: 'OpenCode Go' }, { id: 'crof', name: 'CrofAI' }, + { id: 'deepseek', name: 'DeepSeek' }, { id: 'neuralwatt', name: 'NeuralWatt' }, ]; diff --git a/packages/ui/src/lib/relay/transport-error.ts b/packages/ui/src/lib/relay/transport-error.ts new file mode 100644 index 00000000..8d3c1159 --- /dev/null +++ b/packages/ui/src/lib/relay/transport-error.ts @@ -0,0 +1,42 @@ +/** + * Ambiguous transport failures. + * + * When a request dies after it was already handed to the transport, the client + * knows the response was lost — it does NOT know whether the server processed + * the request. Over the relay tunnel this is the common case: a reconnect, a + * host-side stream abort, or a dead channel all fail an in-flight POST that may + * already be running server-side. + * + * Callers must be able to tell that state apart from a definite failure, and + * string-matching the message text is not a contract — a renamed abort reason + * silently reclassifies a send. Transports therefore tag these errors, and + * callers read the tag (see `isAmbiguousTransportFailure`). + * + * `prompt_async` is the motivating case: treating an ambiguous failure as a + * definite one rolls back the user message and lets the queue re-send a prompt + * the engine is already answering, producing two independent AI responses. + */ + +const AMBIGUOUS_TRANSPORT_FLAG = '__openchamberAmbiguousTransport'; + +/** + * Mark an error as "dispatched, outcome unknown". Returns the same error so it + * can be thrown inline. + */ +export const markAmbiguousTransportFailure = (error: T): T => { + Object.defineProperty(error, AMBIGUOUS_TRANSPORT_FLAG, { + value: true, + enumerable: false, + configurable: true, + }); + return error; +}; + +/** + * True when a transport tagged this error as dispatched-but-unconfirmed. + * Deliberately tag-only: text heuristics belong to the caller that owns them. + */ +export const isAmbiguousTransportFailure = (error: unknown): boolean => { + if (!error || typeof error !== 'object') return false; + return (error as Record)[AMBIGUOUS_TRANSPORT_FLAG] === true; +}; diff --git a/packages/ui/src/lib/relay/tunnel-client.test.ts b/packages/ui/src/lib/relay/tunnel-client.test.ts index 0fd903b8..7972b342 100644 --- a/packages/ui/src/lib/relay/tunnel-client.test.ts +++ b/packages/ui/src/lib/relay/tunnel-client.test.ts @@ -11,6 +11,7 @@ import { } from './crypto'; import { createHostHandshake } from './handshake'; import { TunnelFrameType } from './protocol'; +import { isAmbiguousTransportFailure } from './transport-error'; import { createFragmentAssembler, decodeFrameBatch, @@ -339,6 +340,24 @@ describe('createRelayTunnelClient', () => { await expect(reader.read()).rejects.toThrow(); }); + // A POST that dies after dispatch may already have been processed by the + // server. Callers must be able to tell that apart from a definite failure — + // a prompt re-sent on this error produces a second AI response (#2425). + test('tags an in-flight request killed by reconnect as an ambiguous failure', async () => { + const { client, killWire } = await setupClient({ silent: true }); + track(client); + const pending = client.fetch('/api/session/s1/prompt_async', { method: 'POST', body: '{}' }); + let caught: unknown = null; + const settled = pending.catch((error: unknown) => { + caught = error; + }); + await wait(20); + killWire(); + await settled; + expect(caught).toBeInstanceOf(Error); + expect(isAmbiguousTransportFailure(caught)).toBe(true); + }); + test('opens, echoes, and closes a tunneled WebSocket', async () => { const { client } = await setupClient(); track(client); diff --git a/packages/ui/src/lib/relay/tunnel-client.ts b/packages/ui/src/lib/relay/tunnel-client.ts index ab24d78f..fd12b3cb 100644 --- a/packages/ui/src/lib/relay/tunnel-client.ts +++ b/packages/ui/src/lib/relay/tunnel-client.ts @@ -35,6 +35,7 @@ import { isWsClosePayload, normalizeTunnelRequest, } from './tunnel-payloads'; +import { markAmbiguousTransportFailure } from './transport-error'; const EMPTY_PAYLOAD = new Uint8Array(0); const textEncoder = new TextEncoder(); @@ -721,6 +722,13 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela } }; + // The request head is written to the channel below before any of these + // failures can fire, so losing the stream never proves the server did + // not process the request — only that the response was lost. Callers + // that would otherwise retry (prompt sends) must see that distinction. + const dispatchedFailure = (message: string): Error => + markAmbiguousTransportFailure(new Error(message)); + onAbort = () => { sendAbort('aborted'); finishError(abortError()); @@ -735,7 +743,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela head = decodeJsonPayload(payload, isHttpResponsePayload); } catch (error) { sendAbort('malformed response head'); - finishError(toError(error)); + finishError(dispatchedFailure(toError(error).message)); return; } const nullBody = head.status === 204 || head.status === 205 || head.status === 304; @@ -773,7 +781,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela if (frameType === TunnelFrameType.StreamEnd) { if (finished) return; if (!responseDelivered) { - finishError(new Error('tunnel stream ended before response head')); + finishError(dispatchedFailure('tunnel stream ended before response head')); return; } finished = true; @@ -792,11 +800,15 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela } catch { // Keep the generic reason. } - finishError(new Error(reason)); + finishError(dispatchedFailure(reason)); } }, fail(error) { - finishError(error); + // Channel death (reconnect, keepalive timeout) with this stream still + // open — same rule as above: dispatched, outcome unknown. A fresh + // error is tagged instead of the shared one so the tag cannot leak to + // waiters whose request never reached the wire. + finishError(dispatchedFailure(error.message)); }, }); @@ -824,7 +836,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela } } catch (error) { sendAbort('request body failed'); - finishError(toError(error)); + finishError(dispatchedFailure(toError(error).message)); } })(); }); diff --git a/packages/ui/src/lib/runtime-switch.runtime-key.test.ts b/packages/ui/src/lib/runtime-switch.runtime-key.test.ts new file mode 100644 index 00000000..9808b088 --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.runtime-key.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { getRuntimeKey } from './runtime-switch'; + +/** + * `getRuntimeKey` runs on store, event, and render paths, so its cost is + * multiplied by everything the UI does. These tests pin both directions of the + * derived-key cache: repeated calls with unchanged inputs must do no work, and + * any change to the inputs it derives from must still be observed. + * + * This lives in its own file because the cache is only reachable while the + * runtime endpoint has not been explicitly initialised, and module state is + * shared across tests within a file. + */ + +type RuntimeWindow = typeof globalThis & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_LOCAL_ORIGIN__?: string; +}; + +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +const NativeURL = globalThis.URL; +let urlConstructions = 0; + +const setRuntimeWindow = (apiBaseUrl: string | undefined, localOrigin: string | undefined): void => { + const runtimeWindow = {} as RuntimeWindow; + if (apiBaseUrl !== undefined) runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; + if (localOrigin !== undefined) runtimeWindow.__OPENCHAMBER_LOCAL_ORIGIN__ = localOrigin; + Object.defineProperty(globalThis, 'window', { value: runtimeWindow, configurable: true, writable: true }); +}; + +beforeEach(() => { + urlConstructions = 0; + class CountingURL extends NativeURL { + constructor(url: string | URL, base?: string | URL) { + urlConstructions += 1; + super(url, base); + } + } + globalThis.URL = CountingURL as unknown as typeof URL; +}); + +afterEach(() => { + globalThis.URL = NativeURL; + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); +}); + +describe('getRuntimeKey caching', () => { + test('resolves a same-origin endpoint to the local runtime key', () => { + setRuntimeWindow('https://app.example.com/api', 'https://app.example.com'); + expect(getRuntimeKey()).toBe('local'); + }); + + test('performs no URL work on repeated calls with unchanged inputs', () => { + setRuntimeWindow('https://remote.example.com', 'https://app.example.com'); + const first = getRuntimeKey(); + expect(first).toBe('url:https://remote.example.com'); + + urlConstructions = 0; + for (let index = 0; index < 50; index += 1) expect(getRuntimeKey()).toBe(first); + expect(urlConstructions).toBe(0); + }); + + test('recomputes when the injected API base URL changes at runtime', () => { + setRuntimeWindow('https://first.example.com', 'https://app.example.com'); + expect(getRuntimeKey()).toBe('url:https://first.example.com'); + + (globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_API_BASE_URL__ = 'https://second.example.com'; + expect(getRuntimeKey()).toBe('url:https://second.example.com'); + }); + + test('recomputes when the injected local origin changes at runtime', () => { + setRuntimeWindow('https://app.example.com', 'https://other.example.com'); + expect(getRuntimeKey()).toBe('url:https://app.example.com'); + + (globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_LOCAL_ORIGIN__ = 'https://app.example.com'; + expect(getRuntimeKey()).toBe('local'); + }); +}); diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 23ed5e16..babc4a1a 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -76,11 +76,52 @@ const sameOrigin = (left: string, right: string): boolean => { }; export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl(); + +// `getRuntimeKey` keys caches, stores, and persisted state across the whole UI, +// so it runs on store reads, event handling, and render paths. Before the +// runtime endpoint is explicitly initialised, every call re-derived the key by +// trimming two injected globals and constructing three `URL` objects, which +// made this one of the most expensive functions during streaming. +// +// The result depends only on `activeApiBaseUrl` and the two injected globals, +// and `switchRuntimeEndpoint` writes the injected API base URL at runtime, so +// the cache is validated against the raw, untrimmed values. That comparison +// allocates nothing and still recomputes the moment any input changes. +let cachedRuntimeKey = ''; +let cachedActiveApiBaseUrl: string | null = null; +let cachedRawApiBaseUrl: string | undefined; +let cachedRawLocalOrigin: string | undefined; + +const readRawRuntimeGlobal = (key: '__OPENCHAMBER_API_BASE_URL__' | '__OPENCHAMBER_LOCAL_ORIGIN__'): string | undefined => { + if (typeof window === 'undefined') return undefined; + const value = (window as typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_LOCAL_ORIGIN__?: string; + })[key]; + return typeof value === 'string' ? value : undefined; +}; + export const getRuntimeKey = (): string => { if (activeRuntimeKey) return activeRuntimeKey; + + const rawApiBaseUrl = readRawRuntimeGlobal('__OPENCHAMBER_API_BASE_URL__'); + const rawLocalOrigin = readRawRuntimeGlobal('__OPENCHAMBER_LOCAL_ORIGIN__'); + if ( + cachedActiveApiBaseUrl === activeApiBaseUrl + && cachedRawApiBaseUrl === rawApiBaseUrl + && cachedRawLocalOrigin === rawLocalOrigin + ) { + return cachedRuntimeKey; + } + const apiBaseUrl = getRuntimeApiBaseUrl(); - if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local'; - return normalizeRuntimeUrlKey(apiBaseUrl); + cachedRuntimeKey = sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) + ? 'local' + : normalizeRuntimeUrlKey(apiBaseUrl); + cachedActiveApiBaseUrl = activeApiBaseUrl; + cachedRawApiBaseUrl = rawApiBaseUrl; + cachedRawLocalOrigin = rawLocalOrigin; + return cachedRuntimeKey; }; export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => { diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index e6e16e29..6afb4656 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -159,8 +159,15 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Toggle the files panel', }, { - id: 'toggle_sidebar', + id: 'add_selection_to_chat', defaultCombo: 'mod+l', + label: 'Add selection to chat', + description: 'Add the selected text to the chat input', + customizable: true, + }, + { + id: 'toggle_sidebar', + defaultCombo: 'mod+alt+l', label: 'Toggle sidebar', description: 'Toggle the session sidebar', customizable: true, diff --git a/packages/ui/src/lib/theme/themes/openchamber-dark.json b/packages/ui/src/lib/theme/themes/openchamber-dark.json index 3e8be4e0..22d083d3 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-dark.json +++ b/packages/ui/src/lib/theme/themes/openchamber-dark.json @@ -24,12 +24,12 @@ "emphasis": "#fd9b66" }, "surface": { - "background": "#0c0b0a", - "foreground": "#dbd7ca", - "muted": "#131211", + "background": "#120f0e", + "foreground": "#c9c5ba", + "muted": "#171615", "mutedForeground": "#8f8b81", "elevated": "#181715", - "elevatedForeground": "#dbd7ca", + "elevatedForeground": "#c9c5ba", "overlay": "#00000099", "subtle": "#171616" }, @@ -37,11 +37,11 @@ "border": "#242323", "borderHover": "#504e4c", "borderFocus": "#da7c47", - "selection": "#da7c472b", - "selectionForeground": "#dbd7ca", + "selection": "#b9a5992b", + "selectionForeground": "#c9c5ba", "focus": "#da7c47", "focusRing": "#da7c4755", - "cursor": "#dbd7ca", + "cursor": "#c9c5ba", "hover": "#ffffff12", "active": "#ffffff1f" }, @@ -72,8 +72,8 @@ }, "syntax": { "base": { - "background": "#131211", - "foreground": "#dbd7ca", + "background": "#120f0e", + "foreground": "#c9c5ba", "comment": "#728772", "keyword": "#34983a", "string": "#d58373", @@ -127,14 +127,14 @@ "diffModified": "#5d99a9", "diffModifiedBackground": "#5d99a920", "lineNumber": "#3c3a37", - "lineNumberActive": "#dbd7ca" + "lineNumberActive": "#c9c5ba" } }, "markdown": { - "heading1": "#dbd7ca", - "heading2": "#dbd7ca", - "heading3": "#dbd7ca", - "heading4": "#dbd7ca", + "heading1": "#c9c5ba", + "heading2": "#c9c5ba", + "heading3": "#c9c5ba", + "heading4": "#c9c5ba", "link": "#5d99a9", "linkHover": "#6ba7b8", "inlineCode": "#76ad4f", @@ -144,19 +144,19 @@ "listMarker": "#4d934e99" }, "chat": { - "userMessage": "#dbd7ca", + "userMessage": "#c9c5ba", "userMessageBackground": "#25170e", - "assistantMessage": "#dbd7ca", - "assistantMessageBackground": "#0c0b0a", + "assistantMessage": "#c9c5ba", + "assistantMessageBackground": "#120f0e", "timestamp": "#8f8b81", "divider": "#302e2b" }, "tools": { - "background": "#13121150", + "background": "#120f0e50", "border": "#302e2b99", "headerHover": "#ffffff0d", "icon": "#ada9a0", - "title": "#dbd7ca", + "title": "#c9c5ba", "description": "#aba9a3", "edit": { "added": "#4d934e", diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index bca5d525..a578d61f 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -47,17 +47,20 @@ Examples: - `useProjectsStore.ts` - `useGlobalSessionsStore.ts` - `useSessionFoldersStore.ts` +- `messageQueueStore.ts` These stores coordinate persistent project/session metadata across multiple views. +`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. + `useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages. User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`. Global refresh rules: -- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page. -- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory. +- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. The global cache therefore loads with one inclusive request (`archived: true`) and splits active/archived client-side via `splitGlobalSessionsByArchived` — an `archived: false` request cannot be truthful because the server filter excludes restored sessions (`time.archived` falsy-but-present, see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`). For callers that still want only archived records, `listGlobalSessionPages` narrows inclusive responses at the data boundary (default `narrowToArchived`), so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page. +- Per-directory refresh issues one inclusive request per directory (previously two), bounded to two requests across callers and prioritizing the current directory. - Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally. - Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions. - Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit. diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 1319568d..5001bd40 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { OpencodeClient } from '@opencode-ai/sdk/v2' -import { listGlobalSessionPages } from './globalSessions' +import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions' describe('listGlobalSessionPages', () => { test('sanitizes session list records before returning them', async () => { @@ -138,6 +138,27 @@ describe('listGlobalSessionPages', () => { expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2']) }) + test('returns the inclusive response unfiltered when narrowing is disabled', async () => { + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [ + { id: 'ses_active', time: { created: 1, updated: 20 } }, + { id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } }, + { id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, narrowToArchived: false, pageSize: 500 }) + + expect(sessions.map((session) => session.id)).toEqual(['ses_active', 'ses_archived', 'ses_restored']) + }) + test('keeps paginating archived pages that are full of non-archived records', async () => { const calls: Array> = [] const apiClient = { @@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => { expect(sessions.map((session) => session.id)).toEqual(['ses_1']) }) }) + +describe('splitGlobalSessionsByArchived', () => { + test('classifies restored (falsy archived) records as active', () => { + const { active, archived } = splitGlobalSessionsByArchived([ + { id: 'ses_active', time: { created: 1, updated: 20 } }, + { id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } }, + { id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } }, + ] as unknown as Parameters[0]) + + expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored']) + expect(archived.map((session) => session.id)).toEqual(['ses_archived']) + }) +}) diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index f27a825e..da8ed9ea 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -84,11 +84,38 @@ const unwrapSessionList = ( */ const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived); +/** + * Split an inclusive (`archived: true`) session page stream into active and + * archived buckets. Restored sessions carry `time.archived === 0` (see + * `UNARCHIVED_TIMESTAMP` in `sync/session-actions.ts`); the truthiness check + * classifies them as active even though the server's own + * `time_archived IS NULL` filter would still exclude them, which is why the + * global cache must split client-side instead of issuing an + * `archived: false` request for its active list. + */ +export const splitGlobalSessionsByArchived = ( + sessions: T[], +): { active: T[]; archived: T[] } => { + const active: T[] = []; + const archived: T[] = []; + for (const session of sessions) { + if (isArchivedSession(session)) archived.push(session); + else active.push(session); + } + return { active, archived }; +}; + export async function listGlobalSessionPages( apiClient: OpencodeClient, options: { directory?: string; archived: boolean; + /** + * When `archived` is true, narrow results to records carrying a truthy + * `time.archived` (default true). Pass false to receive the inclusive + * server response unfiltered, e.g. to split active/archived locally. + */ + narrowToArchived?: boolean; roots?: boolean; pageSize: number; onPage?: (sessions: GlobalSessionRecord[]) => void; @@ -97,17 +124,17 @@ export async function listGlobalSessionPages( const all: GlobalSessionRecord[] = []; const seenIds = new Set(); let cursor: number | undefined; + const narrowToArchived = options.narrowToArchived !== false; let operation: string; if (!options.directory) { - operation = `global-sessions.${options.archived ? "archived" : "active"}`; + operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`; } else if (options.roots === true) { operation = "bootstrap.sessions.roots"; } else if (options.archived) { - operation = "bootstrap.sessions.archived"; + operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all"; } else { operation = "bootstrap.sessions.all"; } - while (true) { let attempts = 0; const finishPerformanceEvent = startSessionLoadPerformanceEvent({ @@ -150,7 +177,7 @@ export async function listGlobalSessionPages( if (!session?.id || seenIds.has(session.id)) continue; seenIds.add(session.id); appended += 1; - if (options.archived && !isArchivedSession(session)) continue; + if (options.archived && narrowToArchived && !isArchivedSession(session)) continue; all.push(session); accepted.push(session); } diff --git a/packages/ui/src/stores/messageQueueStore.test.ts b/packages/ui/src/stores/messageQueueStore.test.ts index 6ec859a0..8d4b237d 100644 --- a/packages/ui/src/stores/messageQueueStore.test.ts +++ b/packages/ui/src/stores/messageQueueStore.test.ts @@ -8,7 +8,7 @@ import { } from "./messageQueueStore" beforeEach(() => { - useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} }) + useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} }) }) describe("message queue runtime ownership", () => { @@ -49,3 +49,48 @@ describe("message queue runtime ownership", () => { expect(queue[0]?.content).toBe("message-5") }) }) + +describe("in-flight queued sends", () => { + test("hides a dispatched message from the sendable queue but keeps it visible", () => { + const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! + const store = useMessageQueueStore.getState() + store.addToQueue(target, { content: "first" }) + store.addToQueue(target, { content: "second" }) + const [first] = useMessageQueueStore.getState().getQueueForTarget(target) + + useMessageQueueStore.getState().markSending(target, first.id) + + expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(2) + const sendable = useMessageQueueStore.getState().getSendableQueue(target) + expect(sendable).toHaveLength(1) + expect(sendable[0]?.content).toBe("second") + + useMessageQueueStore.getState().clearSending(target, first.id) + expect(useMessageQueueStore.getState().getSendableQueue(target)).toHaveLength(2) + expect(useMessageQueueStore.getState().sendingIds).toEqual({}) + }) + + test("clearQueue retains a message whose send is still awaiting the server", () => { + const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! + const store = useMessageQueueStore.getState() + store.addToQueue(target, { content: "in flight" }) + store.addToQueue(target, { content: "merged by composer" }) + const [inFlight] = useMessageQueueStore.getState().getQueueForTarget(target) + useMessageQueueStore.getState().markSending(target, inFlight.id) + + useMessageQueueStore.getState().clearQueue(target) + + const remaining = useMessageQueueStore.getState().getQueueForTarget(target) + expect(remaining).toHaveLength(1) + expect(remaining[0]?.id).toBe(inFlight.id) + }) + + test("clearQueue drops everything once no send is in flight", () => { + const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! + useMessageQueueStore.getState().addToQueue(target, { content: "queued" }) + + useMessageQueueStore.getState().clearQueue(target) + + expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(0) + }) +}) diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 2ac9f86f..8ee096ec 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -85,6 +85,19 @@ interface MessageQueueState { queuedMessages: Record; // runtime + directory + session → queue quarantinedLegacyMessages: Record; followUpBehavior: FollowUpBehavior; + /** + * Queued messages whose send is currently awaiting the server, per target. + * + * A queued item is removed only after its send resolves, so between + * dispatch and resolution it is still visible to every other reader — and + * a composer submit merges the whole queue into its own send. Over a relay + * that window is seconds, long enough for the same message to be delivered + * twice. Dispatchers must skip entries listed here. + * + * Never persisted: a restart has no in-flight sends, and a stale flag would + * strand a queued message permanently. + */ + sendingIds: Record; } interface MessageQueueActions { @@ -94,6 +107,9 @@ interface MessageQueueActions { popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null; clearQueue: (target: MessageQueueTarget) => void; clearAllQueues: () => void; + markSending: (target: MessageQueueTarget, messageId: string) => void; + clearSending: (target: MessageQueueTarget, messageId: string) => void; + getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[]; setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; } @@ -127,6 +143,7 @@ export const useMessageQueueStore = create()( queuedMessages: {}, quarantinedLegacyMessages: {}, followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, + sendingIds: {}, addToQueue: (target, message) => { const key = getMessageQueueKey(target); @@ -237,6 +254,14 @@ export const useMessageQueueStore = create()( clearQueue: (target) => { const key = getMessageQueueKey(target); set((state) => { + // Clearing drops what is still queued, never a message + // already handed to the server: that send will resolve + // and must find its entry to remove or restore. + const sending = state.sendingIds[key] ?? []; + const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id)); + if (retained.length > 0) { + return { queuedMessages: { ...state.queuedMessages, [key]: retained } }; + } const { [key]: _removed, ...rest } = state.queuedMessages; void _removed; return { queuedMessages: rest }; @@ -244,7 +269,40 @@ export const useMessageQueueStore = create()( }, clearAllQueues: () => { - set({ queuedMessages: {} }); + set({ queuedMessages: {}, sendingIds: {} }); + }, + + markSending: (target, messageId) => { + const key = getMessageQueueKey(target); + set((state) => { + const current = state.sendingIds[key] ?? []; + if (current.includes(messageId)) return state; + return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } }; + }); + }, + + clearSending: (target, messageId) => { + const key = getMessageQueueKey(target); + set((state) => { + const current = state.sendingIds[key]; + if (!current || !current.includes(messageId)) return state; + const next = current.filter((id) => id !== messageId); + if (next.length === 0) { + const { [key]: _removed, ...rest } = state.sendingIds; + void _removed; + return { sendingIds: rest }; + } + return { sendingIds: { ...state.sendingIds, [key]: next } }; + }); + }, + + getSendableQueue: (target) => { + const key = getMessageQueueKey(target); + const state = get(); + const queue = state.queuedMessages[key] ?? []; + const sending = state.sendingIds[key]; + if (!sending || sending.length === 0) return queue; + return queue.filter((message) => !sending.includes(message.id)); }, setFollowUpBehavior: (behavior) => { diff --git a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts index 02c2fc54..a2a7aa90 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts @@ -20,14 +20,17 @@ const deferred = (): Deferred => { return { promise, resolve, reject } } -let activeRequest: Deferred -let archivedRequest: Deferred +let listRequest: Deferred +// The store issues one inclusive (`archived: true`) paginated request per +// load/refresh scope and splits active/archived client-side, so restored +// sessions (`time.archived` falsy-but-present) stay visible in the active +// list. The mock serves that single request. const sdk = { experimental: { session: { - list: async (options: { archived?: boolean }) => ({ - data: await (options.archived ? archivedRequest.promise : activeRequest.promise), + list: async () => ({ + data: await listRequest.promise, response: { headers: new Headers() }, }), }, @@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient const session = (id: string, title = id, archived?: number): Session => ({ id, title, - time: { created: 1, updated: 1, ...(archived ? { archived } : {}) }, + time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) }, } as Session) describe("global session mutation reconciliation", () => { beforeEach(() => { - activeRequest = deferred() - archivedRequest = deferred() + listRequest = deferred() opencodeClient.getSdkClient = () => sdk useGlobalSessionsStore.getState().resetForRuntimeSwitch() }) @@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => { const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().upsertSession(session("created")) - activeRequest.resolve([]) - archivedRequest.resolve([]) + listRequest.resolve([]) await loading expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"]) @@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => { const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().removeSessions([stale.id]) - activeRequest.resolve([stale]) - archivedRequest.resolve([]) + listRequest.resolve([stale]) await loading expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) @@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => { const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().archiveSessions([stale.id], 10) - activeRequest.resolve([stale]) - archivedRequest.resolve([]) + listRequest.resolve([stale]) await loading expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) @@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => { const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().upsertSession(session("updated", "New")) - activeRequest.resolve([stale]) - archivedRequest.resolve([]) + listRequest.resolve([stale]) await loading expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New") }) - test("uses commit-time state when one side of the load fails", async () => { + test("uses commit-time state when the load fails", async () => { const created = session("created") const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().upsertSession(created) - activeRequest.reject(new Error("unavailable")) - archivedRequest.resolve([]) + listRequest.reject(new Error("unavailable")) await loading expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created]) expect(useGlobalSessionsStore.getState().status).toBe("error") }) + test("splits a restored session into the active list", async () => { + const loading = useGlobalSessionsStore.getState().loadSessions() + + listRequest.resolve([session("active"), session("archived", "archived", 5), session("restored", "restored", 0)]) + await loading + + expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["active", "restored"]) + expect(useGlobalSessionsStore.getState().archivedSessions.map((item) => item.id)).toEqual(["archived"]) + expect(useGlobalSessionsStore.getState().status).toBe("ready") + }) + test("does not undo a move while refreshing the source directory", async () => { const source = { ...session("moved"), directory: "/source" } as Session const destination = { ...source, directory: "/destination" } as Session @@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => { const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"]) useGlobalSessionsStore.getState().upsertSession(destination) - activeRequest.resolve([source]) - archivedRequest.resolve([]) + listRequest.resolve([source]) await refreshing expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined) expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved") }) + + test("keeps a restore mutation newer than the directory refresh", async () => { + const archived = { ...session("restored", "restored", 5), directory: "/source" } as Session + useGlobalSessionsStore.getState().applySnapshot([], [archived]) + const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"]) + useGlobalSessionsStore.getState().upsertSession({ ...archived, time: { ...archived.time, archived: 0 } }) + + // The server still reports the pre-restore row for this directory. + listRequest.resolve([archived]) + await refreshing + + expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["restored"]) + expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([]) + }) }) diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 3c3e6af2..7f6f3854 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; -import { listGlobalSessionPages } from '@/stores/globalSessions'; +import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions'; import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { normalizePath } from '@/lib/pathNormalization'; @@ -253,7 +253,6 @@ type DirectoryPageResult = { const fetchDirectoryPages = async ( sdk: OpencodeClient, directories: Set, - archived: boolean, ): Promise => { const currentDirectory = normalizePath(opencodeClient.getDirectory()); const orderedDirectories = [...directories].sort((left, right) => { @@ -267,8 +266,11 @@ const fetchDirectoryPages = async ( status: 'fulfilled' as const, value: { directory, + // One inclusive request per directory: the server has no filter that + // returns only active sessions including restored (`time.archived` + // falsy-but-present) rows, so fetch everything and split client-side. sessions: await withDirectorySessionRefreshSlot(() => ( - listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE }) + listGlobalSessionPages(sdk, { directory, archived: true, narrowToArchived: false, pageSize: PAGE_SIZE }) )), }, }; @@ -526,35 +528,25 @@ export const useGlobalSessionsStore = create((set, get) => const loadPromise = (async () => { try { const sdk = opencodeClient.getSdkClient(); - const [activeResult, archivedResult] = await Promise.allSettled([ - listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), - listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), - ]); - - if (activeResult.status === 'rejected') { - console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason); - } - if (archivedResult.status === 'rejected') { - console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason); - } + // One inclusive fetch, split client-side. The server's + // `time_archived IS NULL` active filter would exclude restored + // sessions (`time.archived` falsy-but-present), so an + // `archived: false` request cannot produce a truthful active list. + const allSessions = await listGlobalSessionPages(sdk, { + archived: true, + narrowToArchived: false, + pageSize: PAGE_SIZE, + }); if (generation !== loadGeneration) { // Runtime switched mid-load: this snapshot belongs to the previous // instance — drop it. return { activeSessions: [], archivedSessions: [] }; } - const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled' - ? 'ready' - : 'error'; + const { active, archived } = splitGlobalSessionsByArchived(allSessions); set((state) => { - const fetchedActive = activeResult.status === 'fulfilled' - ? activeResult.value - : mergeSessionLists(state.activeSessions, fallbackActive); - const fetchedArchived = archivedResult.status === 'fulfilled' - ? archivedResult.value - : state.archivedSessions; - const reconciled = overlayMutationsSince(state, fetchedActive, fetchedArchived, baselineRevision); - return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, status); + const reconciled = overlayMutationsSince(state, active, archived, baselineRevision); + return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready'); }); const committed = get(); return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions }; @@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create((set, get) => const generation = loadGeneration; const baselineRevision = get().mutationRevision; const sdk = opencodeClient.getSdkClient(); - const [active, archived] = await Promise.all([ - fetchDirectoryPages(sdk, directorySet, false), - fetchDirectoryPages(sdk, directorySet, true), - ]); + const fetched = await fetchDirectoryPages(sdk, directorySet); if (generation !== loadGeneration) { const state = get(); return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; } - if (active.errors.length > 0) { - console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]); - } - if (archived.errors.length > 0) { - console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]); + if (fetched.errors.length > 0) { + console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]); } + const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions); + set((state) => { - let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories); + let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories); nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive); if (sameSessionList(state.activeSessions, nextActiveSessions)) { nextActiveSessions = state.activeSessions; } - let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories); + let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived, fetched.directories); if (sameSessionList(state.archivedSessions, nextArchivedSessions)) { nextArchivedSessions = state.archivedSessions; } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 78152f32..cc9b4abb 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -207,7 +207,7 @@ The discriminator is whether the server confirmed the path, not whether the valu | Source | Meaning | |---|---| -| `authoritative` | The child store that actually holds the session, then its own record | +| `authoritative` | The session record's own directory, then a child store that holds it | | `selected` | Server-confirmed directory captured at selection; a guessed one is never passed | | `attachment` | Worktree attachment recorded by this client; the *requested* path | | `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path | @@ -215,7 +215,7 @@ The discriminator is whether the server confirmed the path, not whether the valu Rules: -1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory". +1. Ownership comes from the session record's own `directory`. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: a project's session list includes the sessions of its worktrees so the sidebar can group them, so the parent repository holds worktree sessions too, and reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory". 2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent. 3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts. 4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically. @@ -232,6 +232,7 @@ Rules: 3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. 5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. +6. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. Examples of global-store updates performed in `session-actions.ts`: @@ -239,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`: - `updateSessionTitle()` -> `upsertSession(result.data)` - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` - `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session +- `unarchiveSession()` / `unarchiveSessions()` -> wait for server confirmation, then upsert each restored session - `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state - `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index +### Restore (unarchive) contract + +The OpenCode server cannot clear `time.archived` over HTTP: `session.update` +only applies the field when the payload carries a finite number, so an omitted +key is a no-op and `null` is silently ignored. Restore therefore writes +`time.archived = 0` (`UNARCHIVED_TIMESTAMP` in `session-actions.ts`). Every +client-side reader classifies archive state by truthiness of `time.archived`, +so `0` reads as active in the UI, the event reducer, and the OpenCode app/TUI. + +The server's `time_archived IS NULL` list filter still excludes such rows, so +any query that wants a truthful active list must fetch inclusively +(`archived: true`) and split client-side (`splitGlobalSessionsByArchived`). +The global sessions store does this for its full and per-directory loads; +directory bootstrap keeps using the server filter because live child stores +must not hold archived sessions. A restored session re-enters its live +directory store through the authoritative `session.updated` event the server +publishes for the update; until then it remains fully visible through the +global store (sidebar, switcher) and addressable by ID (message loading). + Archive and delete actions capture the active runtime key when they start and recheck it before every store reconciliation, so a response produced by the previous runtime is rejected instead of mutating the current -runtime's live or global session state. A guarded batch stops at the first -observed runtime change: sessions the server already confirmed remain archived -or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed -on the captured runtime is returned in `failedIds` so existing partial-failure +runtime's live or global session state. Restore follows the same guard: a +stale completion returns `false` without touching any store. A guarded batch +stops at the first observed runtime change: sessions the server already +confirmed remain archived, restored, or deleted and stay in +`archivedIds`/`restoredIds`/`deletedIds`, while every ID not confirmed on the +captured runtime is returned in `failedIds` so existing partial-failure feedback stays truthful. Callers whose confirmation can span a runtime switch may pass an `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index cfd23584..0b0142de 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -1,6 +1,6 @@ import { create, type StoreApi } from "zustand" import type { DirState, State } from "./types" -import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types" +import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS, EVICTION_GRACE_MS } from "./types" import { pickDirectoriesToEvict, canDisposeDirectory, hasPendingBlockingRequests } from "./eviction" import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessions } from "./persist-cache" import { normalizePath } from "@/lib/pathNormalization" @@ -250,6 +250,7 @@ export class ChildStoreManager { readonly children = new Map>() private readonly lifecycle = new Map() private readonly pins = new Map() + private evictionScheduled = false private readonly disposers = new Map void>() private readonly registrySubscribers = new Set<() => void>() private readonly bootstrapSubscribers = new Set<() => void>() @@ -308,7 +309,25 @@ export class ChildStoreManager { mark(directory: string) { if (!directory) return this.lifecycle.set(directory, { lastAccessAt: Date.now() }) - this.runEviction(directory) + this.scheduleEviction() + } + + /** + * Coalesce eviction into one pass per tick. + * + * `ensureChild` runs during render, once per sidebar row, and used to sort + * and scan every directory synchronously on each call. Deferring the pass + * also lets a whole render commit — and with it every pin effect — settle + * before anything is considered for disposal. + */ + private scheduleEviction() { + if (this.evictionScheduled || this.disposed) return + this.evictionScheduled = true + queueMicrotask(() => { + this.evictionScheduled = false + if (this.disposed) return + this.runEviction() + }) } pin(directory: string) { @@ -327,6 +346,8 @@ export class ChildStoreManager { return } this.pins.delete(normalizedDirectory) + // Releasing the final consumer is an explicit lifecycle edge, not a render- + // path access, so this pass stays synchronous. this.runEviction() } @@ -621,6 +642,7 @@ export class ChildStoreManager { pins: new Set(stores.filter((d) => this.pinned(d))), max: MAX_DIR_STORES, ttl: DIR_IDLE_TTL_MS, + graceMs: EVICTION_GRACE_MS, now: Date.now(), hasPendingBlockingRequests: (dir) => this.hasPendingBlockingRequestsForDirectory(dir), }).filter((d) => d !== skip) diff --git a/packages/ui/src/sync/eviction-thrash.test.ts b/packages/ui/src/sync/eviction-thrash.test.ts new file mode 100644 index 00000000..da316215 --- /dev/null +++ b/packages/ui/src/sync/eviction-thrash.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" + +import { pickDirectoriesToEvict } from "./eviction" +import { DIR_IDLE_TTL_MS, EVICTION_GRACE_MS, MAX_DIR_STORES } from "./types" + +/** + * Regression coverage for the sidebar cache-thrash loop (issue #1472). + * + * Expanding a project with many worktrees mounts a sidebar row per directory. + * Each row calls `ensureChild` during render, while the pin that protects it is + * only taken in an effect after commit. With more live directories than the + * store limit, overflow eviction therefore disposed directories that were + * actively being rendered; the next render recreated them with a `loading` + * status, which issued another bootstrap request, and the cycle repeated + * indefinitely. + * + * The fix treats the limit as a soft target: a directory touched within the + * grace window is never an overflow victim, so a burst of live directories + * overflows the cache briefly instead of thrashing. Idle directories stay + * evictable, which is what keeps the cache bounded. + */ + +const buildState = (directories: string[], lastAccessAt: number) => + new Map(directories.map((directory) => [directory, { lastAccessAt }])) + +const directories = (count: number, prefix = "/repo/worktree-") => + Array.from({ length: count }, (_, index) => `${prefix}${index}`) + +describe("directory eviction under sidebar expansion", () => { + test("does not evict directories that are being accessed right now", () => { + const now = 1_000_000 + const stores = directories(MAX_DIR_STORES + 25) + + const evicted = pickDirectoriesToEvict({ + stores, + state: buildState(stores, now), + pins: new Set(), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + graceMs: EVICTION_GRACE_MS, + now, + }) + + expect(evicted).toEqual([]) + }) + + test("still evicts overflow once directories fall outside the grace window", () => { + const now = 1_000_000 + const live = directories(MAX_DIR_STORES, "/repo/live-") + const stale = directories(5, "/repo/stale-") + const state = new Map([ + ...buildState(live, now), + ...buildState(stale, now - EVICTION_GRACE_MS - 1), + ]) + + const evicted = pickDirectoriesToEvict({ + stores: [...live, ...stale], + state, + pins: new Set(), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + graceMs: EVICTION_GRACE_MS, + now, + }) + + expect([...evicted].sort()).toEqual([...stale].sort()) + }) + + test("still evicts directories idle past the TTL even inside the limit", () => { + const now = 1_000_000 + const active = directories(3, "/repo/active-") + const abandoned = directories(2, "/repo/abandoned-") + const state = new Map([ + ...buildState(active, now), + ...buildState(abandoned, now - DIR_IDLE_TTL_MS - 1), + ]) + + const evicted = pickDirectoriesToEvict({ + stores: [...active, ...abandoned], + state, + pins: new Set(), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + graceMs: EVICTION_GRACE_MS, + now, + }) + + expect([...evicted].sort()).toEqual([...abandoned].sort()) + }) + + test("never evicts pinned or blocked directories regardless of overflow", () => { + const now = 1_000_000 + const stores = directories(MAX_DIR_STORES + 10) + const stale = now - EVICTION_GRACE_MS - 1 + + const evicted = pickDirectoriesToEvict({ + stores, + state: buildState(stores, stale), + pins: new Set([stores[0]]), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + graceMs: EVICTION_GRACE_MS, + now, + hasPendingBlockingRequests: (directory) => directory === stores[1], + }) + + expect(evicted).not.toContain(stores[0]) + expect(evicted).not.toContain(stores[1]) + }) +}) diff --git a/packages/ui/src/sync/eviction.ts b/packages/ui/src/sync/eviction.ts index 723149a4..cb9a65a5 100644 --- a/packages/ui/src/sync/eviction.ts +++ b/packages/ui/src/sync/eviction.ts @@ -26,6 +26,7 @@ export function hasPendingBlockingRequests(state: State | undefined): boolean { export function pickDirectoriesToEvict(input: EvictPlan) { const overflow = Math.max(0, input.stores.length - input.max) let pendingOverflow = overflow + const graceMs = input.graceMs ?? 0 const sorted = input.stores .filter((dir) => !input.pins.has(dir)) .filter((dir) => !input.hasPendingBlockingRequests?.(dir)) @@ -34,8 +35,14 @@ export function pickDirectoriesToEvict(input: EvictPlan) { const output: string[] = [] for (const dir of sorted) { const last = input.state.get(dir)?.lastAccessAt ?? 0 - const idle = input.now - last >= input.ttl + const age = input.now - last + const idle = age >= input.ttl if (!idle && pendingOverflow <= 0) continue + // A directory touched moments ago is almost certainly still mounted and + // merely waiting for its pin effect to run. Evicting it starts the + // recreate/bootstrap loop this grace window exists to prevent; going over + // the limit for a while is the cheaper failure. + if (!idle && age < graceMs) continue output.push(dir) if (pendingOverflow > 0) pendingOverflow -= 1 } diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index c9c4325a..84c276f8 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -619,6 +619,116 @@ describe("confirmed session removal", () => { }) }) +describe("session restore (unarchive)", () => { + beforeEach(() => { + replyCalls.length = 0 + registeredSessionDirectories.length = 0 + globalUpsertedSessions.length = 0 + sessionUpdateResult = {} + beforeSessionUpdateResolve = null + }) + + test("does not restore locally until the server returns the restored session", async () => { + const source = createStore({}, { + session: [], + }) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await unarchiveSession("session-a")).toBe(false) + expect(globalUpsertedSessions).toEqual([]) + expect(registeredSessionDirectories).toEqual([]) + }) + + test("sends the archive-clearing sentinel and upserts the restored session after confirmation", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session, + } + const source = createStore({}, { + session: [], + }) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await unarchiveSession("session-a")).toBe(true) + // The server cannot clear time.archived over HTTP, so the action must + // write the falsy sentinel rather than omitting the field. + expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([{ + method: "session.update", + params: { sessionID: "session-a", time: { archived: 0 }, directory: "/test/project" }, + }]) + expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(0) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/test/project" }]) + }) + + test("fails when the server keeps the session archived", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session, + } + const source = createStore({}, { + session: [], + }) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + // A silent server-side no-op must surface as a failure, not a success toast. + expect(await unarchiveSession("session-a")).toBe(false) + expect(globalUpsertedSessions).toEqual([]) + expect(registeredSessionDirectories).toEqual([]) + }) + + test("rejects a restore response that arrives after a runtime switch", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session, + } + const source = createStore({}, { + session: [], + }) + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-a.test", runtimeKey: "restore-runtime-a" }) + beforeSessionUpdateResolve = () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-b.test", runtimeKey: "restore-runtime-b" }) + } + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await unarchiveSession("session-a")).toBe(false) + expect(getRuntimeKey()).toBe("restore-runtime-b") + // The stale response must not reconcile the runtime the user switched to. + expect(globalUpsertedSessions).toEqual([]) + expect(registeredSessionDirectories).toEqual([]) + }) + + test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session, + } + const source = createStore({}, { + session: [], + }) + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-a.test", runtimeKey: "restore-batch-a" }) + beforeSessionUpdateResolve = (sessionId) => { + if (sessionId === "session-b") { + switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-b.test", runtimeKey: "restore-batch-b" }) + } + } + const { unarchiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await unarchiveSessions(["session-a", "session-b", "session-c"]) + + // session-a was confirmed before the switch and stays restored; session-b's + // response is stale and session-c is never attempted, so both are reported + // as failures instead of being silently dropped. + expect(result).toEqual({ restoredIds: ["session-a"], failedIds: ["session-b", "session-c"] }) + expect(globalUpsertedSessions).toHaveLength(1) + // session-c must not reach the SDK after the runtime changed. + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a", "session-b"]) + }) +}) + describe("fetchMessagesForSession startup race", () => { test("does not reject before sync action refs are initialized", async () => { const { fetchMessagesForSession } = await import("./session-actions") @@ -1014,6 +1124,53 @@ describe("optimisticSend target directory", () => { expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part") }) + // Relay tunnel aborts carry no HTTP status and no wording the text-matching + // heuristic recognizes. Without the transport tag they were classified as + // definite failures, the accepted prompt was rolled back, and the queue + // re-sent a message the engine was already answering (#2425). + test("confirms a tunnel-tagged transport failure that no text heuristic matches", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let optimisticRemove: OptimisticRemoveCall | null = null + let optimisticConfirm: OptimisticRemoveCall | null = null + let sentMessageID = "" + + const { markAmbiguousTransportFailure } = await import("@/lib/relay/transport-error") + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + (input) => { + optimisticRemove = input + }, + (input) => { + optimisticConfirm = input + }, + ) + + await optimisticSend({ + sessionId: "session-tunnel", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + send: async (messageID) => { + sentMessageID = messageID + sessionMessagesResult = { + data: [{ + info: { id: messageID, role: "user", sessionID: "session-tunnel", time: { created: 1 } } as Message, + parts: [{ id: "server-part", type: "text", text: "hello" } as Part], + }], + } + throw markAmbiguousTransportFailure(new Error("stream aborted by host")) + }, + }) + + expect(optimisticRemove).toBe(null) + expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID) + expect(targetStore.getState().message["session-tunnel"]?.[0]?.id).toBe(sentMessageID) + }) + test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => { const targetStore = createStore({}) const childStores = createChildStores([["/target/project", targetStore]]) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 5a6eab26..1b695891 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -29,11 +29,21 @@ import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/l import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" import { getRuntimeKey } from "@/lib/runtime-switch" +import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error" const MESSAGE_REFETCH_LIMIT = 100 const SEND_CONFIRMATION_REFETCH_LIMIT = 30 -const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2 -const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150 +// A relay-tunnel send fails when the tunnel drops, and the confirming refetch +// then has to travel over that same tunnel to answer "did my message land?". +// Two attempts 150ms apart always answered "no" on a remote connection, so an +// accepted prompt looked like a failed one and got re-sent — two AI responses +// for one user message. Wait for the connection to actually come back (an +// authoritative signal, not a blind sleep), then retry with backoff. A healthy +// connection skips the wait and answers on the first attempt. +const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 3 +const SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS = 250 +const SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS = 3000 +const SEND_CONFIRMATION_RECONNECT_POLL_MS = 100 const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const UNREVERT_REFETCH_ATTEMPTS = 3 const UNREVERT_REFETCH_RETRY_MS = 150 @@ -360,6 +370,13 @@ function getErrorStatus(error: unknown): number | null { } function isAmbiguousSendFailure(error: unknown): boolean { + // Authoritative first: the transport that lost the request says whether it + // had already been dispatched. The text matching below only covers direct + // fetch/HTTP failures, whose wording we do not control either — relay tunnel + // aborts ("stream aborted by host", "relay keepalive timeout", …) match none + // of those patterns and used to be misread as definite failures. + if (isAmbiguousTransportFailure(error)) return true + const status = getErrorStatus(error) if (status === 503 || status === 504 || status === 408) return true if (error instanceof TypeError) return true @@ -997,6 +1014,92 @@ export async function archiveSessions( return { archivedIds, failedIds } } +/** + * Sentinel written to `time.archived` when restoring a session. + * + * The OpenCode server has no HTTP path to clear `time.archived` back to NULL: + * `session.update` only applies the field when the payload carries a finite + * number (`archived !== undefined`), so omitting the key is a no-op and `null` + * is silently ignored. Writing `0` is the only value that makes every reader + * treat the session as active again: the UI, the event reducer, and the + * OpenCode app/TUI all classify archive state by truthiness of + * `time.archived`, and `0` is falsy. The one place that still excludes such a + * session is the server's own `time_archived IS NULL` list filter, so the + * global session cache loads with the inclusive `archived` flag and splits + * client-side instead of relying on that filter (see + * `useGlobalSessionsStore.loadSessions`). + */ +const UNARCHIVED_TIMESTAMP = 0 + +/** + * Restore one archived session back to the active list. + * + * Same contract as `archiveSession`: waits for server confirmation before + * reconciling stores, and rejects stale runtimes so a response produced by a + * previous runtime cannot mutate the current runtime's state. The global + * session cache is updated directly (the sidebar reads active/archived + * buckets from it); the live directory store is re-populated by the + * authoritative `session.updated` event the server publishes for the update. + */ +export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { + if (isStaleRuntime(expectedRuntimeKey)) return false + const sessionDirectory = getSessionDirectory(sessionId) + try { + const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory) + if (isStaleRuntime(expectedRuntimeKey)) return false + if (!restored) { + throw new Error("session.update failed: server did not return the restored session") + } + if (restored.time?.archived) { + throw new Error("session.update failed: server kept the session archived") + } + useGlobalSessionsStore.getState().upsertSession(restored) + if (sessionDirectory) registerSessionDirectory(sessionId, sessionDirectory) + return true + } catch (error) { + console.error("[session-actions] unarchiveSession failed", error) + return false + } +} + +export type UnarchiveSessionsOptions = { + /** + * Runtime key captured when the batch was confirmed. When supplied, the batch + * stops as soon as the active runtime differs. + */ + expectedRuntimeKey?: string +} + +/** + * Restore several archived sessions sequentially, preserving partial results. + * + * One failed session never blocks or erases the others: it is reported in + * `failedIds` while the remaining IDs are still attempted. When + * `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the + * already-confirmed sessions stay in `restoredIds` and every ID that was not + * confirmed on the captured runtime is reported in `failedIds`, so callers keep + * showing truthful partial-failure feedback. + */ +export async function unarchiveSessions( + ids: string[], + options?: UnarchiveSessionsOptions, +): Promise<{ restoredIds: string[]; failedIds: string[] }> { + const restoredIds: string[] = [] + const failedIds: string[] = [] + const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + + for (const [index, id] of ids.entries()) { + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...ids.slice(index)) + break + } + if (await unarchiveSession(id, expectedRuntimeKey)) restoredIds.push(id) + else failedIds.push(id) + } + + return { restoredIds, failedIds } +} + export async function updateSessionTitle(sessionId: string, title: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) @@ -1255,8 +1358,15 @@ async function fetchRecentSendConfirmationRecords( messageID: string, directory?: string | null, ): Promise | null> { + // Bounded: a connection that never returns must still let the send fail + // rather than hang the composer. + const reconnectDeadline = Date.now() + SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS + while (!useConfigStore.getState().isConnected && Date.now() < reconnectDeadline) { + await wait(SEND_CONFIRMATION_RECONNECT_POLL_MS) + } + for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) { - if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS) + if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS * 2 ** (attempt - 1)) try { const result = await sdk().session.messages({ sessionID: sessionId, diff --git a/packages/ui/src/sync/session-directory-adoption.test.ts b/packages/ui/src/sync/session-directory-adoption.test.ts new file mode 100644 index 00000000..4857872c --- /dev/null +++ b/packages/ui/src/sync/session-directory-adoption.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test } from "bun:test" + +import { ChildStoreManager } from "./child-store" +import { setSyncRefs } from "./sync-refs" +import { useSessionUIStore } from "./session-ui-store" + +/** + * Selecting a session whose directory this client has not indexed yet routes it + * through the active directory as a deliberate guess. Nothing used to settle + * that guess once the owning directory finished bootstrapping, so every fetch + * stayed addressed to a directory that does not own the session and the session + * never rendered. + * + * These tests pin both directions: a guess is promoted once the authoritative + * directory becomes readable, and a confirmed selection is never rewritten. + */ + +const PARENT = "/repo" +const WORKTREE = "/repo/.worktrees/feature" +const SESSION_ID = "ses_directory_adoption" + +const indexSessionIn = ( + manager: ChildStoreManager, + directory: string, + recordDirectory: string = directory, +): void => { + const store = manager.ensureChild(directory, { bootstrap: false }) + store.setState({ + session: [{ id: SESSION_ID, directory: recordDirectory, title: "test" } as never], + }) +} + +let manager: ChildStoreManager + +beforeEach(() => { + manager = new ChildStoreManager() + setSyncRefs({} as never, manager, PARENT) + useSessionUIStore.getState().setCurrentSession(null) +}) + +describe("adoptAuthoritativeSessionDirectory", () => { + test("promotes a guessed selection once the owning directory is indexed", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE) + + indexSessionIn(manager, WORKTREE) + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + }) + + test("believes the session record over the store that merely holds it", () => { + // A project's session list includes the sessions of its worktrees so the + // sidebar can group them, so the parent store holds this session while the + // session itself reports the worktree. Ownership comes from the record. + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + indexSessionIn(manager, PARENT, WORKTREE) + + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + }) + + test("does nothing while the owning directory is still unknown", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + const before = useSessionUIStore.getState().currentSessionDirectory + + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(before) + }) + + test("never rewrites a selection that was confirmed at selection time", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID, WORKTREE) + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + + // A different directory claiming the session must not move a confirmed + // selection: the confirmed value outranks anything sync learns later. + indexSessionIn(manager, PARENT) + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(WORKTREE) + }) + + test("is a no-op for a session that is no longer selected", () => { + useSessionUIStore.getState().setCurrentSession(SESSION_ID) + indexSessionIn(manager, WORKTREE) + useSessionUIStore.getState().setCurrentSession("ses_other") + + // Whatever the new selection resolved to, a late adoption for the previous + // session must not touch it. + const before = useSessionUIStore.getState().currentSessionDirectory + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory(SESSION_ID) + + expect(useSessionUIStore.getState().currentSessionId).toBe("ses_other") + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(before) + }) +}) diff --git a/packages/ui/src/sync/session-directory-resolution.ts b/packages/ui/src/sync/session-directory-resolution.ts index d8069176..616c4aa7 100644 --- a/packages/ui/src/sync/session-directory-resolution.ts +++ b/packages/ui/src/sync/session-directory-resolution.ts @@ -13,8 +13,10 @@ * The ordering discriminator is **whether the server confirmed the path**, not * whether the value is local or synced: * - * 1. `authoritative` — the child store that actually holds the session, then - * the session's own record. Server-backed truth for an indexed session. + * 1. `authoritative` — the session's own record, then a child store that holds + * it. Server-backed truth for an indexed session. Record first because + * holding a session proves containment, not ownership: a project's session + * list includes its worktrees' sessions so the sidebar can group them. * 2. `selected` — the directory captured when the session was selected, but * only when it came from a server response (the directory `createSession` * returned, which may be a canonicalized form of what was requested). A @@ -42,7 +44,7 @@ export type SessionDirectorySource = | 'none' export type SessionDirectorySources = { - /** Directory of the child store that holds the session, or its own record. */ + /** The session record's own directory, or a store that holds it. */ authoritative?: string | null /** Server-confirmed directory captured at selection. Never a guessed one. */ selected?: string | null diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index d60be0fe..ce4cf88f 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -483,6 +483,15 @@ describe('archiveSessions option forwarding', () => { expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] }); expect(updateSessionCalls).toEqual([]); }); + + test('unarchiveSessions honors expectedRuntimeKey instead of discarding the options object', async () => { + const result = await useSessionUIStore.getState().unarchiveSessions(['session-x', 'session-y'], { + expectedRuntimeKey: 'runtime-that-is-not-active', + }); + + expect(result).toEqual({ restoredIds: [], failedIds: ['session-x', 'session-y'] }); + expect(updateSessionCalls).toEqual([]); + }); }); describe('deleteSessions option forwarding', () => { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 4f65e98b..895f5062 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -55,6 +55,8 @@ import { deleteSessions as deleteSessionsAction, archiveSession as archiveSessionAction, archiveSessions as archiveSessionsAction, + unarchiveSession as unarchiveSessionAction, + unarchiveSessions as unarchiveSessionsAction, updateSessionTitle as updateSessionTitleAction, shareSession as shareSessionAction, unshareSession as unshareSessionAction, @@ -67,6 +69,7 @@ import { type ArchiveSessionsOptions, type DeleteSessionOptions, type DeleteSessionsOptions, + type UnarchiveSessionsOptions, } from "./session-actions" import { useInputStore, type SyntheticContextPart } from "./input-store" import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore" @@ -335,6 +338,8 @@ export type SessionUIState = { deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> archiveSession: (id: string) => Promise archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }> + unarchiveSession: (id: string) => Promise + unarchiveSessions: (ids: string[], options?: UnarchiveSessionsOptions) => Promise<{ restoredIds: string[]; failedIds: string[] }> updateSessionTitle: (sessionId: string, title: string) => Promise shareSession: (sessionId: string) => Promise unshareSession: (sessionId: string) => Promise @@ -352,6 +357,12 @@ export type SessionUIState = { debugSessionMessages: (sessionId: string) => Promise pollForTokenUpdates: () => void setSessionDirectory: (sessionId: string, directory: string | null) => void + /** + * Replace a guessed selection directory with the authoritative one once sync + * has indexed the session. Safe to call at any time: it only ever promotes a + * guess, never overrides a confirmed selection. + */ + adoptAuthoritativeSessionDirectory: (sessionId?: string) => void } // --------------------------------------------------------------------------- @@ -401,15 +412,26 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW } /** - * Authoritative directory for a session: the child store that holds it, and - * only then the session record's own fields. `null` means "not indexed yet", - * never "no directory" — callers must fall back rather than treat it as empty. + * The directory that owns a session, from the two server-backed signals. + * + * `null` means "not indexed yet", never "no directory" — callers must fall back + * rather than treat it as empty. + * + * The session's own record wins. Holding a session in a child store proves + * containment, not ownership: a project's session list legitimately includes + * the sessions of its worktrees so the sidebar can group them, so the parent + * repository holds worktree sessions too. Reading ownership from store + * membership therefore reports the parent for a session that lives in a + * worktree, and every fetch is then addressed to a directory that does not own + * it. Store membership remains the fallback for a session whose record carries + * no directory. */ const getAuthoritativeSessionDirectory = (sessionId: string): string | null => { - const owningDirectory = getSyncSessionDirectory(sessionId) - if (owningDirectory) return normalizePath(owningDirectory) const target = getAllSyncSessions().find((s) => s.id === sessionId) - return target ? resolveDirectoryKey(target) : null + const recordDirectory = target ? resolveDirectoryKey(target) : null + if (recordDirectory) return normalizePath(recordDirectory) + const owningDirectory = getSyncSessionDirectory(sessionId) + return owningDirectory ? normalizePath(owningDirectory) : null } /** @@ -1406,6 +1428,10 @@ export const useSessionUIStore = create()((set, get) => ({ archiveSessions: (ids, options) => archiveSessionsAction(ids, options), + unarchiveSession: (id) => unarchiveSessionAction(id), + + unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options), + // --------------------------------------------------------------------------- // updateSessionTitle — calls SDK, SSE event updates child store // --------------------------------------------------------------------------- @@ -1739,6 +1765,26 @@ export const useSessionUIStore = create()((set, get) => ({ // Handled by sync system's SSE stream }, + adoptAuthoritativeSessionDirectory: (sessionId) => { + const target = sessionId ?? get().currentSessionId + // Only a guess is promoted. A confirmed selection outranks anything sync + // learns later, and a selection that has since moved on must not be + // rewritten by a directory that finished bootstrapping in the background. + if (!target || target !== guessedSelectionSessionId) return + if (target !== get().currentSessionId) return + + const authoritative = getAuthoritativeSessionDirectory(target) + if (!authoritative) return + + // The selection stops being a guess even when the directory is unchanged: + // the value has now been confirmed by the store that owns the session. + guessedSelectionSessionId = null + if (authoritative !== get().currentSessionDirectory) { + set({ currentSessionDirectory: authoritative }) + } + writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId: target, directory: authoritative }) + }, + setSessionDirectory: (sessionId, directory) => { const normalized = normalizePath(directory) // Callers set this from a confirmed destination (a completed move, a diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 9e1da2b4..b4ff6c19 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -34,6 +34,7 @@ import { countSyncPerformance } from "./performance-diagnostics" import { runBackgroundNetworkTask } from "@/lib/background-network" import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" +import { useSessionUIStore } from "./session-ui-store" import { stripSessionDiffSnapshots } from "./sanitize" import { applySessionEventToGlobalSessions } from "./session-event-router" import { syncDebug } from "./debug" @@ -1949,6 +1950,16 @@ export function SyncProvider(props: { const result = await runBootstrap(0) if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`) + + // Selecting a session whose directory this client had not indexed yet + // routes it through the active directory as a documented guess. This is + // the moment that guess can be settled: the owning store now holds the + // session, so the authoritative directory is finally readable. Without + // this the guess survives, every fetch is addressed to a directory that + // does not own the session, and the session never renders. + if (result === "complete") { + useSessionUIStore.getState().adoptAuthoritativeSessionDirectory() + } }, onDispose: (directory) => { messageLoader.invalidateDirectory(directory) diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 0d96911c..ccc236f2 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -123,13 +123,15 @@ export function getAllSyncSessionMap(): ReadonlyMap max: number ttl: number + graceMs?: number now: number hasPendingBlockingRequests?: (directory: string) => boolean } @@ -110,6 +111,18 @@ export type DisposeCheck = { } export const MAX_DIR_STORES = 30 +/** + * Directories touched within this window are never overflow-eviction victims. + * + * Sidebar rows call `ensureChild` during render but only take their pin in an + * effect after commit. Without a grace window, expanding a project with more + * worktrees than `MAX_DIR_STORES` evicted directories that were actively + * rendering, which recreated them, which issued another bootstrap request, in + * an endless loop (issue #1472). The limit is therefore a soft target: a burst + * of live directories overflows briefly rather than thrashing, and the cache is + * bounded by idle-time eviction instead. + */ +export const EVICTION_GRACE_MS = 30 * 1000 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_CACHE_LIMIT = 40 diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 6e5e11a4..365f588d 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -17,6 +17,7 @@ export type QuotaProviderId = | 'wafer' | 'opencode-go' | 'crof' + | 'deepseek' | 'neuralwatt'; export interface UsageWindow { diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index e2d8aa34..77f468aa 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,10 +1,13 @@ -## [Unreleased] +## [1.18.0] - 2026-08-04 - **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech). - UI/Localization: added German interface translations (thanks to @SGD-DEV). - Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271). - Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update. - Settings/Skills: repository-local `.agents/skills` now appear for the active workspace (thanks to @makeittech). +- Settings/Skills: renaming a skill now preserves its instructions and supporting files; only skills in locations OpenChamber can safely rename show the action (thanks to @makeittech). +- Usage: added DeepSeek quota tracking (thanks to @airtaxi). +- Usage: Kimi for Coding now calculates usage correctly when the provider reports either used or remaining quota (thanks to @makeittech). - Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). - Chat: assistant messages no longer render active HTML. - Sidebar: a worktree shared by more than one project no longer appears twice. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index d66c18a2..eb796875 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.17.2", + "version": "1.18.0", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 4d915d80..35f24347 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -1385,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise => { } }; -const getProjectStoragePath = (projectID: string) => { - return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); -}; - -const updateProjectSandboxes = async ( - projectID: string, - primaryWorktree: string, - updater: (project: { - id: string; - worktree: string; - vcs: string; - sandboxes: string[]; - time: { created: number; updated: number }; - }) => void -) => { - const storagePath = getProjectStoragePath(projectID); - await fs.promises.mkdir(path.dirname(storagePath), { recursive: true }); - - const now = Date.now(); - const base = { - id: projectID, - worktree: primaryWorktree, - vcs: 'git', - sandboxes: [] as string[], - time: { created: now, updated: now }, - }; - - const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null); - const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base; - current.id = String(current.id || projectID); - current.worktree = String(current.worktree || primaryWorktree); - current.vcs = current.vcs || 'git'; - current.sandboxes = Array.isArray(current.sandboxes) - ? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean) - : []; - const createdAt = Number(current?.time?.created); - current.time = { - created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now, - updated: now, - }; - - updater(current); - - current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))]; - await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); -}; - -const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => { - const sandbox = String(sandboxPath || '').trim(); - if (!sandbox) { - return; - } - await updateProjectSandboxes(projectID, primaryWorktree, (project) => { - if (!project.sandboxes.includes(sandbox)) { - project.sandboxes.push(sandbox); - } - }); -}; - -const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => { - const sandbox = String(sandboxPath || '').trim(); - if (!sandbox) { - return; - } - await updateProjectSandboxes(projectID, primaryWorktree, (project) => { - project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox); - }); -}; +// OpenCode owns its own project/sandbox registry and records a worktree as a +// sandbox itself when an instance boots for that directory. OpenChamber used to +// write that state into OpenCode's storage JSON directly, behind the back of the +// running process — and since OpenCode v2 reads sandboxes from its database, the +// JSON write did not even reach it. Registration is not ours to perform. const isInsideOrSameDirectory = (root: string, target: string): boolean => { const relative = path.relative(root, target); @@ -1477,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async ( const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot; const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory); - if (!isAttached) { - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory); - } catch (error) { - console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error)); - } - } - if (!isInsideWorktreeRoot || isAttached) { return; } @@ -1963,12 +1892,6 @@ async function attachGitWorktreeToCandidate( await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); - try { - await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); - } catch (error) { - console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); - } - const shouldSetUpstream = Boolean(input?.setUpstream); const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); @@ -2033,12 +1956,6 @@ export async function createWorktree(directory: string, input: CreateGitWorktree if (input?.returnAfterDirectoryCreated === true) { await fs.promises.mkdir(candidate.directory, { recursive: false }); - try { - await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); - } catch (error) { - console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); - } - const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_PENDING, @@ -2129,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree await fs.promises.rm(targetDirectory, { recursive: true, force: true }); } - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory); - } catch (error) { - console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); - } - clearWorktreeBootstrapState(targetDirectory); return true; @@ -2157,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree } } - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree); - } catch (error) { - console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); - } - clearWorktreeBootstrapState(matchedEntry.worktree); return true; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 9f7b466b..94451eac 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -11,6 +11,7 @@ const AUTH = JSON.stringify({ crof: { key: 'test-token' }, neuralwatt: { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, + deepseek: { key: 'test-token' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; @@ -419,3 +420,93 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { fsMock.readFileSync = ORIGINAL_FS.readFileSync; }); }); + +describe('DeepSeek quota provider (VS Code parity)', () => { + beforeEach(() => { + const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string }; + fsMock.existsSync = () => true; + fsMock.readFileSync = () => AUTH; + }); + + test('builds credits_balance window from documented USD payload (string balance)', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' }, + ], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'deepseek'); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54'); + assert.equal(result.usage!.windows.credits_balance!.usedPercent, null); + assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null); + assert.equal(result.usage!.windows.credits_balance!.resetAt, null); + }); + + test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' }, + ], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00'); + }); + + test('maps 401 to session-expired', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek'); + }); + + test('reports a normalized timeout error', async () => { + stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError'))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Request timed out'); + }); + + test('returns no-quota-data on a 200 payload with no usable balance', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + + test('keeps a literal zero balance as a valid valueLabel', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); + }); + + test('teardown: restore fs', () => { + const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; + fsMock.existsSync = ORIGINAL_FS.existsSync; + fsMock.readFileSync = ORIGINAL_FS.readFileSync; + }); +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 807d8ace..f759e351 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -124,6 +124,16 @@ type CrofPayload = { credits?: number | string; }; +type DeepseekPayload = { + is_available?: boolean; + balance_infos?: Array<{ + currency?: string; + total_balance?: number | string; + granted_balance?: number | string; + topped_up_balance?: number | string; + }>; +}; + type NeuralwattPayload = { balance?: { credits_remaining_usd?: number | string; @@ -492,6 +502,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('neuralwatt'); } + const deepseekAuth = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])); + if (deepseekAuth && ((deepseekAuth as Record).key || (deepseekAuth as Record).token)) { + configured.add('deepseek'); + } + return Array.from(configured); }; @@ -1137,6 +1152,24 @@ const fetchCopilotAddonQuota = async (): Promise => { } }; +// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail` +// blocks report `remaining` instead. Neither field is guaranteed present, so +// derive usedPercent from whichever one the API actually returned. +const computeKimiUsedPercent = ( + total: number | null, + used: number | null, + remaining: number | null, +): number | null => { + if (!total) return null; + if (used !== null) { + return Math.max(0, Math.min(100, (used / total) * 100)); + } + if (remaining !== null) { + return Math.max(0, Math.min(100, 100 - (remaining / total) * 100)); + } + return null; +}; + const fetchKimiQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record | null; @@ -1176,10 +1209,9 @@ const fetchKimiQuota = async (): Promise => { const usage = payload.usage as Record | undefined; if (usage) { const limit = toNumber(usage.limit); + const used = toNumber(usage.used); const remaining = toNumber(usage.remaining); - const usedPercent = limit && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) - : null; + const usedPercent = computeKimiUsedPercent(limit, used, remaining); windows.weekly = toUsageWindow({ usedPercent, windowSeconds: null, @@ -1195,10 +1227,9 @@ const fetchKimiQuota = async (): Promise => { const windowSeconds = durationToSeconds(window?.duration as number | undefined, window?.timeUnit as string | undefined); const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; const total = toNumber(detail?.limit); + const used = toNumber(detail?.used); const remaining = toNumber(detail?.remaining); - const usedPercent = total && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) - : null; + const usedPercent = computeKimiUsedPercent(total, used, remaining); windows[label] = toUsageWindow({ usedPercent, windowSeconds, @@ -2175,6 +2206,103 @@ const fetchCrofQuota = async (): Promise => { } }; +const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance'; + +const fetchDeepseekQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(DEEPSEEK_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with DeepSeek' + : `API error: ${response.status}`, + }); + } + + const payload = await response.json() as DeepseekPayload; + const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : []; + const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD') + ?? balanceInfos.find((info) => info?.currency === 'CNY') + ?? null; + const rawBalance = balanceInfo?.total_balance; + const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (totalBalance === null) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const symbol = balanceInfo?.currency === 'CNY' ? '¥' : '$'; + const windows: Record = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `${symbol}${formatMoney(totalBalance)}`, + }), + }; + + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + export const fetchQuotaForProvider = async (providerId: string): Promise => { switch (providerId) { case 'claude': @@ -2218,6 +2346,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise { const DEFAULT_WAIT_TIMEOUT_SECONDS = 600; const WAIT_HTTP_TIMEOUT_BUFFER_MS = 30_000; +// Provisioning a worktree is not one of the instant control calls the short +// default is sized for: it runs git against the repository and prepares a new +// directory, which on a cold path takes longer than the default allows. The +// server finishes the work regardless of the client giving up, so a client-side +// timeout here reported a failure for a worktree that was in fact created. +const WORKTREE_PROVISION_TIMEOUT_MS = 120_000; + // The control service blocks server-side while wait is set, so the client // HTTP timeout must outlive the requested wait window instead of the short // default used for instant control calls. export const resolveControlTimeoutMs = (input, options) => { if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) return options.timeoutMs; - if (input?.wait !== true) return undefined; + const provisionsWorktree = asNonEmptyString(input?.worktree) !== null; + if (input?.wait !== true) { + return provisionsWorktree ? WORKTREE_PROVISION_TIMEOUT_MS : undefined; + } const waitSeconds = Number(input?.timeout) > 0 ? Number(input.timeout) : DEFAULT_WAIT_TIMEOUT_SECONDS; - return (waitSeconds * 1000) + WAIT_HTTP_TIMEOUT_BUFFER_MS; + const waitTimeoutMs = (waitSeconds * 1000) + WAIT_HTTP_TIMEOUT_BUFFER_MS; + // The server provisions the worktree inside session creation, before it + // starts waiting for the session to go idle, so the two windows run in + // sequence rather than overlapping. The client window has to cover both. + return provisionsWorktree ? waitTimeoutMs + WORKTREE_PROVISION_TIMEOUT_MS : waitTimeoutMs; }; export const requestControlAction = async (port, action, input, options = {}) => { diff --git a/packages/web/bin/lib/cli-control.test.js b/packages/web/bin/lib/cli-control.test.js index 0e259e58..5b19853e 100644 --- a/packages/web/bin/lib/cli-control.test.js +++ b/packages/web/bin/lib/cli-control.test.js @@ -19,4 +19,19 @@ describe('resolveControlTimeoutMs', () => { it('never shrinks an explicitly requested HTTP timeout', () => { expect(resolveControlTimeoutMs({ wait: true, timeout: 30 }, { timeoutMs: 5000 })).toBe(5000); }); + + it('allows a worktree to be provisioned without waiting for the session', () => { + expect(resolveControlTimeoutMs({ worktree: 'feature' }, {})).toBe(120_000); + }); + + it('ignores a blank worktree name', () => { + expect(resolveControlTimeoutMs({ worktree: ' ' }, {})).toBeUndefined(); + }); + + it('covers provisioning and waiting in sequence when both are requested', () => { + // The server creates the worktree before it begins waiting for the session, + // so the client window must span both rather than the longer of the two. + expect(resolveControlTimeoutMs({ wait: true, timeout: 30, worktree: 'feature' }, {})).toBe(180_000); + expect(resolveControlTimeoutMs({ wait: true, worktree: 'feature' }, {})).toBe(750_000); + }); }); diff --git a/packages/web/package.json b/packages/web/package.json index f5afcf43..ba63d58f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.17.2", + "version": "1.18.0", "private": false, "type": "module", "main": "./server/index.js", @@ -28,7 +28,6 @@ "@opencode-ai/sdk": "1.18.11", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", - "better-sqlite3": "^12.10.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index ebe8c334..a3a99f62 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -1644,94 +1644,13 @@ const loadProjectStartCommand = async (projectID) => { } }; -const getProjectStoragePath = (projectID) => { - return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); -}; - -const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => { - try { - const Database = require('better-sqlite3'); - const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db'); - if (!fs.existsSync(dbPath)) return; - const db = new Database(dbPath); - try { - const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID); - if (!row) return; - const json = JSON.stringify(sandboxes); - db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID); - } finally { - db.close(); - } - } catch (error) { - console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error)); - } -}; - -const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { - const storagePath = getProjectStoragePath(projectID); - await fsp.mkdir(path.dirname(storagePath), { recursive: true }); - - const now = Date.now(); - const base = { - id: projectID, - worktree: primaryWorktree, - vcs: 'git', - sandboxes: [], - time: { - created: now, - updated: now, - }, - }; - - const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null); - const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base; - current.id = String(current.id || projectID); - current.worktree = String(current.worktree || primaryWorktree); - current.vcs = current.vcs || 'git'; - current.sandboxes = Array.isArray(current.sandboxes) - ? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean) - : []; - const createdAt = Number(current?.time?.created); - current.time = { - created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now, - updated: now, - }; - - updater(current); - - current.sandboxes = [...new Set( - (Array.isArray(current.sandboxes) ? current.sandboxes : []) - .map((entry) => String(entry || '').trim()) - .filter(Boolean) - )]; - - await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); - - // Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK - syncSandboxesToOpenCodeDb(projectID, current.sandboxes); -}; - -const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => { - const sandbox = String(sandboxPath || '').trim(); - if (!sandbox) { - return; - } - await updateProjectSandboxes(projectID, primaryWorktree, (project) => { - if (!project.sandboxes.includes(sandbox)) { - project.sandboxes.push(sandbox); - } - }); -}; - -const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => { - const sandbox = String(sandboxPath || '').trim(); - if (!sandbox) { - return; - } - await updateProjectSandboxes(projectID, primaryWorktree, (project) => { - project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox); - }); -}; +// OpenCode owns its own project/sandbox registry. It records a worktree as a +// sandbox itself when an instance boots for that directory, and filters entries +// whose directory no longer exists when reading them back. OpenChamber used to +// write that state directly into OpenCode's storage JSON and SQLite database, +// behind the back of the running process: the row changed but the server was +// never told, so a worktree created while OpenCode was running stayed unknown +// to it until a restart. Registration is not ours to perform. const isAttachedGitWorktreeDirectory = async (directory) => { try { @@ -1748,14 +1667,6 @@ const cleanupFailedFastWorktreeCreate = async (context, candidate) => { const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot; const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory); - if (!isAttached) { - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory); - } catch (error) { - console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error)); - } - } - if (!isInsideWorktreeRoot || isAttached) { return; } @@ -3940,12 +3851,6 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); - try { - await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); - } catch (error) { - console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); - } - const shouldSetUpstream = Boolean(input?.setUpstream); const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); @@ -4005,12 +3910,6 @@ export async function createWorktree(directory, input = {}) { if (input?.returnAfterDirectoryCreated === true) { await fsp.mkdir(candidate.directory, { recursive: false }); - try { - await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); - } catch (error) { - console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); - } - const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_PENDING, @@ -4103,12 +4002,6 @@ export async function removeWorktree(directory, input = {}) { await fsp.rm(targetDirectory, { recursive: true, force: true }); } - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory); - } catch (error) { - console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); - } - clearWorktreeBootstrapState(targetDirectory); return true; @@ -4131,12 +4024,6 @@ export async function removeWorktree(directory, input = {}) { } } - try { - await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree); - } catch (error) { - console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); - } - clearWorktreeBootstrapState(matchedEntry.worktree); return true; diff --git a/packages/web/server/lib/openchamber-control/DOCUMENTATION.md b/packages/web/server/lib/openchamber-control/DOCUMENTATION.md index 3d4f1477..1d15a123 100644 --- a/packages/web/server/lib/openchamber-control/DOCUMENTATION.md +++ b/packages/web/server/lib/openchamber-control/DOCUMENTATION.md @@ -32,7 +32,15 @@ other. requires observed activity or a newly completed assistant message. - Timeout and cancellation are failures, never authoritative idle results. - Validation that protects side effects runs before session creation or - dispatch. + dispatch. An explicitly requested model, agent, or variant is checked against + the directory's own OpenCode agent and provider lists before any session, + worktree, or goal is created, because `prompt_async` accepts an unusable + selection and then fails only on the event stream. A failed or empty lookup + never turns a valid selection into a rejection. +- `promptDispatched` reports an observed dispatch, never an accepted request. + After `prompt_async` the service confirms a new user message reached the + session; when it does not, the result reports `promptDispatched: false` with + `promptError` instead of claiming success. - Send and fork dispatches without an explicit model/agent/variant reuse the target session's last user-message selection before falling back to the configured defaults; only session creation resolves defaults directly. diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 79918817..0c82b1e0 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -272,6 +272,41 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated : { ok: false, status: 400, error: validated.error || 'Invalid directory' }; }; +const PROMPT_LANDED_TIMEOUT_MS = 5_000; +const PROMPT_LANDED_POLL_MS = 150; + +const latestUserMessageID = async ({ client, sessionID, directory }) => { + let response; + try { + response = await client.session.messages({ sessionID, directory, limit: 100 }); + } catch { + return { ok: false, messageID: null }; + } + const messages = Array.isArray(response?.data) ? response.data : []; + let latest = null; + for (const message of messages) { + const info = message?.info; + if (info?.role !== 'user') continue; + if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info; + } + return { ok: true, messageID: asNonEmptyString(latest?.id) }; +}; + +// `prompt_async` answers 204 as soon as OpenCode forks the run, and every later +// failure is reported only on the session event stream. Confirm the prompt was +// actually recorded so `promptDispatched` never claims a dispatch that vanished. +const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => { + const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS; + for (;;) { + const latest = await latestUserMessageID({ client, sessionID, directory }); + // A failed lookup is not authoritative evidence that the prompt was lost. + if (!latest.ok) return true; + if (latest.messageID && latest.messageID !== baselineUserMessageID) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS)); + } +}; + const resolveWorktreeInput = (payload) => { if (!payload?.worktree || typeof payload.worktree !== 'object') return null; const name = asNonEmptyString(payload.worktree.name); @@ -322,6 +357,48 @@ export const createOpenChamberSessionService = (dependencies) => { return null; }; + // Explicit model/agent/variant are never checked by `prompt_async`: an unknown + // agent makes the forked run fail silently, leaving a session with no message. + // Reject them before any session, worktree, or goal side effect happens. + const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => { + if (!requestedModel && !requestedAgent && !requestedVariant) return; + const authHeaders = getOpenCodeAuthHeaders(); + const { providers, agents } = await fetchSelectionInputs({ + buildOpenCodeUrl, + authHeaders, + directory, + readSettingsFromDiskMigrated, + }); + + // An empty list means the lookup failed or returned nothing authoritative; + // it must not turn a valid selection into a rejection. + if (requestedAgent && agents.length > 0) { + const agent = agents.find((entry) => entry?.name === requestedAgent) || null; + if (!agent) { + throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400); + } + if (!isPrimaryAgentMode(agent.mode)) { + throw new OpenChamberControlError(`Agent '${requestedAgent}' is a subagent and cannot receive a prompt directly`, 400); + } + } + + if (requestedModel && providers.length > 0) { + if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) { + throw new OpenChamberControlError( + `Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`, + 400, + ); + } + if (requestedVariant + && !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) { + throw new OpenChamberControlError( + `Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`, + 400, + ); + } + } + }; + const dispatchPrompt = async ({ client, baseUrl, @@ -417,6 +494,7 @@ export const createOpenChamberSessionService = (dependencies) => { throw markGoalPartial(error); } } else { + const baseline = await latestUserMessageID({ client, sessionID, directory }); try { await runPromptAsync({ baseUrl, @@ -438,6 +516,22 @@ export const createOpenChamberSessionService = (dependencies) => { } catch (error) { throw markGoalPartial(error); } + const landed = await waitForPromptLanded({ + client, + sessionID, + directory, + baselineUserMessageID: baseline.messageID, + }); + if (!landed) { + return { + model, + agent, + variant, + promptDispatched: false, + dispatchedAsCommand: false, + promptError: 'OpenCode accepted the prompt but it never appeared in the session', + }; + } } return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) }; @@ -470,13 +564,23 @@ export const createOpenChamberSessionService = (dependencies) => { if (payload?.worktree && !worktreeInput) { throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400); } + + if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); + + if (prompt) { + await validateRequestedSelection({ + directory: resolvedDirectory.directory, + requestedModel: model, + requestedAgent: agent, + requestedVariant: variant, + }); + } + if (worktreeInput) { worktree = await createWorktree(resolvedDirectory.directory, worktreeInput); sessionDirectory = worktree.path; } - if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); - const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); const authHeaders = getOpenCodeAuthHeaders(); const client = createOpencodeClient({ baseUrl, headers: authHeaders }); @@ -514,6 +618,7 @@ export const createOpenChamberSessionService = (dependencies) => { ...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}), ...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}), promptDispatched: dispatch.promptDispatched, + ...(dispatch.promptError ? { promptError: dispatch.promptError } : {}), dispatchedAsCommand: dispatch.dispatchedAsCommand, ...(goalInput.enabled ? { goalEnabled: true } : {}), ...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}), @@ -566,6 +671,13 @@ export const createOpenChamberSessionService = (dependencies) => { directory = resolvedDirectory.directory; if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); + await validateRequestedSelection({ + directory, + requestedModel, + requestedAgent: asNonEmptyString(payload.agent), + requestedVariant: asNonEmptyString(payload.variant), + }); + const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); const authHeaders = getOpenCodeAuthHeaders(); const client = createOpencodeClient({ baseUrl, headers: authHeaders }); @@ -608,7 +720,8 @@ export const createOpenChamberSessionService = (dependencies) => { model: dispatch.model, ...(dispatch.agent ? { agent: dispatch.agent } : {}), ...(dispatch.variant ? { variant: dispatch.variant } : {}), - promptDispatched: true, + promptDispatched: dispatch.promptDispatched, + ...(dispatch.promptError ? { promptError: dispatch.promptError } : {}), dispatchedAsCommand: dispatch.dispatchedAsCommand, ...(goalInput.enabled ? { goalEnabled: true } : {}), ...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}), @@ -624,7 +737,7 @@ export const createOpenChamberSessionService = (dependencies) => { model: dispatch.model, ...(dispatch.agent ? { agent: dispatch.agent } : {}), ...(dispatch.variant ? { variant: dispatch.variant } : {}), - promptDispatched: true, + promptDispatched: dispatch.promptDispatched, dispatchedAsCommand: dispatch.dispatchedAsCommand, ...(goalInput.enabled ? { goalEnabled: true } : {}), ...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}), diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index e781fc62..8b964fb6 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -11,6 +11,53 @@ const createWorktreeMock = vi.fn(async () => ({ const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } })); const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } })); const sessionMessagesMock = vi.fn(async () => ({ data: [] })); + +let existingSessionMessages = []; +let dispatchedUserMessageSeq = 0; + +// The service confirms a prompt landed by watching for a new user message, so +// the default mock behaves like OpenCode recording each dispatched prompt. +const setSessionMessages = (messages) => { + existingSessionMessages = messages; +}; + +const recordedSessionMessages = async () => { + dispatchedUserMessageSeq += 1; + return { + data: [ + ...existingSessionMessages, + { + info: { + id: `msg_dispatched_${dispatchedUserMessageSeq}`, + role: 'user', + time: { created: 1000 + dispatchedUserMessageSeq }, + }, + }, + ], + }; +}; + +// Selection inputs are fetched whenever a request names a model, agent, or +// variant, so every prompt-dispatching fetch mock must answer them. +const selectionInputResponse = (url) => { + const text = String(url); + if (text.includes('/config/providers')) { + return { + ok: true, + json: async () => ({ + providers: [ + { id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] }, + { id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] }, + ], + }), + }; + } + if (text.includes('/agent')) { + return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] }; + } + if (text.includes('/config')) return { ok: true, json: async () => ({}) }; + return null; +}; const sessionCommandMock = vi.fn(async () => ({ data: {} })); const commandListMock = vi.fn(async () => ({ data: [] })); globalThis.__openchamberCreateWorktreeMock = createWorktreeMock; @@ -62,8 +109,10 @@ describe('openchamber session routes', () => { createWorktreeMock.mockClear(); sessionCreateMock.mockClear(); sessionForkMock.mockClear(); + existingSessionMessages = []; + dispatchedUserMessageSeq = 0; sessionMessagesMock.mockReset(); - sessionMessagesMock.mockResolvedValue({ data: [] }); + sessionMessagesMock.mockImplementation(recordedSessionMessages); sessionCommandMock.mockReset(); sessionCommandMock.mockResolvedValue({ data: {} }); commandListMock.mockReset(); @@ -319,13 +368,11 @@ describe('openchamber session routes', () => { it('sends a goal prompt to an existing session after creating goal metadata', async () => { const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' })); + const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' }); const createSessionGoal = vi.fn(async () => undefined); globalThis.fetch = fetchMock; try { - sessionMessagesMock.mockResolvedValue({ - data: [{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }], - }); + setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]); const { app } = createApp({ createSessionGoal }); const response = await request(app) .post('/api/openchamber/sessions/ses_source/send') @@ -370,7 +417,7 @@ describe('openchamber session routes', () => { template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.', }], }); - globalThis.fetch = vi.fn(); + globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url)); try { const { app } = createApp({ createSessionGoal }); const response = await request(app) @@ -393,7 +440,7 @@ describe('openchamber session routes', () => { })); expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]); expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true }); - expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false); } finally { globalThis.fetch = originalFetch; } @@ -401,11 +448,10 @@ describe('openchamber session routes', () => { it('reuses the previous session selection when send omits model, agent, and variant', async () => { const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' })); + const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' }); globalThis.fetch = fetchMock; try { - sessionMessagesMock.mockResolvedValue({ - data: [ + setSessionMessages([ { info: { id: 'msg_user', @@ -416,8 +462,7 @@ describe('openchamber session routes', () => { }, }, { info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }, - ], - }); + ]); const { app } = createApp(); const response = await request(app) .post('/api/openchamber/sessions/ses_source/send') @@ -449,7 +494,7 @@ describe('openchamber session routes', () => { it('forks from a message, dispatches the prompt, and emits the new session', async () => { const originalFetch = globalThis.fetch; const emitSessionCreatedEvent = vi.fn(); - globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => '' })); + globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' }); try { const { app } = createApp({ emitSessionCreatedEvent }); const response = await request(app) @@ -519,7 +564,7 @@ describe('openchamber session routes', () => { it('reports the forked session when prompt dispatch fails', async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'dispatch failed' })); + globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' }); try { const { app } = createApp(); const response = await request(app) @@ -584,9 +629,77 @@ describe('openchamber session routes', () => { } }); + it('rejects an unknown agent before creating a session or worktree', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) }); + globalThis.fetch = fetchMock; + try { + const { app } = createApp(); + await request(app) + .post('/api/openchamber/sessions') + .send({ + directory: '/repo/app', + prompt: 'Run this', + agent: 'not-an-agent', + worktree: { name: 'side-task' }, + }) + .expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" }); + + expect(createWorktreeMock).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects an unknown model and an unknown variant before dispatching', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) }); + globalThis.fetch = fetchMock; + try { + const { app } = createApp(); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' }) + .expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" }); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' }) + .expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" }); + + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('reports promptDispatched false when the accepted prompt never reaches the session', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (url) => { + if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' }; + return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) }; + }); + globalThis.fetch = fetchMock; + sessionMessagesMock.mockResolvedValue({ data: [] }); + try { + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' }) + .expect(200); + + expect(response.body.sessionId).toBe('ses_123'); + expect(response.body.promptDispatched).toBe(false); + expect(response.body.promptError).toBeTruthy(); + } finally { + globalThis.fetch = originalFetch; + } + }, 20_000); + it('does not retry a failed slash command as a normal prompt', async () => { const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(); + const fetchMock = vi.fn(async (url) => selectionInputResponse(url)); commandListMock.mockResolvedValue({ data: [{ name: 'review' }] }); sessionCommandMock.mockRejectedValue(new Error('command response failed')); globalThis.fetch = fetchMock; @@ -604,7 +717,7 @@ describe('openchamber session routes', () => { .expect(500); expect(sessionCommandMock).toHaveBeenCalledTimes(1); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 475ff2cf..2725225d 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -20,6 +20,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` | | `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import | | `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) | +| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) | | `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file | | `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` | | `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` | @@ -70,6 +71,14 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo - **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent. - **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows. +## Kimi for Coding field semantics + +`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption: +- The weekly `usage` block returns `used` (consumed) with no `remaining` field. +- Each `limits[].detail` rate-limit block returns `remaining` (available) with no `used` field. + +The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it. + ## Notes for contributors - Keep provider IDs stable; clients use them directly. - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. diff --git a/packages/web/server/lib/quota/index.js b/packages/web/server/lib/quota/index.js index 7fab4cf5..d675251c 100644 --- a/packages/web/server/lib/quota/index.js +++ b/packages/web/server/lib/quota/index.js @@ -13,6 +13,7 @@ export { fetchGoogleQuota, fetchCodexQuota, fetchCursorQuota, + fetchDeepseekQuota, fetchCopilotQuota, fetchCopilotAddonQuota, fetchKimiQuota, diff --git a/packages/web/server/lib/quota/providers/deepseek.js b/packages/web/server/lib/quota/providers/deepseek.js new file mode 100644 index 00000000..8963ca82 --- /dev/null +++ b/packages/web/server/lib/quota/providers/deepseek.js @@ -0,0 +1,118 @@ +import { readAuthFile } from '../../opencode/auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + formatMoney +} from '../utils/index.js'; + +export const providerId = 'deepseek'; +export const providerName = 'DeepSeek'; +const aliases = ['deepseek']; +const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance'; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(DEEPSEEK_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with DeepSeek' + : `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : []; + const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD') + ?? balanceInfos.find((info) => info?.currency === 'CNY') + ?? null; + const rawBalance = balanceInfo?.total_balance; + const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (totalBalance === null) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); + } + + const isCny = balanceInfo?.currency === 'CNY'; + const symbol = isCny ? '¥' : '$'; + const valueLabel = `${symbol}${formatMoney(totalBalance)}`; + + const windows = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel + }) + }; + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed') + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/deepseek.test.js b/packages/web/server/lib/quota/providers/deepseek.test.js new file mode 100644 index 00000000..a133bb6d --- /dev/null +++ b/packages/web/server/lib/quota/providers/deepseek.test.js @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ deepseek: { key: 'test-token' } }), +})); + +import { fetchQuota } from './deepseek.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +// Documented payload shape from https://api.deepseek.com/user/balance +const DOCUMENTED_PAYLOAD = { + is_available: true, + balance_infos: [ + { + currency: 'USD', + total_balance: '7.54', + granted_balance: '0.00', + topped_up_balance: '7.54' + } + ] +}; + +describe('DeepSeek quota provider', () => { + it('builds credits_balance window from documented USD payload (string balance)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.providerId).toBe('deepseek'); + + const window = result.usage.windows.credits_balance; + expect(window).toBeDefined(); + expect(window.valueLabel).toBe('$7.54'); + expect(window.usedPercent).toBeNull(); + expect(window.windowSeconds).toBeNull(); + expect(window.resetAt).toBeNull(); + }); + + it('falls back to CNY entry when no USD entry is present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' } + ] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('¥100.00'); + }); + + it('prefers the USD entry when both USD and CNY are present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' }, + { currency: 'USD', total_balance: '3.55', granted_balance: '0.00', topped_up_balance: '3.55' } + ] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$3.55'); + }); + + it('tolerates a numeric total_balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: 12.5, granted_balance: 0, topped_up_balance: 12.5 }] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$12.50'); + }); + + it('maps 401 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Session expired — please re-authenticate with DeepSeek'); + }); + + it('maps 403 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Session expired — please re-authenticate with DeepSeek'); + }); + + it('reports invalid-response on JSON parse failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { throw new SyntaxError('Unexpected token'); }, + })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Invalid response from provider'); + }); + + it('reports a normalized timeout error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Request timed out'); + }); + + it('returns no-quota-data on a 200 payload with no usable balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('No quota data in response'); + expect(result.usage).toBeNull(); + }); + + it('keeps a literal zero balance as a valid valueLabel', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 17bb6a9c..3d37e889 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -12,6 +12,7 @@ import * as codex from './codex.js'; import * as copilot from './copilot.js'; import * as crof from './crof.js'; import * as cursor from './cursor.js'; +import * as deepseek from './deepseek.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; @@ -51,6 +52,12 @@ const registry = { isConfigured: cursor.isConfigured, fetchQuota: cursor.fetchQuota }, + deepseek: { + providerId: deepseek.providerId, + providerName: deepseek.providerName, + isConfigured: deepseek.isConfigured, + fetchQuota: deepseek.fetchQuota + }, google: { providerId: google.providerId, providerName: google.providerName, @@ -184,6 +191,7 @@ export const fetchOpenaiQuota = openai.fetchQuota; export const fetchGoogleQuota = google.fetchGoogleQuota; export const fetchCodexQuota = codex.fetchQuota; export const fetchCursorQuota = cursor.fetchQuota; +export const fetchDeepseekQuota = deepseek.fetchQuota; export const fetchCopilotQuota = copilot.fetchQuota; export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; export const fetchKimiQuota = kimi.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/kimi.js b/packages/web/server/lib/quota/providers/kimi.js index a9d6c893..2ebd34a6 100644 --- a/packages/web/server/lib/quota/providers/kimi.js +++ b/packages/web/server/lib/quota/providers/kimi.js @@ -14,6 +14,20 @@ export const providerId = 'kimi-for-coding'; export const providerName = 'Kimi for Coding'; const aliases = ['kimi-for-coding', 'kimi']; +// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail` +// blocks report `remaining` instead. Neither field is guaranteed present, so +// derive usedPercent from whichever one the API actually returned. +const computeUsedPercent = (total, used, remaining) => { + if (!total) return null; + if (used !== null) { + return Math.max(0, Math.min(100, (used / total) * 100)); + } + if (remaining !== null) { + return Math.max(0, Math.min(100, 100 - (remaining / total) * 100)); + } + return null; +}; + export const isConfigured = () => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); @@ -59,10 +73,9 @@ export const fetchQuota = async () => { const usage = payload?.usage ?? null; if (usage) { const limit = toNumber(usage.limit); + const used = toNumber(usage.used); const remaining = toNumber(usage.remaining); - const usedPercent = limit && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) - : null; + const usedPercent = computeUsedPercent(limit, used, remaining); windows.weekly = toUsageWindow({ usedPercent, windowSeconds: null, @@ -78,10 +91,9 @@ export const fetchQuota = async () => { const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit); const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; const total = toNumber(detail?.limit); + const used = toNumber(detail?.used); const remaining = toNumber(detail?.remaining); - const usedPercent = total && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) - : null; + const usedPercent = computeUsedPercent(total, used, remaining); windows[label] = toUsageWindow({ usedPercent, windowSeconds, diff --git a/packages/web/server/lib/quota/providers/kimi.test.js b/packages/web/server/lib/quota/providers/kimi.test.js new file mode 100644 index 00000000..c2eb3b00 --- /dev/null +++ b/packages/web/server/lib/quota/providers/kimi.test.js @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ 'kimi-for-coding': { key: 'test-token' } }), +})); + +import { fetchQuota } from './kimi.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +describe('Kimi for Coding quota provider', () => { + it('computes weekly usedPercent from the used field (live API shape, no remaining field)', async () => { + // Captured from GET https://api.kimi.com/coding/v1/usages — the weekly + // `usage` block only ever includes `used`, never `remaining`. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ + usage: { limit: '100', used: '100', resetTime: '2026-08-04T06:21:48.514003Z' }, + limits: [{ + window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' }, + detail: { limit: '100', remaining: '100', resetTime: '2026-08-03T07:21:48.514003Z' }, + }], + }), + )); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.weekly.usedPercent).toBe(100); + expect(result.usage.windows['Rate Limit (300m)'].usedPercent).toBe(0); + }); + + it('falls back to computing usedPercent from remaining when used is absent', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ + usage: { limit: '2048', remaining: '512', resetTime: '2026-08-04T06:21:48.514003Z' }, + limits: [], + }), + )); + + const result = await fetchQuota(); + + expect(result.usage.windows.weekly.usedPercent).toBe(75); + }); + + it('prefers used over remaining when both fields are present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ + usage: { limit: '100', used: '30', remaining: '999', resetTime: null }, + limits: [], + }), + )); + + const result = await fetchQuota(); + + expect(result.usage.windows.weekly.usedPercent).toBe(30); + }); + + it('reports null usedPercent when neither used nor remaining is present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ + usage: { limit: '100', resetTime: null }, + limits: [], + }), + )); + + const result = await fetchQuota(); + + expect(result.usage.windows.weekly.usedPercent).toBeNull(); + }); + + it('reports not configured when no credentials are stored', async () => { + vi.doMock('../../opencode/auth.js', () => ({ readAuthFile: () => ({}) })); + vi.resetModules(); + const { fetchQuota: fetchQuotaFresh } = await import('./kimi.js'); + + const result = await fetchQuotaFresh(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(false); + expect(result.error).toBe('Not configured'); + + vi.doUnmock('../../opencode/auth.js'); + vi.resetModules(); + }); + + it('surfaces API errors with status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({}), + })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('API error: 401'); + }); +}); diff --git a/scripts/perf/DOCUMENTATION.md b/scripts/perf/DOCUMENTATION.md new file mode 100644 index 00000000..1303eb4c --- /dev/null +++ b/scripts/perf/DOCUMENTATION.md @@ -0,0 +1,170 @@ +# Performance Measurement Tooling + +Owns the unattended performance capture commands and their shared Chrome +DevTools Protocol plumbing. Read this before measuring OpenChamber performance +or extending these scripts. The methodology rules they enforce come from +`.agents/skills/performance-engineering/SKILL.md`. + +## Commands + +| Command | Answers | +|---|---| +| `bun run profile:idle` | What the app does while nobody interacts with it. | +| `bun run profile:session` | What receiving and rendering a live assistant response costs. | +| `bun run profile:animation` | What a CSS animation costs, isolated from the app. | +| `bun run profile:browser` | A manually driven capture, for interactions that cannot be scripted. | + +All of them measure a real browser over CDP. Pass `--help` to any of them for +the full option list. + +## Before Measuring Anything + +**Measure a production build.** A development build's render and bundle +behaviour does not represent what users run. + +```bash +bun run build:ui && bun run build:web +cd && node /packages/web/bin/cli.js serve --port 4599 --foreground +``` + +`profile:idle` and `profile:session` need a running server; `profile:animation` +serves its own fixture and needs nothing. + +## profile:idle + +Loads the app, lets it settle, then records a window during which no input is +delivered. Everything it reports is therefore work the app performs while the +user is doing nothing — the class of regression users notice as fan noise, +battery drain, and a permanently busy tab. + +Reports per second of idle time: main-thread busy time, script, style +recalculation and layout time and counts, DOM node / document / frame / +listener growth, heap trajectory including a least-squares growth rate, a CPU +sampling profile with self time per function, and attribution of timer, +animation-frame and observer work to the call site that scheduled it. + +```bash +# Baseline, then compare a change against it and fail on a budget. +bun run profile:idle -- --url http://127.0.0.1:4599 --output artifacts/before +bun run profile:idle -- --url http://127.0.0.1:4599 --baseline artifacts/before --budget-cpu 5 +``` + +Scenario options reach a specific mounted state, because idle cost depends on +what is mounted: `--session`, `--tab`, `--panel `, `--expand-projects`, +`--expand-sessions`, and `--then-tab` (navigate away after settling, to measure +what a surface keeps doing once the user has left it). + +## profile:session + +Creates a session, opens it in a browser, dispatches a prompt through the +supported `openchamber session` CLI, and records until the session reports +itself idle. No input is synthesised; the prompt is the only stimulus. + +Streaming is judged by responsiveness, not totals, so the report leads with the +long-task distribution, a timeline-trace breakdown naming where time went, +running animations, the application's own stream counters, and output-normalised +metrics. + +```bash +bun run profile:session -- --url http://127.0.0.1:4599 --dir +# What an idle session costs while a different session is active elsewhere: +bun run profile:session -- --view-session --expand-projects --expand-sessions +``` + +This command calls a real model. Use a cheap one; `--model` overrides the +configured selection. + +## profile:animation + +Serves an isolated fixture and measures each animation variant directly, so a +comparison takes seconds instead of an application rebuild plus a streamed +response. + +```bash +bun run profile:animation +bun run profile:animation -- --variant border-color --count 8 +``` + +Measured on this repository's fixture, at any element count from 1 to 32: + +| Animated property | Style recalculations/sec | Layouts/sec | +|---|---|---| +| none | 0 | 0 | +| `transform` (rotate, translate, scale) | 0 | 0 | +| `opacity`, `filter` | 0 | 0 | +| `rotate` (the individual property) | 60 | 0 | +| `background-position` | 60 | 0 | +| `border-color` | 60 | 0 | +| `box-shadow` | 60 | 0 | +| `width` | 60 | 60 | + +Animate `transform` and `opacity`. Anything else recalculates style on every +frame for as long as the animation runs, and geometry properties add layout on +top. Note that `rotate: 360deg` is *not* equivalent to +`transform: rotate(360deg)` in cost. + +Add a variant to `animation-fixture.html` to measure a property or technique +that is not listed. + +## Reading The Results + +Every run writes a JSON summary next to any raw capture, so results can be +compared later without re-running: + +- `profile:idle` → `idle-summary.json`, `cpu-profile.cpuprofile` +- `profile:session` → `session-summary.json`, `cpu-profile.cpuprofile` + +`--baseline ` prints a per-metric delta table against a previous run +of the same command. `--budget-*` options make the command exit non-zero, so the +same invocation works as an investigation tool and as a regression gate. + +Artifacts can reveal project paths and endpoint names. They are gitignored; do +not publish them without review. + +## Validity Guarantees + +These commands fail loudly rather than reporting a clean result, because each +of these failure modes once produced a confident, wrong "everything is fast": + +- **Throttled renderer.** Chrome stops producing frames and throttles timers for + windows it considers backgrounded or occluded. Launch flags disable that, and + every run measures frame liveness and warns when the renderer was not + producing frames. +- **Missing trace data.** `RunTask` is only emitted under the + disabled-by-default timeline category. A capture without it would report zero + long tasks; the missing-task case is reported instead. +- **A scenario that never ran.** A session belonging to a directory the browser + is not viewing renders nothing and produces a perfectly quiet profile. + `profile:session` verifies both new message elements in the DOM and + message-list render counters before believing a quiet result. + +Preserve this property when extending these scripts. A metric reading zero must +be a measurement, never a disabled instrument. + +## Methodology Rules + +- **Never report an "after" without a "before" on the identical scenario and + build.** Rebuild the unchanged version and re-run it, however inconvenient. + Expect plausible fixes to change nothing. +- **A sampling profiler cannot explain native work.** Self time in `(program)` + only means the time was not in interpreted JavaScript. Use the trace + breakdown, which names parsing, style, layout, layerization, paint and raster. +- **Normalise when the workload varies.** Assistant responses differ in length + between runs, so per-second totals are not comparable; `profile:session` + reports output-normalised metrics for this reason. +- **Revert what you cannot measure.** A change that does not move its target + metric is unvalidated complexity, not a small win. +- **Reproduction may need production scale you do not have.** A threshold effect + is invisible below its threshold. Compare the reporter's scale against yours + on the dimension the code keys on before concluding a bug is absent. + +## Module Layout + +| File | Responsibility | +|---|---| +| `cdp.mjs` | Chrome launch, target discovery, minimal CDP client. Owns the anti-throttling launch flags. | +| `metrics.mjs` | Metric derivations shared by the profilers: growth rates, percentiles, long-task and trace-event summaries. | +| `cpu-profile.mjs` | Aggregates `Profiler.stop()` output into self time per function. | +| `idle-probe.mjs` | Page-side instrumentation installed before application code runs; attributes scheduled work to the call site that scheduled it. Must never change observable behaviour. | +| `scenario.mjs` | Shared scenario setup, currently sidebar expansion. Setup always runs before the measured window. | +| `animation-fixture.html` | Isolated animation variants for `profile:animation`. | diff --git a/scripts/perf/animation-fixture.html b/scripts/perf/animation-fixture.html new file mode 100644 index 00000000..a4b08ce0 --- /dev/null +++ b/scripts/perf/animation-fixture.html @@ -0,0 +1,133 @@ + + + + +OpenChamber animation cost fixture + + + +
+ + + diff --git a/scripts/perf/cdp.mjs b/scripts/perf/cdp.mjs new file mode 100644 index 00000000..4be7886d --- /dev/null +++ b/scripts/perf/cdp.mjs @@ -0,0 +1,191 @@ +/** + * Shared Chrome DevTools Protocol helpers for OpenChamber performance tooling. + * + * `scripts/profile-browser.mjs` and `scripts/profile-idle.mjs` both drive Chrome + * over CDP. Launching, target discovery, and the minimal protocol client live + * here so both entry points stay thin and behave identically. + */ + +import { spawn } from "node:child_process" +import { createServer } from "node:net" +import { existsSync } from "node:fs" +import { platform } from "node:os" +import { join, resolve } from "node:path" +import process from "node:process" + +export const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)) + +const chromeCandidates = () => { + if (platform() === "darwin") { + return [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + } + if (platform() === "win32") { + return [ + join(process.env.PROGRAMFILES ?? "", "Google/Chrome/Application/chrome.exe"), + join(process.env["PROGRAMFILES(X86)"] ?? "", "Google/Chrome/Application/chrome.exe"), + join(process.env.LOCALAPPDATA ?? "", "Google/Chrome/Application/chrome.exe"), + ] + } + return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"] +} + +export const resolveChrome = (explicit) => { + if (explicit) { + const candidate = resolve(explicit) + if (!existsSync(candidate)) throw new Error(`Chrome executable not found: ${candidate}`) + return candidate + } + const candidate = chromeCandidates().find((path) => path && existsSync(path)) + if (!candidate) throw new Error("Chrome/Chromium was not found. Pass its path with --chrome.") + return candidate +} + +export const reservePort = () => new Promise((resolvePort, reject) => { + const server = createServer() + server.unref() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close() + reject(new Error("Could not reserve a Chrome debugging port")) + return + } + const port = address.port + server.close(() => resolvePort(port)) + }) +}) + +const waitForJson = async (url, timeoutMs = 15_000) => { + const deadline = Date.now() + timeoutMs + let lastError + while (Date.now() < deadline) { + try { + const response = await fetch(url) + if (response.ok) return await response.json() + } catch (error) { + lastError = error + } + await wait(100) + } + throw new Error(`Chrome debugging endpoint did not start: ${lastError?.message ?? url}`) +} + +export const createPageTarget = async (port) => { + const baseUrl = `http://127.0.0.1:${port}` + await waitForJson(`${baseUrl}/json/version`) + + try { + const response = await fetch(`${baseUrl}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" }) + if (response.ok) { + const target = await response.json() + if (target?.type === "page" && target.webSocketDebuggerUrl) return target + } + } catch { + // Some Chromium variants do not expose /json/new; use their startup page. + } + + const targets = await waitForJson(`${baseUrl}/json`) + const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl) + if (!target) throw new Error("Chrome did not expose or create a page target") + return target +} + +/** + * Chrome throttles timers and stops producing frames for windows it considers + * backgrounded or occluded. A profiling run must never silently measure a + * throttled renderer, so occlusion and background throttling are disabled for + * every launch: without these the idle report shows zero layouts per second + * regardless of how much work the page actually schedules. + */ +const ANTI_THROTTLING_ARGS = [ + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling", +] + +export const launchChrome = ({ chrome, profileDir, port, headless, extraArgs = [] }) => { + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + ...ANTI_THROTTLING_ARGS, + ...extraArgs, + "about:blank", + ] + if (headless) args.unshift("--headless=new", "--disable-gpu") + return spawn(chrome, args, { stdio: "ignore" }) +} + +export class CdpClient { + constructor(url) { + this.socket = new WebSocket(url) + this.nextId = 1 + this.pending = new Map() + this.listeners = new Map() + } + + async connect() { + await new Promise((resolveConnect, reject) => { + this.socket.addEventListener("open", resolveConnect, { once: true }) + this.socket.addEventListener("error", reject, { once: true }) + }) + this.socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)) + if (message.id) { + const pending = this.pending.get(message.id) + if (!pending) return + this.pending.delete(message.id) + if (message.error) pending.reject(new Error(message.error.message)) + else pending.resolve(message.result ?? {}) + return + } + for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {}) + }) + } + + send(method, params = {}) { + const id = this.nextId++ + return new Promise((resolveSend, reject) => { + this.pending.set(id, { resolve: resolveSend, reject: reject }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + on(method, listener) { + const listeners = this.listeners.get(method) ?? new Set() + listeners.add(listener) + this.listeners.set(method, listeners) + return () => listeners.delete(listener) + } + + once(method, timeoutMs = 15_000) { + return new Promise((resolveEvent, reject) => { + const timeout = setTimeout(() => { + unsubscribe() + reject(new Error(`Timed out waiting for ${method}`)) + }, timeoutMs) + const unsubscribe = this.on(method, (params) => { + clearTimeout(timeout) + unsubscribe() + resolveEvent(params) + }) + }) + } + + close() { + this.socket.close() + } +} + +export const evaluateValue = async (client, expression) => { + const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }) + return result.result?.value ?? null +} diff --git a/scripts/perf/cpu-profile.mjs b/scripts/perf/cpu-profile.mjs new file mode 100644 index 00000000..6f96ae8a --- /dev/null +++ b/scripts/perf/cpu-profile.mjs @@ -0,0 +1,71 @@ +/** + * Aggregation helpers for `Profiler.stop()` CPU profiles. + * + * The sampling profiler answers "which functions burned the main thread while + * nobody touched the app", which is the question the idle report exists to + * answer. Self time is derived from the sample stream rather than from + * `hitCount`, because sample deltas carry the actual elapsed time. + */ + +const frameLabel = (callFrame) => { + const name = callFrame.functionName || "(anonymous)" + const url = callFrame.url || "(native)" + const shortUrl = url.replace(/^https?:\/\/[^/]+/, "") + return `${name} @ ${shortUrl}:${callFrame.lineNumber + 1}` +} + +/** + * @param {{nodes: Array, samples: Array, timeDeltas: Array}} profile + * @param {number} topCount + */ +export const summarizeCpuProfile = (profile, topCount = 25) => { + const nodes = new Map() + for (const node of profile?.nodes ?? []) nodes.set(node.id, node) + + const samples = profile?.samples ?? [] + const timeDeltas = profile?.timeDeltas ?? [] + + const selfMicros = new Map() + let totalMicros = 0 + let idleMicros = 0 + let gcMicros = 0 + let programMicros = 0 + + for (let index = 0; index < samples.length; index += 1) { + // `timeDeltas[i]` is the interval preceding sample `i`. + const delta = Math.max(0, Number(timeDeltas[index] ?? 0)) + totalMicros += delta + const node = nodes.get(samples[index]) + if (!node) continue + const name = node.callFrame?.functionName + if (name === "(idle)") { + idleMicros += delta + continue + } + if (name === "(garbage collector)") gcMicros += delta + if (name === "(program)") programMicros += delta + const label = frameLabel(node.callFrame ?? {}) + selfMicros.set(label, (selfMicros.get(label) ?? 0) + delta) + } + + const busyMicros = totalMicros - idleMicros + const toMs = (micros) => Number((micros / 1000).toFixed(2)) + + return { + sampleCount: samples.length, + totalMs: toMs(totalMicros), + idleMs: toMs(idleMicros), + busyMs: toMs(busyMicros), + busyPercent: totalMicros > 0 ? Number(((busyMicros / totalMicros) * 100).toFixed(2)) : 0, + garbageCollectorMs: toMs(gcMicros), + programMs: toMs(programMicros), + topSelfTime: [...selfMicros.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, topCount) + .map(([label, micros]) => ({ + function: label, + selfMs: toMs(micros), + percentOfBusy: busyMicros > 0 ? Number(((micros / busyMicros) * 100).toFixed(2)) : 0, + })), + } +} diff --git a/scripts/perf/idle-probe.mjs b/scripts/perf/idle-probe.mjs new file mode 100644 index 00000000..0f11e829 --- /dev/null +++ b/scripts/perf/idle-probe.mjs @@ -0,0 +1,218 @@ +/** + * Page-side idle instrumentation. + * + * `buildIdleProbeSource()` returns a self-contained script installed with + * `Page.addScriptToEvaluateOnNewDocument`, so it wraps the scheduling APIs + * before any application module runs. The probe attributes wall time to the + * call site that scheduled the work, which is what identifies a background + * loop that keeps running while the user is idle. + * + * Design constraints: + * - The probe must not change observable behaviour: every wrapper forwards + * arguments and return values unchanged, and preserves handle identity so + * `clearInterval`/`cancelAnimationFrame` keep working. + * - Stack capture happens at schedule time, not at fire time, and stops after + * a bounded number of captures so instrumentation cannot become the + * bottleneck it is measuring. + */ + +export const IDLE_PROBE_GLOBAL = "__openchamberIdleProbe" + +const probeFactory = function installOpenchamberIdleProbe(globalName, stackCaptureBudget) { + if (globalThis[globalName]) return + + const now = () => performance.now() + const sites = new Map() + let stackCaptures = 0 + let recording = false + + const siteFromStack = (kind) => { + if (stackCaptures >= stackCaptureBudget) return `${kind} ` + stackCaptures += 1 + const stack = new Error().stack ?? "" + const lines = stack.split("\n") + for (const line of lines) { + // Skip the Error line and every frame belonging to the probe itself. + if (!line.includes("http")) continue + if (line.includes("installOpenchamberIdleProbe")) continue + const match = line.match(/\(?((?:https?:)\/\/[^\s)]+)\)?$/) + const location = match ? match[1] : line.trim() + const name = line.trim().replace(/^at\s+/, "").split(" (")[0] + return `${kind} ${name} @ ${location}` + } + return `${kind} ` + } + + const record = (site, elapsedMs) => { + if (!recording) return + let entry = sites.get(site) + if (!entry) { + entry = { site, calls: 0, totalMs: 0, maxMs: 0 } + sites.set(site, entry) + } + entry.calls += 1 + entry.totalMs += elapsedMs + if (elapsedMs > entry.maxMs) entry.maxMs = elapsedMs + } + + const counters = { + setTimeoutScheduled: 0, + setIntervalScheduled: 0, + rafScheduled: 0, + idleCallbackScheduled: 0, + listenersAdded: 0, + listenersRemoved: 0, + mutationRecords: 0, + resizeEntries: 0, + intersectionEntries: 0, + fetches: 0, + postMessages: 0, + } + const listenerTypes = new Map() + const bump = (map, key, amount) => map.set(key, (map.get(key) ?? 0) + amount) + + const wrapCallback = (callback, site) => { + if (typeof callback !== "function") return callback + return function instrumentedIdleProbeCallback(...args) { + const started = now() + try { + return callback.apply(this, args) + } finally { + record(site, now() - started) + } + } + } + + const nativeSetTimeout = globalThis.setTimeout + const nativeSetInterval = globalThis.setInterval + const nativeRaf = globalThis.requestAnimationFrame + const nativeIdle = globalThis.requestIdleCallback + + globalThis.setTimeout = function setTimeout(handler, timeout, ...rest) { + counters.setTimeoutScheduled += 1 + if (typeof handler !== "function") return nativeSetTimeout.call(this, handler, timeout, ...rest) + const site = siteFromStack(`setTimeout(${Number(timeout) || 0})`) + return nativeSetTimeout.call(this, wrapCallback(handler, site), timeout, ...rest) + } + + globalThis.setInterval = function setInterval(handler, timeout, ...rest) { + counters.setIntervalScheduled += 1 + if (typeof handler !== "function") return nativeSetInterval.call(this, handler, timeout, ...rest) + const site = siteFromStack(`setInterval(${Number(timeout) || 0})`) + return nativeSetInterval.call(this, wrapCallback(handler, site), timeout, ...rest) + } + + if (typeof nativeRaf === "function") { + globalThis.requestAnimationFrame = function requestAnimationFrame(callback) { + counters.rafScheduled += 1 + if (typeof callback !== "function") return nativeRaf.call(this, callback) + const site = siteFromStack("requestAnimationFrame") + return nativeRaf.call(this, wrapCallback(callback, site)) + } + } + + if (typeof nativeIdle === "function") { + globalThis.requestIdleCallback = function requestIdleCallback(callback, options) { + counters.idleCallbackScheduled += 1 + if (typeof callback !== "function") return nativeIdle.call(this, callback, options) + const site = siteFromStack("requestIdleCallback") + return nativeIdle.call(this, wrapCallback(callback, site), options) + } + } + + // Listener accounting explains the growing "JS event listeners" curve. + const nativeAdd = EventTarget.prototype.addEventListener + const nativeRemove = EventTarget.prototype.removeEventListener + EventTarget.prototype.addEventListener = function addEventListener(type, listener, options) { + counters.listenersAdded += 1 + bump(listenerTypes, String(type), 1) + return nativeAdd.call(this, type, listener, options) + } + EventTarget.prototype.removeEventListener = function removeEventListener(type, listener, options) { + counters.listenersRemoved += 1 + bump(listenerTypes, String(type), -1) + return nativeRemove.call(this, type, listener, options) + } + + const wrapObserver = (Original, kind, countEntries) => { + if (typeof Original !== "function") return Original + const Wrapped = function ObserverWrapper(callback, ...rest) { + const site = siteFromStack(kind) + const instrumented = typeof callback === "function" + ? function instrumentedObserverCallback(entries, observer) { + countEntries(entries) + const started = now() + try { + return callback.call(this, entries, observer) + } finally { + record(site, now() - started) + } + } + : callback + return new Original(instrumented, ...rest) + } + Wrapped.prototype = Original.prototype + return Wrapped + } + + globalThis.MutationObserver = wrapObserver(globalThis.MutationObserver, "MutationObserver", (entries) => { + counters.mutationRecords += entries?.length ?? 0 + }) + globalThis.ResizeObserver = wrapObserver(globalThis.ResizeObserver, "ResizeObserver", (entries) => { + counters.resizeEntries += entries?.length ?? 0 + }) + globalThis.IntersectionObserver = wrapObserver(globalThis.IntersectionObserver, "IntersectionObserver", (entries) => { + counters.intersectionEntries += entries?.length ?? 0 + }) + + const nativeFetch = globalThis.fetch + if (typeof nativeFetch === "function") { + globalThis.fetch = function fetch(...args) { + counters.fetches += 1 + return nativeFetch.apply(this, args) + } + } + + const nativePostMessage = globalThis.postMessage + if (typeof nativePostMessage === "function") { + globalThis.postMessage = function postMessage(...args) { + counters.postMessages += 1 + return nativePostMessage.apply(this, args) + } + } + + globalThis[globalName] = { + start() { + recording = true + sites.clear() + for (const key of Object.keys(counters)) counters[key] = 0 + }, + stop() { + recording = false + }, + snapshot() { + return { + stackCaptures, + stackBudgetExhausted: stackCaptures >= stackCaptureBudget, + counters: { ...counters }, + listenerTypes: [...listenerTypes.entries()] + .map(([type, net]) => ({ type, net })) + .filter((entry) => entry.net !== 0) + .sort((left, right) => right.net - left.net) + .slice(0, 25), + sites: [...sites.values()] + .sort((left, right) => right.totalMs - left.totalMs) + .slice(0, 40) + .map((entry) => ({ + site: entry.site, + calls: entry.calls, + totalMs: Number(entry.totalMs.toFixed(2)), + maxMs: Number(entry.maxMs.toFixed(2)), + })), + } + }, + } +} + +export const buildIdleProbeSource = (stackCaptureBudget = 200_000) => + `(${probeFactory.toString()})(${JSON.stringify(IDLE_PROBE_GLOBAL)}, ${stackCaptureBudget});` diff --git a/scripts/perf/metrics.mjs b/scripts/perf/metrics.mjs new file mode 100644 index 00000000..41272b35 --- /dev/null +++ b/scripts/perf/metrics.mjs @@ -0,0 +1,89 @@ +/** + * Metric helpers shared by the idle and streaming profilers. + * + * Both commands read the same `Performance.getMetrics` counters and need the + * same derivations, so the maths lives here and each entry point only decides + * which numbers to report. + */ + +export const round = (value, digits = 2) => Number(Number(value ?? 0).toFixed(digits)) + +export const metricMap = (metrics = []) => Object.fromEntries(metrics.map(({ name, value }) => [name, value])) + +/** + * Least-squares slope of a sampled series, in units per second. A slope + * separates a genuine upward trend from the sawtooth that garbage collection + * produces, which start/end deltas alone cannot distinguish. + */ +export const growthPerSecond = (samples, key) => { + if (samples.length < 2) return 0 + const meanTime = samples.reduce((total, sample) => total + sample.elapsedSeconds, 0) / samples.length + const meanValue = samples.reduce((total, sample) => total + (sample[key] ?? 0), 0) / samples.length + let covariance = 0 + let variance = 0 + for (const sample of samples) { + const timeDelta = sample.elapsedSeconds - meanTime + covariance += timeDelta * ((sample[key] ?? 0) - meanValue) + variance += timeDelta * timeDelta + } + return variance === 0 ? 0 : Number((covariance / variance).toFixed(3)) +} + +/** Percentile of an unsorted numeric series, using nearest-rank. */ +export const percentile = (values, fraction) => { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)) + return round(sorted[rank]) +} + +// `RunTask` and `RunMicrotasks` are containers: their duration already +// includes the work below them, so counting them would double-count. +const CONTAINER_TRACE_EVENTS = new Set(["RunTask", "RunMicrotasks", "ProfileChunk", "Profile"]) + +/** + * Breaks recorded time down by trace event. + * + * A CPU sampling profile attributes native work to `(program)`, which hides + * whether time went to HTML parsing, style recalculation, layout, or paint. + * The timeline trace names that work explicitly, so this is what turns "76% of + * busy time is native" into an actionable list. + */ +export const summarizeTraceEvents = (traceEvents, topCount = 15) => { + const totals = new Map() + for (const event of traceEvents) { + if (event.ph !== "X" || !(Number(event.dur) > 0)) continue + if (CONTAINER_TRACE_EVENTS.has(event.name)) continue + const entry = totals.get(event.name) ?? { name: event.name, count: 0, totalMs: 0, maxMs: 0 } + const durationMs = Number(event.dur) / 1000 + entry.count += 1 + entry.totalMs += durationMs + if (durationMs > entry.maxMs) entry.maxMs = durationMs + totals.set(event.name, entry) + } + return [...totals.values()] + .sort((left, right) => right.totalMs - left.totalMs) + .slice(0, topCount) + .map((entry) => ({ ...entry, totalMs: round(entry.totalMs), maxMs: round(entry.maxMs) })) +} + +/** + * Long tasks block input and animation, so a streaming capture is judged by + * its task-duration distribution rather than by an average frame rate. + */ +export const summarizeLongTasks = (traceEvents, thresholdMs = 50) => { + const durations = traceEvents + .filter((event) => event.name === "RunTask" && Number(event.dur) > 0) + .map((event) => Number(event.dur) / 1000) + const long = durations.filter((duration) => duration >= thresholdMs) + return { + taskCount: durations.length, + longTaskCount: long.length, + longTaskTotalMs: round(long.reduce((total, duration) => total + duration, 0)), + // Spreading a large array into Math.max overflows the call stack; a trace + // can easily carry hundreds of thousands of tasks. + longestTaskMs: round(durations.reduce((max, duration) => Math.max(max, duration), 0)), + taskP95Ms: percentile(durations, 0.95), + taskP99Ms: percentile(durations, 0.99), + } +} diff --git a/scripts/perf/scenario.mjs b/scripts/perf/scenario.mjs new file mode 100644 index 00000000..105e11d5 --- /dev/null +++ b/scripts/perf/scenario.mjs @@ -0,0 +1,44 @@ +/** + * Scenario setup shared by the idle and streaming profilers. + * + * Idle and streaming cost both depend on how much of the sidebar is mounted, + * so both commands need the same way to reach a heavily populated sidebar. + * Setup always runs before the measured window. + */ + +import { evaluateValue, wait } from "./cdp.mjs" + +/** + * Expands every project. The sidebar persists the ids of collapsed projects, + * so an empty list expands everything. Requires a reload to take effect. + */ +export const expandProjects = async (client) => { + await evaluateValue(client, `localStorage.setItem("oc.sessions.projectCollapse", "[]")`) +} + +/** + * Clicks every "Show more sessions" control until none remain. + * + * Session list pagination is component state, so unlike project collapse it + * cannot be seeded through storage. The controls only exist once the sidebar + * has populated, so call this after the page has settled, never straight after + * the load event. + * + * Matching is a case-insensitive substring test, which assumes the English UI + * locale; a non-English locale expands nothing and reports zero. + */ +export const expandSessionLists = async (client, { passes = 40, settleMs = 400 } = {}) => { + let totalClicked = 0 + for (let pass = 0; pass < passes; pass += 1) { + const clicked = await evaluateValue(client, `(() => { + const controls = [...document.querySelectorAll("button")] + .filter((button) => (button.textContent ?? "").toLowerCase().includes("show more")) + for (const control of controls) control.click() + return controls.length + })()`) + if (!clicked) break + totalClicked += clicked + await wait(settleMs) + } + return totalClicked +} diff --git a/scripts/profile-animation.mjs b/scripts/profile-animation.mjs new file mode 100644 index 00000000..ffe24d8d --- /dev/null +++ b/scripts/profile-animation.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * Measures what a CSS animation costs, in isolation. + * + * A continuously running animation is one of the few things an idle interface + * keeps paying for, and the price depends entirely on whether the compositor + * can drive the animated property. Compositor-driven properties cost a small + * constant; everything else recalculates style on every frame, roughly an order + * of magnitude more. + * + * Rather than rebuilding the application and streaming a response to answer + * that question, this command serves a fixture page and measures each variant + * directly, so a whole comparison takes seconds. Add a variant to + * `perf/animation-fixture.html` to measure a property or technique that is not + * listed yet. + * + * The output answers two questions the fixture is designed for: + * - which properties are cheap to animate; + * - whether cost scales with the number of animated elements (`--count`). + */ + +import { createReadStream } from "node:fs" +import { createServer } from "node:http" +import { homedir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import process from "node:process" + +import { CdpClient, createPageTarget, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs" +import { metricMap, round } from "./perf/metrics.mjs" + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const fixturePath = join(scriptDirectory, "perf", "animation-fixture.html") + +const DEFAULT_VARIANTS = [ + "none", + "transform-rotate", + "transform-rotate-willchange", + "transform-rotate-steps", + "transform-rotate-wrapper", + "rotate-property", + "opacity", + "translate", + "scale", + "background-position", + "border-color", + "box-shadow", + "filter", + "width", + "ctx-button", + "ctx-sibling-translatez", + "ctx-filter", + "ctx-overflow", + "ctx-backdrop", + "ctx-opacity", + "ctx-transformed-parent", + "ctx-currentcolor", +] + +const HELP = `Usage: bun run profile:animation -- [options] + +Measures the idle cost of CSS animations using an isolated fixture page. + +Options: + --variant Measure only this variant (repeatable) + --count Animated elements per variant (default: 2) + --duration Measurement window per variant (default: 10) + --settle Wait before measuring each variant (default: 3) + --filler Static elements added to the page, to measure a variant + against a realistically sized document (default: 0) + --chrome Chrome/Chromium executable + --profile-dir Reusable isolated Chrome profile + --headed Show the browser (default: headless) + --json Print results as JSON + --help Show this help + +Variants live in scripts/perf/animation-fixture.html. Add one there to measure +a property or technique that is not covered.` + +const parseArgs = (argv) => { + const options = { + variants: [], + count: 2, + duration: 10, + settle: 3, + filler: 0, + chrome: null, + profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"), + headless: true, + json: false, + } + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index] + if (value === "--help") return { ...options, help: true } + else if (value === "--headed") options.headless = false + else if (value === "--json") options.json = true + else if (value === "--variant") options.variants.push(argv[++index]) + else if (value === "--count") options.count = Number(argv[++index]) + else if (value === "--duration") options.duration = Number(argv[++index]) + else if (value === "--settle") options.settle = Number(argv[++index]) + else if (value === "--filler") options.filler = Number(argv[++index]) + else if (value === "--chrome") options.chrome = argv[++index] + else if (value === "--profile-dir") options.profileDir = argv[++index] + else throw new Error(`Unknown option: ${value}`) + } + if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number") + if (!Number.isFinite(options.count) || options.count < 1) throw new Error("--count must be at least 1") + if (options.variants.length === 0) options.variants = DEFAULT_VARIANTS + return options +} + +/** Serves only the fixture, on an ephemeral loopback port. */ +const startFixtureServer = () => new Promise((resolvePromise, reject) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }) + createReadStream(fixturePath).pipe(response) + }) + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close() + reject(new Error("Could not bind the fixture server")) + return + } + resolvePromise({ server, port: address.port }) + }) +}) + +const measureVariant = async (client, url, options) => { + const loaded = client.once("Page.loadEventFired", 30_000) + await client.send("Page.navigate", { url }) + await loaded + await wait(options.settle * 1000) + + const before = metricMap((await client.send("Performance.getMetrics")).metrics) + const startedAt = Date.now() + await wait(options.duration * 1000) + const elapsedSeconds = (Date.now() - startedAt) / 1000 + const after = metricMap((await client.send("Performance.getMetrics")).metrics) + + const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0) + return { + recalcStylePerSecond: round(delta("RecalcStyleCount") / elapsedSeconds), + layoutsPerSecond: round(delta("LayoutCount") / elapsedSeconds), + mainThreadBusyPercent: round((delta("TaskDuration") / elapsedSeconds) * 100), + recalcStyleMsPerSecond: round((delta("RecalcStyleDuration") / elapsedSeconds) * 1000), + } +} + +const main = async () => { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + console.log(HELP) + return + } + + const chrome = resolveChrome(options.chrome) + const { server, port: fixturePort } = await startFixtureServer() + const debuggingPort = await reservePort() + const chromeProcess = launchChrome({ + chrome, + profileDir: resolve(options.profileDir), + port: debuggingPort, + headless: options.headless, + }) + + let client + const results = [] + try { + const target = await createPageTarget(debuggingPort) + client = new CdpClient(target.webSocketDebuggerUrl) + await client.connect() + await Promise.all([ + client.send("Page.enable"), + client.send("Runtime.enable"), + client.send("Performance.enable"), + ]) + await client.send("Emulation.setDeviceMetricsOverride", { + width: 1200, height: 800, deviceScaleFactor: 1, mobile: false, + }) + + console.log(`Measuring ${options.variants.length} variants, ${options.count} element(s) each, ${options.duration}s per variant.\n`) + for (const variant of options.variants) { + const url = `http://127.0.0.1:${fixturePort}/?variant=${encodeURIComponent(variant)}&count=${options.count}&filler=${options.filler}` + const measured = await measureVariant(client, url, options) + results.push({ variant, ...measured }) + if (!options.json) { + console.log( + `${variant.padEnd(30)} recalc/s ${String(measured.recalcStylePerSecond).padStart(8)}` + + ` layout/s ${String(measured.layoutsPerSecond).padStart(6)}` + + ` busy% ${String(measured.mainThreadBusyPercent).padStart(6)}`, + ) + } + } + + if (options.json) { + console.log(JSON.stringify({ count: options.count, durationSeconds: options.duration, results }, null, 2)) + return + } + + const baseline = results.find((entry) => entry.variant === "none") + if (baseline) { + console.log( + `\nA still page costs ${baseline.recalcStylePerSecond} style recalculations per second.` + + " Compositor-driven properties add a small constant; anything far above that recalculates every frame.", + ) + } + } finally { + client?.close() + if (!chromeProcess.killed) chromeProcess.kill("SIGTERM") + server.close() + } +} + +main().catch((error) => { + console.error(`Animation profiling failed: ${error.message}`) + process.exitCode = 1 +}) diff --git a/scripts/profile-idle.mjs b/scripts/profile-idle.mjs new file mode 100644 index 00000000..bb06d637 --- /dev/null +++ b/scripts/profile-idle.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Fully automated idle CPU/memory capture for OpenChamber. + * + * Unlike `profile:browser`, this command needs no human in the loop: it loads + * the app, lets it settle, then records a window during which no input is + * delivered. Everything it reports is therefore work the app performs while the + * user is doing nothing, which is the regression class users notice as fan + * noise, battery drain, and a permanently busy tab. + * + * Reported dimensions (per second of the idle window): + * - main-thread busy time, script time, style recalculation, layout; + * - style recalculation and layout counts; + * - DOM node, document, frame, and JS event listener growth; + * - JS heap trajectory (start/end/max plus linear growth rate); + * - CPU sampling profile with self time per function; + * - scheduled-work attribution per timer/animation-frame/observer call site. + * + * Runs are directly comparable: `--baseline ` prints a per-metric + * delta table and exits non-zero when a budget in `--budget-*` is exceeded, so + * the same command works as an investigation tool and as a regression gate. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join, resolve } from "node:path" +import process from "node:process" + +import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs" +import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs" +import { summarizeCpuProfile } from "./perf/cpu-profile.mjs" +import { expandProjects, expandSessionLists } from "./perf/scenario.mjs" +import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs" + +const HELP = `Usage: bun run profile:idle -- [options] + +Records what OpenChamber does while nobody is interacting with it. + +Options: + --url OpenChamber URL (default: http://localhost:3000) + --session Open this session before recording (deep link) + --tab Open this main tab before recording + --then-tab After settling, navigate to this tab without a + reload, then record. Use it to measure what a + surface keeps doing after the user leaves it. + --expand-sessions Click every "Show more sessions" control until the + sidebar has no collapsed session lists left, so all + session rows are mounted. Clicks happen before the + recording window, which stays input-free. + --expand-projects Expand every project in the sidebar before + recording, which mounts a row per worktree and + session directory + --panel Open the context panel on this surface before + recording (chat, preview, terminal, git, pr, notes, + file, diff, plan, context, browser, walkthrough). + Repeatable; the first entry becomes the active tab. + --duration Idle recording window (default: 30) + --settle Wait after load before recording (default: 15) + --output Artifact directory (default: artifacts/idle-profile-