Merge origin/main into deferred OpenCode restart branch.
Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API key hiding, always-load auth methods) while keeping deferred Apply & Restart for provider mutations. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -68,4 +68,5 @@ workspaces/
|
||||
*.pid
|
||||
.worktrees/
|
||||
test-results/
|
||||
artifacts/browser-profile-*/
|
||||
artifacts/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
+6
-3
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -30,7 +30,6 @@ const result = await Bun.build({
|
||||
'@openchamber/web/*',
|
||||
'bun-pty',
|
||||
'node-pty',
|
||||
'better-sqlite3',
|
||||
],
|
||||
minify: false,
|
||||
sourcemap: 'none',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <repo>/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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.17.2",
|
||||
"version": "1.18.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
|
||||
@@ -1800,26 +1800,28 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
style={{ paddingBottom: 'calc(0.375rem + var(--oc-safe-area-bottom, 0px))' }}
|
||||
>
|
||||
{footer.instanceLabel && footer.onOpenInstances ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex h-10 min-w-0 flex-1 items-center gap-2 rounded-xl px-2 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
variant="info"
|
||||
size="lg"
|
||||
className="min-w-0 shrink justify-start"
|
||||
onClick={footer.onOpenInstances}
|
||||
aria-label={t('mobile.menu.instances')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="server" className="size-[18px] shrink-0 text-muted-foreground" />
|
||||
<span className="block min-w-0 truncate typography-ui-label text-foreground">
|
||||
{footer.instanceLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
<span className="block min-w-0 truncate">{footer.instanceLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1" />
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{footer.onOpenUpdate ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="relative flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-10 px-0"
|
||||
onClick={footer.onOpenUpdate}
|
||||
aria-label={t('mobile.menu.update')}
|
||||
title={t('mobile.menu.update')}
|
||||
@@ -1827,18 +1829,20 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
>
|
||||
<Icon name="download" className="size-5" />
|
||||
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-10 px-0"
|
||||
onClick={footer.onOpenSettings}
|
||||
aria-label={t('mobile.menu.settings')}
|
||||
title={t('mobile.menu.settings')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="settings-3" className="size-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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<ChatInputProps> = ({ 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;
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
@@ -309,6 +310,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
|
||||
@@ -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<FileRowProps> = ({
|
||||
root,
|
||||
isExpanded,
|
||||
isActive,
|
||||
isBrowserClient,
|
||||
status,
|
||||
badge,
|
||||
permissions,
|
||||
@@ -223,6 +226,9 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
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<FileRowProps> = ({
|
||||
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<FileRowProps> = ({
|
||||
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||
});
|
||||
}}>
|
||||
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
|
||||
<Icon name="download" className="mr-2 h-4 w-4" /> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')}
|
||||
</Item>
|
||||
)}
|
||||
{canReveal && (
|
||||
{canRevealPath && (
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
|
||||
</Item>
|
||||
@@ -362,7 +368,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
{hasMenuActions && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
|
||||
<DropdownMenu
|
||||
open={contextMenuOpen}
|
||||
@@ -406,6 +412,7 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
|
||||
&& prev.root === next.root
|
||||
&& prev.isExpanded === next.isExpanded
|
||||
&& prev.isActive === next.isActive
|
||||
&& prev.isBrowserClient === next.isBrowserClient
|
||||
&& prev.status === next.status
|
||||
&& prev.badge === next.badge
|
||||
&& prev.permissions === next.permissions
|
||||
@@ -422,7 +429,8 @@ const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
|
||||
|
||||
export const SidebarFilesTree: React.FC = () => {
|
||||
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}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, AuthMethod[]> = {};
|
||||
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<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(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 = () => {
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
||||
const candidateOAuthMethods = listOAuthMethods(candidateAuthMethods);
|
||||
|
||||
if (candidateOAuthMethods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
|
||||
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
{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 ? (
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<div key={`${candidateProviderId}-${methodLabel}-${methodIndex}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
{candidateOAuthMethods.length > 0 ? (
|
||||
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{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 (
|
||||
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : 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 = () => {
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
||||
<SettingsInfoHint>{incompleteAuthHint}</SettingsInfoHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
@@ -1020,37 +1032,39 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
{showApiKeyAuth ? (
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{oauthAuthMethods.length > 0 && (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{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 (
|
||||
<div key={`${selectedProvider.id}-${methodLabel}-${methodIndex}`} className="space-y-3">
|
||||
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
@@ -1168,14 +1182,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{showModelsSection ? (
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.models.title')}
|
||||
titleAccessory={
|
||||
providerModels.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
) : null
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
}
|
||||
headerAction={(
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -1284,6 +1297,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, AuthMethod[]> = {};
|
||||
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');
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
|
||||
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
mobileVariant,
|
||||
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
unarchiveSession,
|
||||
childrenMap,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
openContextPanelTab={openContextPanelTab}
|
||||
handleDeleteSession={stableHandleDeleteSession}
|
||||
handleRestoreSession={stableHandleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
renderSessionNode={renderSessionNode}
|
||||
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
} = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulkCanRemoveFromFolder}
|
||||
onRestore={handleBulkRestore}
|
||||
onDelete={handleBulkDelete}
|
||||
onDone={handleExitSelectionMode}
|
||||
/>
|
||||
|
||||
@@ -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<Props> = ({
|
||||
onCreateFolderAndMove,
|
||||
onRemoveFromFolder,
|
||||
canRemoveFromFolder,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onDone,
|
||||
}) => {
|
||||
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{archivedBucket ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.restore')}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
|
||||
@@ -92,6 +92,7 @@ type Props = {
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (
|
||||
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{t('sessions.sidebar.bulkActions.archive')}
|
||||
</Item>
|
||||
) : null}
|
||||
{archivedBucket ? (
|
||||
<Item className="[&>svg]:mr-1" onClick={() => handleRestoreSession(session)}>
|
||||
<Icon name="inbox-unarchive" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.restore')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket, hardDelete: true })}>
|
||||
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.delete')}
|
||||
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type Args = {
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSession: (id: string) => Promise<boolean>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
|
||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||
}, [args, executeDeleteSession]);
|
||||
|
||||
const handleRestoreSession = React.useCallback(
|
||||
async (session: Session) => {
|
||||
const success = await args.unarchiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
},
|
||||
[args, t],
|
||||
);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
handleSessionSelect,
|
||||
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type Args = {
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
|
||||
sessionCount: number;
|
||||
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
} = args;
|
||||
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
|
||||
|
||||
const handleBulkRestore = React.useCallback(async () => {
|
||||
if (!hasSelection || !bulkScopeIsArchived) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const { restoredIds, failedIds } = await unarchiveSessions(ids);
|
||||
if (restoredIds.length > 0) {
|
||||
toast.success(restoredIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
|
||||
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,6 +65,12 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "layout-left",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'add_selection_to_chat',
|
||||
descriptionKey: "helpDialog.item.addSelectionToChat",
|
||||
icon: "add",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_agent',
|
||||
keys: '',
|
||||
|
||||
@@ -32,6 +32,18 @@ const TINT_DESTRUCTIVE = [
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]",
|
||||
].join(" ")
|
||||
|
||||
const TINT_INFO = [
|
||||
"bg-[color-mix(in_srgb,var(--status-info)_4%,var(--background))]",
|
||||
"text-[var(--status-info)]",
|
||||
"border border-[color-mix(in_srgb,var(--status-info)_8%,transparent)]",
|
||||
"hover:bg-[color-mix(in_srgb,var(--status-info)_7%,var(--background))]",
|
||||
"active:bg-[color-mix(in_srgb,var(--status-info)_10%,var(--background))]",
|
||||
"dark:bg-[color-mix(in_srgb,var(--status-info)_7%,transparent)]",
|
||||
"dark:border-[color-mix(in_srgb,var(--status-info)_12%,transparent)]",
|
||||
"dark:hover:bg-[color-mix(in_srgb,var(--status-info)_10%,transparent)]",
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--status-info)_14%,transparent)]",
|
||||
].join(" ")
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"group relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] typography-ui-label font-medium lowercase tracking-[0.01em] shrink-0 select-none",
|
||||
@@ -49,6 +61,7 @@ const buttonVariants = cva(
|
||||
TINT_DESTRUCTIVE,
|
||||
"focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
),
|
||||
info: TINT_INFO,
|
||||
neutral:
|
||||
"bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active",
|
||||
outline:
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
});
|
||||
}, [t, unarchiveSession]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderDirectoryItem = (
|
||||
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-14 focus-within:pr-14"
|
||||
onClick={() => openSession(session)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
|
||||
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
|
||||
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
restoreSession(session);
|
||||
}}
|
||||
className="absolute right-7 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-foreground group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.restoreSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -70,7 +70,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -361,6 +361,7 @@ interface FileRowProps {
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
isMobile: boolean;
|
||||
isBrowserClient: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
status?: FileStatus | null;
|
||||
badge?: { modified: number; added: number } | null;
|
||||
@@ -388,6 +389,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
isExpanded,
|
||||
isActive,
|
||||
isMobile,
|
||||
isBrowserClient,
|
||||
alwaysShowActions,
|
||||
status,
|
||||
badge,
|
||||
@@ -405,14 +407,17 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
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;
|
||||
|
||||
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
|
||||
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) {
|
||||
if (!hasMenuActions) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
setRightClickMenuPath(node.path);
|
||||
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]);
|
||||
}, [hasMenuActions, node.path, setRightClickMenuPath]);
|
||||
|
||||
const handleInteraction = React.useCallback(() => {
|
||||
if (isDir) {
|
||||
@@ -474,10 +479,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||
});
|
||||
}}>
|
||||
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
|
||||
<Icon name="download" className="mr-2 size-4" /> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')}
|
||||
</Item>
|
||||
)}
|
||||
{canReveal && (
|
||||
{canRevealPath && (
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<Icon name="folder-received" className="mr-2 size-4" /> {t(getRevealLabelKey())}
|
||||
</Item>
|
||||
@@ -546,7 +551,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
{hasMenuActions && (
|
||||
<div className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2",
|
||||
alwaysShowActions ? "opacity-100" : "opacity-0 focus-within:opacity-100 group-hover:opacity-100"
|
||||
@@ -720,6 +725,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
isExpanded={isExpanded}
|
||||
isActive={isActive}
|
||||
isMobile={isMobile}
|
||||
isBrowserClient={isBrowserClient}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>('.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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const embedded = window.__OPENCHAMBER_HOME__;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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)",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 サーフェスを開く',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'helpDialog.item.openCommandPalette': 'コマンドパレットを開く',
|
||||
'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)',
|
||||
'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え',
|
||||
'helpDialog.item.addSelectionToChat': '選択範囲をチャットに追加',
|
||||
'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)',
|
||||
'helpDialog.item.openModelSelector': 'モデルセレクターを開く',
|
||||
'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)',
|
||||
|
||||
@@ -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 서피스 열기',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'helpDialog.item.openCommandPalette': '명령 팔레트 열기',
|
||||
'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)',
|
||||
'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바',
|
||||
'helpDialog.item.addSelectionToChat': '선택 내용을 채팅에 추가',
|
||||
'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)',
|
||||
'helpDialog.item.openModelSelector': '모델 선택기 열기',
|
||||
'helpDialog.item.navigateModels': '모델 이동(선택기)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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)",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"helpDialog.item.openCommandPalette": "Відкрити палітру команд",
|
||||
"helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)",
|
||||
"helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій",
|
||||
"helpDialog.item.addSelectionToChat": "Додати виділення в чат",
|
||||
"helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)",
|
||||
"helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі",
|
||||
"helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)",
|
||||
|
||||
@@ -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 界面',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'helpDialog.item.openCommandPalette': '打开命令面板',
|
||||
'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)',
|
||||
'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏',
|
||||
'helpDialog.item.addSelectionToChat': '将选中内容添加到聊天',
|
||||
'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)',
|
||||
'helpDialog.item.openModelSelector': '打开模型选择器',
|
||||
'helpDialog.item.navigateModels': '导航模型(选择器中)',
|
||||
|
||||
@@ -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 介面',
|
||||
|
||||
@@ -463,6 +463,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'helpDialog.item.openCommandPalette': '開啟命令面板',
|
||||
'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)',
|
||||
'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄',
|
||||
'helpDialog.item.addSelectionToChat': '將選取內容加入聊天',
|
||||
'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)',
|
||||
'helpDialog.item.openModelSelector': '開啟模型選擇器',
|
||||
'helpDialog.item.navigateModels': '導覽模型(選擇器中)',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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 = <T extends Error>(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<string, unknown>)[AMBIGUOUS_TRANSPORT_FLAG] === true;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -159,8 +159,15 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Record<string, unknown>> = []
|
||||
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<typeof splitGlobalSessionsByArchived>[0])
|
||||
|
||||
expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored'])
|
||||
expect(archived.map((session) => session.id)).toEqual(['ses_archived'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = <T extends GlobalSessionRecord>(
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,6 +85,19 @@ interface MessageQueueState {
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
||||
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
||||
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<string, string[]>;
|
||||
}
|
||||
|
||||
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<MessageQueueStore>()(
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
sendingIds: {},
|
||||
|
||||
addToQueue: (target, message) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
@@ -237,6 +254,14 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
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<MessageQueueStore>()(
|
||||
},
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let activeRequest: Deferred<Session[]>
|
||||
let archivedRequest: Deferred<Session[]>
|
||||
let listRequest: Deferred<Session[]>
|
||||
|
||||
// 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<Session[]>()
|
||||
archivedRequest = deferred<Session[]>()
|
||||
listRequest = deferred<Session[]>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>,
|
||||
archived: boolean,
|
||||
): Promise<DirectoryPageResult> => {
|
||||
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<GlobalSessionsState>((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<GlobalSessionsState>((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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, StoreApi<DirectoryStore>>()
|
||||
private readonly lifecycle = new Map<string, DirState>()
|
||||
private readonly pins = new Map<string, number>()
|
||||
private evictionScheduled = false
|
||||
private readonly disposers = new Map<string, () => 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)
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<Array<{ info: Message; parts?: Part[] }> | 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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<boolean>
|
||||
archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }>
|
||||
unarchiveSession: (id: string) => Promise<boolean>
|
||||
unarchiveSessions: (ids: string[], options?: UnarchiveSessionsOptions) => Promise<{ restoredIds: string[]; failedIds: string[] }>
|
||||
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
|
||||
shareSession: (sessionId: string) => Promise<Session | null>
|
||||
unshareSession: (sessionId: string) => Promise<Session | null>
|
||||
@@ -352,6 +357,12 @@ export type SessionUIState = {
|
||||
debugSessionMessages: (sessionId: string) => Promise<void>
|
||||
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<SessionUIState>()((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<SessionUIState>()((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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user