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:
Cursor Agent
2026-08-04 13:29:46 +00:00
co-authored by Serhii Dziupin
133 changed files with 6361 additions and 714 deletions
@@ -27,6 +27,34 @@ Do not optimize against a toy fixture when the report provides production scale.
## Workflow ## 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 ### 1. Reproduce And Measure
- Reproduce the exact interaction, not a nearby helper in isolation. - 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. 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. 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 ### 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. 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 ## Verification
Require both correctness and performance guards: 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. 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 ## Hotfix Policy
Ship a bounded cache-only or local mitigation under deadline pressure only when: 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. | | "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. | | "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. | | "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 ## 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. - [ ] Exact interaction and production scale reproduced.
- [ ] Cost equation written and dominant multipliers removed. - [ ] Cost equation written and dominant multipliers removed.
- [ ] Sources of truth, completeness, and invalidation explicit. - [ ] 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. - [ ] Operation-count or repeated-event regression test prevents recurrence.
- [ ] Structural optimizations have transition-focused correctness coverage independent of performance measurements. - [ ] 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. - [ ] 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. - [ ] 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. - Key runtime-scoped caches by runtime identity when IDs or paths can collide.
- Clean optimistic and local cache state after partial failures. - 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 ## Persisted Snapshot Ordering
When state exists in memory and one or more persistent stores, define an explicit authority and ordering protocol: 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. - New session lookup assumes SSE already indexed it.
- Optimistic data has no shadow entry or rollback. - Optimistic data has no shadow entry or rollback.
- Snapshot-difference cleanup treats its first startup snapshot as a disappearance event. - 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. - 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. - Debounced writes are canceled on owner/lifecycle change without completing against the captured owner or an explicit durability/data-loss contract.
+26
View File
@@ -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. 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 ## Verification
- Animations are limited to `transform` and `opacity`, or their cost was measured and accepted.
- No hardcoded/palette colors were introduced. - No hardcoded/palette colors were introduced.
- Buttons use shared variants and sizes. - Buttons use shared variants and sizes.
- Icons use `Icon`/`IconName`, and generated sprite changes are intentional. - Icons use `Icon`/`IconName`, and generated sprite changes are intentional.
+2 -2
View File
@@ -204,8 +204,8 @@ jobs:
bun run bundle:main bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't # npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own — we must rebuild against the # recompile native deps on its own — we must rebuild against the
# target Electron ABI before packaging, otherwise better-sqlite3/ # target Electron ABI before packaging, otherwise node-pty/bun-pty
# node-pty/bun-pty crash on require inside the packaged app. # crash on require inside the packaged app.
bun run rebuild:native bun run rebuild:native
bunx electron-builder --mac --${{ matrix.arch }} --publish=never bunx electron-builder --mac --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged bun run verify:opencode-cli:packaged
+2 -1
View File
@@ -68,4 +68,5 @@ workspaces/
*.pid *.pid
.worktrees/ .worktrees/
test-results/ test-results/
artifacts/browser-profile-*/ artifacts/
+9 -9
View File
@@ -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. 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: permission:
edit: allow edit: allow
@@ -16,13 +16,13 @@ permission:
"*.env.example": allow "*.env.example": allow
bash: bash:
"*": ask "*": ask
"bun test*": allow bun test*: allow
"bun run type-check*": allow bun run type-check*: allow
"bun run lint*": allow bun run lint*: allow
"bun run build*": allow bun run build*: allow
"bun run docs:validate": allow bun run docs:validate: allow
"bun run dead-code": allow bun run dead-code: allow
"git *": 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. You are an expert code simplification specialist for OpenChamber. Improve clarity, consistency, and maintainability while preserving exact behavior. Prefer readable, explicit code over compact or clever code.
@@ -77,4 +77,4 @@ Do not edit until the required project guidance and local context have been read
3. Re-read the edited code and verify that the observable contract is unchanged. 3. Re-read the edited code and verify that the observable contract is unchanged.
4. Run the narrowest validation required by the repository guidance and actual risk. Use package-scoped checks for local executable changes and broader checks only for genuinely shared contracts. 4. Run the narrowest validation required by the repository guidance and actual risk. Use package-scoped checks for local executable changes and broader checks only for genuinely shared contracts.
5. Run `bun run dead-code` only when files, exports, types, entrypoints, or import shapes changed, and inspect its non-blocking report. 5. Run `bun run dead-code` only when files, exports, types, entrypoints, or import shapes changed, and inspect its non-blocking report.
6. Summarize meaningful clarity improvements and report exactly what was and was not validated. 6. Summarize meaningful clarity improvements and report exactly what was and was not validated.
+1
View File
@@ -65,6 +65,7 @@ High-value anchors:
- Sync: `packages/ui/src/sync/DOCUMENTATION.md` - Sync: `packages/ui/src/sync/DOCUMENTATION.md`
- Stores: `packages/ui/src/stores/DOCUMENTATION.md` - Stores: `packages/ui/src/stores/DOCUMENTATION.md`
- CLI: `packages/web/bin/lib/DOCUMENTATION.md` - CLI: `packages/web/bin/lib/DOCUMENTATION.md`
- Performance measurement tooling: `scripts/perf/DOCUMENTATION.md`
- VS Code runtime: `packages/vscode/src/DOCUMENTATION.md` - VS Code runtime: `packages/vscode/src/DOCUMENTATION.md`
- Electron: `packages/electron/README.md` - Electron: `packages/electron/README.md`
- Mobile: `packages/mobile/README.md` - Mobile: `packages/mobile/README.md`
+11
View File
@@ -4,10 +4,13 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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. - **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. - **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). - **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: 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). - 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: 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. - 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). - 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/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: 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: 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). - 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. - 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). - 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).
-8
View File
@@ -98,7 +98,6 @@
"version": "1.17.2", "version": "1.17.2",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"better-sqlite3": "^12.10.0",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
@@ -270,7 +269,6 @@
"@opencode-ai/sdk": "1.18.11", "@opencode-ai/sdk": "1.18.11",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
"bun-pty": "^0.4.5", "bun-pty": "^0.4.5",
"compression": "^1.8.1", "compression": "^1.8.1",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
@@ -1583,16 +1581,12 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "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=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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
View File
@@ -1,6 +1,6 @@
{ {
"name": "openchamber-monorepo", "name": "openchamber-monorepo",
"version": "1.17.2", "version": "1.18.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -40,7 +40,7 @@
"lint:mobile": "bun run --cwd packages/mobile lint", "lint:mobile": "bun run --cwd packages/mobile lint",
"clean": "bun run --filter '*' clean", "clean": "bun run --filter '*' clean",
"changelog-card": "node scripts/changelog-card/generate.mjs", "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": "bun run --cwd packages/web build:watch",
"dev:web:server": "bun run --cwd packages/web dev:server:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch",
"dev:web:full": "node ./scripts/dev-web-full.mjs", "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:prepare": "bun run build && bun run type-check && bun run lint",
"release:test": "./scripts/test-release-build.sh", "release:test": "./scripts/test-release-build.sh",
"release:test:intel": "./scripts/test-release-build.sh x86_64", "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": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
+10 -1
View File
@@ -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 | | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC |
| `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers |
| `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | | `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/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/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 | | `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. 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: Useful variants:
```bash ```bash
bun run electron:dev:bundled bun run electron:dev:bundled
bun run --cwd packages/electron ensure:electron
bun run type-check:electron bun run type-check:electron
bun run lint: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`. 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`. 3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`.
4. `rebuild:native` to rebuild native modules for Electron. 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`. Build output goes to `packages/electron/dist`.
+9 -2
View File
@@ -234,6 +234,11 @@ const commandExists = (program, env = process.env) => {
const findEntry = (entries, appId, appName) => entries.find((entry) => desktopEntryMatchesApp(entry, appName, appId)) || null; 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 }) => { export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = 'path', entries = [], env = process.env }) => {
if (appId === 'finder') { if (appId === 'finder') {
return [{ kind: 'default', targetKind, targetPath }]; return [{ kind: 'default', targetKind, targetPath }];
@@ -241,7 +246,9 @@ export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = '
const specs = []; const specs = [];
if (TERMINAL_APP_IDS.has(appId)) { if (TERMINAL_APP_IDS.has(appId)) {
const directory = targetKind === 'file' ? path.dirname(targetPath) : targetPath; 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) { if (terminalEntry) {
const spec = buildCommandFromDesktopExec(terminalEntry, directory); const spec = buildCommandFromDesktopExec(terminalEntry, directory);
if (spec) specs.push(spec); if (spec) specs.push(spec);
@@ -515,7 +522,7 @@ export const buildLinuxInstalledApps = async (apps, options = {}) => {
...FILE_MANAGER_ICON_FALLBACKS, ...FILE_MANAGER_ICON_FALLBACKS,
], { ...options, env }); ], { ...options, env });
} else if (normalizeComparable(name) === 'terminal') { } else if (normalizeComparable(name) === 'terminal') {
const terminalEntry = findEntry(entries, 'terminal', name) const terminalEntry = entries.find(isTerminalEmulatorEntry)
|| findEntry(entries, 'ghostty', 'Ghostty'); || findEntry(entries, 'ghostty', 'Ghostty');
iconDataUrl = resolveIconDataUrlForName([ iconDataUrl = resolveIconDataUrlForName([
terminalEntry?.icon, terminalEntry?.icon,
+11 -2
View File
@@ -2255,6 +2255,13 @@ const dispatchMenuAction = (action) => {
dispatchDomEventToWindow(target, 'openchamber:menu-action', 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 // Mini-chat draft windows are not deduplicated, so this must reach the renderer
// exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer // exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer
// resolves the active directory/project and opens the window. // resolves the active directory/project and opens the window.
@@ -4564,6 +4571,7 @@ const buildMacMenu = () => {
{ type: 'separator' }, { type: 'separator' },
{ role: 'cut' }, { role: 'cut' },
{ label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() }, { label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() },
{ label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() },
{ role: 'paste' }, { role: 'paste' },
{ role: 'selectAll' }, { role: 'selectAll' },
], ],
@@ -4582,7 +4590,7 @@ const buildMacMenu = () => {
{ label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'Dark Theme', click: () => dispatchAction('theme-dark') },
{ label: 'System Theme', click: () => dispatchAction('theme-system') }, { label: 'System Theme', click: () => dispatchAction('theme-system') },
{ type: 'separator' }, { 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') }, { label: 'Toggle Memory Debug', accelerator: 'Cmd+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
{ type: 'separator' }, { type: 'separator' },
{ role: 'togglefullscreen' }, { role: 'togglefullscreen' },
@@ -4661,6 +4669,7 @@ const buildAutoHiddenMenu = () => {
{ type: 'separator' }, { type: 'separator' },
{ role: 'cut' }, { role: 'cut' },
{ label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() },
{ label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() },
{ role: 'paste' }, { role: 'paste' },
{ role: 'selectAll' }, { role: 'selectAll' },
], ],
@@ -4683,7 +4692,7 @@ const buildAutoHiddenMenu = () => {
{ label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'Dark Theme', click: () => dispatchAction('theme-dark') },
{ label: 'System Theme', click: () => dispatchAction('theme-system') }, { label: 'System Theme', click: () => dispatchAction('theme-system') },
{ type: 'separator' }, { 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') }, { label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
{ type: 'separator' }, { type: 'separator' },
{ role: 'togglefullscreen' }, { role: 'togglefullscreen' },
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.17.2", "version": "1.18.0",
"private": true, "private": true,
"description": "Electron desktop runtime for OpenChamber", "description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber", "author": "OpenChamber",
@@ -8,7 +8,6 @@
"main": "./dist-bundle/main.mjs", "main": "./dist-bundle/main.mjs",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"better-sqlite3": "^12.10.0",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.8.3" "electron-updater": "^6.8.3"
@@ -32,6 +31,7 @@
"dev": "node ./scripts/electron-dev.mjs", "dev": "node ./scripts/electron-dev.mjs",
"build:web-assets": "node ./scripts/build-web-assets.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs",
"build": "bun -e \"process.exit(0)\"", "build": "bun -e \"process.exit(0)\"",
"ensure:electron": "node ./scripts/ensure-electron.mjs",
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
@@ -39,7 +39,7 @@
"bundle:main": "bun ./scripts/bundle-main.mjs", "bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs", "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: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", "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", "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
-17
View File
@@ -5,23 +5,6 @@ module.exports = (context) => {
const resourcesPath = context.electronPlatformName === 'darwin' const resourcesPath = context.electronPlatformName === 'darwin'
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') ? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
: path.join(context.appOutDir, '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; if (context.electronPlatformName !== 'darwin') return;
const sourceAssetsPath = path.join(__dirname, '..', 'resources', 'icons', 'Assets.car'); const sourceAssetsPath = path.join(__dirname, '..', 'resources', 'icons', 'Assets.car');
@@ -30,7 +30,6 @@ const result = await Bun.build({
'@openchamber/web/*', '@openchamber/web/*',
'bun-pty', 'bun-pty',
'node-pty', 'node-pty',
'better-sqlite3',
], ],
minify: false, minify: false,
sourcemap: 'none', 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 = {}) { function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
@@ -183,6 +202,8 @@ async function main() {
let hmrApiPort = ''; let hmrApiPort = '';
let hmrUiPort = ''; let hmrUiPort = '';
ensureElectronInstalled();
if (useBundledUi) { if (useBundledUi) {
await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']); await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']);
} else { } 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}...`); 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). // Rebuild against the hoisted root node_modules (bun workspace layout).
// force=true re-links regardless of cached state; prebuild-install lookup is // force=true re-links regardless of cached state; prebuild-install lookup is
// bypassed by @electron/rebuild in favor of direct node-gyp builds. // bypassed by @electron/rebuild in favor of direct node-gyp builds.
@@ -28,14 +28,22 @@ try {
const iconsRoot = path.join(dataDir, 'icons'); const iconsRoot = path.join(dataDir, 'icons');
const thunarIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'org.xfce.thunar.png'); const thunarIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'org.xfce.thunar.png');
const codeIcon = path.join(iconsRoot, 'hicolor', '32x32', 'apps', 'code.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(userApps, { recursive: true });
await fs.mkdir(systemApps, { recursive: true }); await fs.mkdir(systemApps, { recursive: true });
await fs.mkdir(path.dirname(thunarIcon), { recursive: true }); await fs.mkdir(path.dirname(thunarIcon), { recursive: true });
await fs.mkdir(path.dirname(codeIcon), { 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. // Minimal valid 1x1 PNG.
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); 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(thunarIcon, png);
await fs.writeFile(codeIcon, png); await fs.writeFile(codeIcon, png);
await fs.writeFile(terminalIcon, png);
await fs.writeFile(helperIcon, helperPng);
const codeDesktopPath = path.join(userApps, 'code.desktop'); const codeDesktopPath = path.join(userApps, 'code.desktop');
await fs.writeFile(codeDesktopPath, [ 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-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(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'); 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, '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'), [ await fs.writeFile(path.join(systemApps, 'thunar.desktop'), [
'[Desktop Entry]', '[Desktop Entry]',
@@ -69,7 +94,7 @@ try {
assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included'); assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included');
const entries = await readLinuxDesktopEntries({ applicationDirs: [userApps, systemApps], env, homeDir: tempRoot }); 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 === '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 === 'Ghostty'), 'system desktop entry should be parsed');
assert(entries.some((entry) => entry.name === 'Plain Editor'), 'no-placeholder 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'); 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'); 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'], { const fetchedIcons = await fetchLinuxAppIcons(['Finder', 'Visual Studio Code'], {
entries, entries,
env, env,
@@ -151,6 +198,27 @@ try {
assert(fallbackTerminalSpecs.length >= 1, 'missing terminal desktop entry should include xdg-terminal-exec fallback'); 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]?.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[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 }); 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'); assert(defaultSpecs[0].kind === 'default', 'finder maps to safe default Linux opener spec');
@@ -169,6 +237,7 @@ try {
specs, specs,
terminalFileSpecs, terminalFileSpecs,
fallbackTerminalSpecs, fallbackTerminalSpecs,
terminalEmulatorSpecs,
defaultSpecs, defaultSpecs,
}, null, 2)); }, null, 2));
} finally { } 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 // 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 // package in the separate server worker, so verify its architecture here rather
// than Electron-rebuilding it with the source-built modules. // 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 */ /** electron-builder AppImage arch token: x64 → x86_64, arm64 → arm64 */
export const linuxAppImageArchSuffix = (architecture) => ( export const linuxAppImageArchSuffix = (architecture) => (
@@ -21,7 +21,7 @@ const createPayload = () => {
].join('\n')); ].join('\n'));
writeElf(path.join(root, 'openchamber'), 'x64'); writeElf(path.join(root, 'openchamber'), 'x64');
writeElf(path.join(root, 'resources/opencode-cli/opencode'), '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'); writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules', name), 'x64');
} }
return root; return root;
@@ -53,7 +53,7 @@ test('verifies identity, version, and native payload architecture', () => {
expectedOpenCodeVersion: '1.17.18', expectedOpenCodeVersion: '1.17.18',
runCliVersion: () => '1.17.18', runCliVersion: () => '1.17.18',
}); });
assert.equal(result.nativeModuleCount, 3); assert.equal(result.nativeModuleCount, 2);
} finally { } finally {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.17.2", "version": "1.18.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/main.tsx", "main": "src/main.tsx",
+17 -13
View File
@@ -1800,26 +1800,28 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
style={{ paddingBottom: 'calc(0.375rem + var(--oc-safe-area-bottom, 0px))' }} style={{ paddingBottom: 'calc(0.375rem + var(--oc-safe-area-bottom, 0px))' }}
> >
{footer.instanceLabel && footer.onOpenInstances ? ( {footer.instanceLabel && footer.onOpenInstances ? (
<button <Button
type="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} onClick={footer.onOpenInstances}
aria-label={t('mobile.menu.instances')} aria-label={t('mobile.menu.instances')}
style={{ touchAction: 'manipulation' }} style={{ touchAction: 'manipulation' }}
> >
<Icon name="server" className="size-[18px] shrink-0 text-muted-foreground" /> <Icon name="server" className="size-[18px]" />
<span className="block min-w-0 truncate typography-ui-label text-foreground"> <span className="block min-w-0 truncate">{footer.instanceLabel}</span>
{footer.instanceLabel} </Button>
</span>
</button>
) : ( ) : (
<div className="min-w-0 flex-1" /> <div className="min-w-0 flex-1" />
)} )}
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{footer.onOpenUpdate ? ( {footer.onOpenUpdate ? (
<button <Button
type="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} onClick={footer.onOpenUpdate}
aria-label={t('mobile.menu.update')} aria-label={t('mobile.menu.update')}
title={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" /> <Icon name="download" className="size-5" />
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden /> <span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
</button> </Button>
) : null} ) : null}
<button <Button
type="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} onClick={footer.onOpenSettings}
aria-label={t('mobile.menu.settings')} aria-label={t('mobile.menu.settings')}
title={t('mobile.menu.settings')} title={t('mobile.menu.settings')}
style={{ touchAction: 'manipulation' }} style={{ touchAction: 'manipulation' }}
> >
<Icon name="settings-3" className="size-5" /> <Icon name="settings-3" className="size-5" />
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
+10 -2
View File
@@ -159,6 +159,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
*/ */
const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_SENDING_IDS: string[] = [];
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const renameFileForAttachmentCitation = (file: File, filename: string): File => { const renameFileForAttachmentCitation = (file: File, filename: string): File => {
if (file.name === filename) { 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, hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
} }
: getCurrentInputSnapshot(); : 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.filter((message) => message.id === queuedMessageId)
: queuedMessages; : queuedMessages
).filter((message) => !sendingIds.includes(message.id));
if (queuedOnly && autoReviewRunning) { if (queuedOnly && autoReviewRunning) {
return; return;
@@ -16,6 +16,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
interface TextSelectionMenuProps { interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
@@ -309,6 +310,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Clear selection // Clear selection
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [selectedTextMarkdown, setPendingInputText, hideMenu]); }, [selectedTextMarkdown, setPendingInputText, hideMenu]);
const handleCreateNewSession = React.useCallback(async () => { const handleCreateNewSession = React.useCallback(async () => {
@@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type FileNode = { type FileNode = {
@@ -190,6 +191,7 @@ interface FileRowProps {
root: string; root: string;
isExpanded: boolean; isExpanded: boolean;
isActive: boolean; isActive: boolean;
isBrowserClient: boolean;
status?: FileStatus | null; status?: FileStatus | null;
badge?: { modified: number; added: number } | null; badge?: { modified: number; added: number } | null;
permissions: { permissions: {
@@ -211,6 +213,7 @@ const FileRow: React.FC<FileRowProps> = ({
root, root,
isExpanded, isExpanded,
isActive, isActive,
isBrowserClient,
status, status,
badge, badge,
permissions, permissions,
@@ -223,6 +226,9 @@ const FileRow: React.FC<FileRowProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const isDir = node.type === 'directory'; const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; 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 // 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 // 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 [rightClickOpen, setRightClickOpen] = React.useState(false);
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return; if (!hasMenuActions) return;
event?.preventDefault(); event?.preventDefault();
setRightClickOpen(true); setRightClickOpen(true);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal]); }, [hasMenuActions]);
const handleInteraction = React.useCallback(() => { const handleInteraction = React.useCallback(() => {
if (isDir) { if (isDir) {
@@ -283,10 +289,10 @@ const FileRow: React.FC<FileRowProps> = ({
toast.error(t('sidebarFilesTree.toast.operationFailed')); 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> </Item>
)} )}
{canReveal && ( {canRevealPath && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}> <Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())} <Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</Item> </Item>
@@ -362,7 +368,7 @@ const FileRow: React.FC<FileRowProps> = ({
</span> </span>
)} )}
</button> </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"> <div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
<DropdownMenu <DropdownMenu
open={contextMenuOpen} open={contextMenuOpen}
@@ -406,6 +412,7 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
&& prev.root === next.root && prev.root === next.root
&& prev.isExpanded === next.isExpanded && prev.isExpanded === next.isExpanded
&& prev.isActive === next.isActive && prev.isActive === next.isActive
&& prev.isBrowserClient === next.isBrowserClient
&& prev.status === next.status && prev.status === next.status
&& prev.badge === next.badge && prev.badge === next.badge
&& prev.permissions === next.permissions && prev.permissions === next.permissions
@@ -422,7 +429,8 @@ const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
export const SidebarFilesTree: React.FC = () => { export const SidebarFilesTree: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const { files } = useRuntimeAPIs(); const { files, runtime } = useRuntimeAPIs();
const isBrowserClient = isBrowserClientRuntime(runtime.platform);
const currentDirectory = useEffectiveDirectory() ?? ''; const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim()); const root = normalizePath(currentDirectory.trim());
const showHidden = useDirectoryShowHidden(); const showHidden = useDirectoryShowHidden();
@@ -1045,6 +1053,7 @@ export const SidebarFilesTree: React.FC = () => {
root={root} root={root}
isExpanded={isExpanded} isExpanded={isExpanded}
isActive={isActive} isActive={isActive}
isBrowserClient={isBrowserClient}
status={!isDir ? getFileStatus(node.path) : undefined} status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined}
permissions={fileRowPermissions} permissions={fileRowPermissions}
@@ -1,6 +1,11 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability'; import { shouldLoadAvailableProviders } from './providerAvailability';
import { listOAuthMethods, normalizeAuthType } from './providerAuthMethods'; import {
getOAuthAuthMethods,
normalizeAuthType,
parseAuthPayload,
shouldShowApiKeyAuth,
} from './providerAuth';
describe('ProvidersPage available provider loading', () => { describe('ProvidersPage available provider loading', () => {
test('loads available providers only in add-provider mode', () => { test('loads available providers only in add-provider mode', () => {
@@ -9,35 +14,47 @@ describe('ProvidersPage available provider loading', () => {
}); });
}); });
describe('ProvidersPage auth method loading', () => { describe('provider auth method helpers', () => {
test('loads auth methods for add mode and reconnect panel', () => { test('normalizeAuthType recognizes oauth and api labels', () => {
expect(shouldLoadProviderAuthMethods(false, false)).toBe(false); expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
expect(shouldLoadProviderAuthMethods(true, false)).toBe(true); expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api');
expect(shouldLoadProviderAuthMethods(false, true)).toBe(true); expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth');
expect(shouldLoadProviderAuthMethods(true, true)).toBe(true); expect(normalizeAuthType({ name: 'API key' })).toBe('api');
}); });
});
test('parseAuthPayload keeps only object auth method entries', () => {
describe('ProvidersPage OAuth method indexes', () => { expect(parseAuthPayload({
test('preserves the original provider.auth() index after filtering', () => { cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'],
const methods = listOAuthMethods([ openai: null,
{ type: 'api' }, })).toEqual({
{ type: 'oauth', label: 'Browser' }, cursor: [{ type: 'oauth', label: 'Cursor' }],
]); });
expect(methods).toEqual([{ method: { type: 'oauth', label: 'Browser' }, methodIndex: 1 }]); expect(parseAuthPayload(null)).toEqual({});
}); });
test('keeps multiple OAuth indexes relative to the full methods array', () => { test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => {
const methods = listOAuthMethods([ expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false);
{ type: 'oauth', label: 'First' }, expect(shouldShowApiKeyAuth([
{ type: 'api' }, { type: 'api', label: 'API Key' },
{ type: 'oauth', label: 'Second' }, { type: 'oauth', label: 'ChatGPT' },
]); ])).toBe(true);
expect(methods.map((entry) => entry.methodIndex)).toEqual([0, 2]); expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true);
}); // Unknown / unloaded methods keep the legacy API key fallback.
expect(shouldShowApiKeyAuth([])).toBe(true);
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'); 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 { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability'; import { shouldLoadAvailableProviders } from './providerAvailability';
import { listOAuthMethods } from './providerAuthMethods'; import {
getOAuthAuthMethods,
parseAuthPayload,
shouldShowApiKeyAuth,
type AuthMethod,
} from './providerAuth';
import { CustomProviderForm } from './CustomProviderForm'; import { CustomProviderForm } from './CustomProviderForm';
import { import {
buildAuthSetRequest, buildAuthSetRequest,
@@ -60,16 +65,6 @@ const formatTokens = (value?: number | null) => {
const ADD_PROVIDER_ID = '__add_provider__'; 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 { interface ProviderOption {
id: string; id: string;
name?: string; name?: string;
@@ -90,19 +85,6 @@ interface ProviderSources {
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null; 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 => { const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
if (typeof entry === 'string') { if (typeof entry === 'string') {
return { id: entry }; return { id: entry };
@@ -182,7 +164,6 @@ export const ProvidersPage: React.FC = () => {
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null); const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null); const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
const isAddMode = selectedProviderId === ADD_PROVIDER_ID; const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
const loadAuthMethods = shouldLoadProviderAuthMethods(isAddMode, showAuthPanel);
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID; const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
const isCustomEditMode = Boolean( const isCustomEditMode = Boolean(
editingCustomProviderId editingCustomProviderId
@@ -198,13 +179,16 @@ export const ProvidersPage: React.FC = () => {
}, [providers, selectedProviderId, setSelectedProvider]); }, [providers, selectedProviderId, setSelectedProvider]);
React.useEffect(() => { 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; return;
} }
let isMounted = true; let isMounted = true;
const fetchAuthMethods = async () => { const loadAuthMethods = async () => {
setAuthLoading(true); setAuthLoading(true);
try { try {
const result = await opencodeClient.getSdkClient().provider.auth(); const result = await opencodeClient.getSdkClient().provider.auth();
@@ -224,12 +208,12 @@ export const ProvidersPage: React.FC = () => {
} }
}; };
void fetchAuthMethods(); loadAuthMethods();
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [loadAuthMethods, t]); }, [selectedProviderId, t]);
React.useEffect(() => { React.useEffect(() => {
if (!shouldLoadAvailableProviders(isAddMode)) { if (!shouldLoadAvailableProviders(isAddMode)) {
@@ -316,6 +300,26 @@ export const ProvidersPage: React.FC = () => {
} }
}, [selectedProviderId, editingCustomProviderId]); }, [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(() => { React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
return; return;
@@ -773,123 +777,126 @@ export const ProvidersPage: React.FC = () => {
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p> <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 candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = listOAuthMethods(candidateAuthMethods); const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
if (candidateOAuthMethods.length === 0) {
return null;
}
return ( return (
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2"> <>
{candidateOAuthMethods.map(({ method, methodIndex }) => { {showApiKey ? (
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); <div className="py-1.5">
const codeKey = `${candidateProviderId}:${methodIndex}`; <label className="typography-ui-label text-foreground flex items-center gap-1.5">
const isPending = {t('settings.providers.page.auth.apiKeyLabel')}
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; <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 ( {candidateOAuthMethods.length > 0 ? (
<div key={`${candidateProviderId}-${methodLabel}-${methodIndex}`} className="space-y-3"> <div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
<div className="flex items-center justify-between gap-2"> {candidateOAuthMethods.map(({ method, methodIndex }) => {
<div> const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
<div className="typography-ui-label text-foreground">{methodLabel}</div> const codeKey = `${candidateProviderId}:${methodIndex}`;
{(method.description || method.help) && ( const isPending =
<div className="typography-meta text-muted-foreground"> pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
{String(method.description || method.help)}
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>
)} )}
</div> </div>
<Button );
variant="outline" })}
size="xs" </div>
className="!font-normal" ) : null}
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>
); );
})()} })()}
</> </>
@@ -914,7 +921,8 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = listOAuthMethods(providerAuthMethods); const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
const sourcesLoaded = Boolean(selectedSources); const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded const isEditableCustomProvider = sourcesLoaded
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources); && isConfigDefinedCustomProvider(selectedProvider, selectedSources);
@@ -924,7 +932,11 @@ export const ProvidersPage: React.FC = () => {
const hasStoredAuth = Boolean(selectedSources?.auth.exists); const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0; const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials; 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 filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : ''; 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"> <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" /> <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> <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>
) : ( ) : (
<div className="flex items-center gap-1.5 py-1.5"> <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="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="py-1.5"> {showApiKeyAuth ? (
<label className="typography-ui-label text-foreground flex items-center gap-1.5"> <div className="py-1.5">
{t('settings.providers.page.auth.apiKeyLabel')} <label className="typography-ui-label text-foreground flex items-center gap-1.5">
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint> {t('settings.providers.page.auth.apiKeyLabel')}
</label> <SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5"> </label>
<Input <div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
type="password" <Input
value={apiKeyInputs[selectedProvider.id] ?? ''} type="password"
onChange={(event) => value={apiKeyInputs[selectedProvider.id] ?? ''}
setApiKeyInputs((prev) => ({ onChange={(event) =>
...prev, setApiKeyInputs((prev) => ({
[selectedProvider.id]: event.target.value, ...prev,
})) [selectedProvider.id]: event.target.value,
} }))
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} }
className="flex-1 font-mono text-xs" placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
/> className="flex-1 font-mono text-xs"
<Button />
size="xs" <Button
className="!font-normal shrink-0" size="xs"
onClick={() => handleSaveApiKey(selectedProvider.id)} className="!font-normal shrink-0"
disabled={authBusyKey === `api:${selectedProvider.id}`} 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> {authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
</div> </div>
</div> ) : null}
{oauthAuthMethods.length > 0 && ( {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 }) => { {oauthAuthMethods.map(({ method, methodIndex }) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
const codeKey = `${selectedProvider.id}:${methodIndex}`; const codeKey = `${selectedProvider.id}:${methodIndex}`;
@@ -1058,7 +1072,7 @@ export const ProvidersPage: React.FC = () => {
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex; pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
return ( 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 className="flex items-center justify-between gap-2">
<div> <div>
<div className="typography-ui-label text-foreground">{methodLabel}</div> <div className="typography-ui-label text-foreground">{methodLabel}</div>
@@ -1168,14 +1182,13 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
</SettingsSection> </SettingsSection>
{showModelsSection ? (
<SettingsSection <SettingsSection
title={t('settings.providers.page.models.title')} title={t('settings.providers.page.models.title')}
titleAccessory={ titleAccessory={
providerModels.length > 0 ? ( <span className="typography-micro text-muted-foreground font-normal">
<span className="typography-micro text-muted-foreground font-normal"> ({providerModels.length})
({providerModels.length}) </span>
</span>
) : null
} }
headerAction={( headerAction={(
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1284,6 +1297,7 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
)} )}
</SettingsSection> </SettingsSection>
) : null}
</SettingsPageLayout> </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; 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 deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const archiveSession = useSessionUIStore((state) => state.archiveSession); const archiveSession = useSessionUIStore((state) => state.archiveSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions); const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
const { const {
copiedSessionId, copiedSessionId,
@@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleCopySessionId, handleCopySessionId,
handleUnshareSession, handleUnshareSession,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
confirmDeleteSession, confirmDeleteSession,
} = useSessionActions({ } = useSessionActions({
mobileVariant, mobileVariant,
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
deleteSessions, deleteSessions,
archiveSession, archiveSession,
archiveSessions, archiveSessions,
unarchiveSession,
childrenMap, childrenMap,
showDeletionDialog, showDeletionDialog,
setDeleteSessionConfirm, setDeleteSessionConfirm,
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId); const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession); const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession); const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename); const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => { const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
@@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
createFolderAndStartRename={stableCreateFolderAndStartRename} createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab} openContextPanelTab={openContextPanelTab}
handleDeleteSession={stableHandleDeleteSession} handleDeleteSession={stableHandleDeleteSession}
handleRestoreSession={stableHandleRestoreSession}
mobileVariant={mobileVariant} mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions} alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleBulkCreateFolderAndMove, handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder, handleBulkRemoveFromFolder,
handleBulkDelete, handleBulkDelete,
handleBulkRestore,
confirmBulkDelete, confirmBulkDelete,
} = useSidebarBulkActions({ } = useSidebarBulkActions({
isInlineEditing, isInlineEditing,
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
removeSessionsFromFolders, removeSessionsFromFolders,
createFolderAndStartRename, createFolderAndStartRename,
archiveSessions, archiveSessions,
unarchiveSessions,
deleteSessions, deleteSessions,
setBulkDeleteConfirm, setBulkDeleteConfirm,
}); });
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
onCreateFolderAndMove={handleBulkCreateFolderAndMove} onCreateFolderAndMove={handleBulkCreateFolderAndMove}
onRemoveFromFolder={handleBulkRemoveFromFolder} onRemoveFromFolder={handleBulkRemoveFromFolder}
canRemoveFromFolder={bulkCanRemoveFromFolder} canRemoveFromFolder={bulkCanRemoveFromFolder}
onRestore={handleBulkRestore}
onDelete={handleBulkDelete} onDelete={handleBulkDelete}
onDone={handleExitSelectionMode} onDone={handleExitSelectionMode}
/> />
@@ -21,6 +21,7 @@ type Props = {
onCreateFolderAndMove: () => void; onCreateFolderAndMove: () => void;
onRemoveFromFolder: () => void; onRemoveFromFolder: () => void;
canRemoveFromFolder: boolean; canRemoveFromFolder: boolean;
onRestore: () => void;
onDelete: () => void; onDelete: () => void;
onDone: () => void; onDone: () => void;
}; };
@@ -34,6 +35,7 @@ export const BulkActionBar: React.FC<Props> = ({
onCreateFolderAndMove, onCreateFolderAndMove,
onRemoveFromFolder, onRemoveFromFolder,
canRemoveFromFolder, canRemoveFromFolder,
onRestore,
onDelete, onDelete,
onDone, onDone,
}) => { }) => {
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
</DropdownMenu> </DropdownMenu>
) : null} ) : 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <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. - 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. - 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). - 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. - 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. - 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. - 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; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void; 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; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
handleRestoreSession: (session: Session) => void;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
renderSessionNode: ( renderSessionNode: (
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
createFolderAndStartRename, createFolderAndStartRename,
openContextPanelTab, openContextPanelTab,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
mobileVariant, mobileVariant,
alwaysShowActions, alwaysShowActions,
renderSessionNode, renderSessionNode,
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
{t('sessions.sidebar.bulkActions.archive')} {t('sessions.sidebar.bulkActions.archive')}
</Item> </Item>
) : null} ) : 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 })}> <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" /> <Icon name="delete-bin" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.bulkActions.delete')} {t('sessions.sidebar.bulkActions.delete')}
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
&& prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.openContextPanelTab === next.openContextPanelTab && prev.openContextPanelTab === next.openContextPanelTab
&& prev.handleDeleteSession === next.handleDeleteSession && prev.handleDeleteSession === next.handleDeleteSession
&& prev.handleRestoreSession === next.handleRestoreSession
&& prev.renderSessionNode === next.renderSessionNode; && prev.renderSessionNode === next.renderSessionNode;
}; };
@@ -40,6 +40,7 @@ type Args = {
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
archiveSession: (id: string) => Promise<boolean>; archiveSession: (id: string) => Promise<boolean>;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSession: (id: string) => Promise<boolean>;
childrenMap: Map<string, Session[]>; childrenMap: Map<string, Session[]>;
showDeletionDialog: boolean; showDeletionDialog: boolean;
setDeleteSessionConfirm: DeleteSessionConfirmSetter; setDeleteSessionConfirm: DeleteSessionConfirmSetter;
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
await executeDeleteSession(session, { archivedBucket }, { descendantIds }); await executeDeleteSession(session, { archivedBucket }, { descendantIds });
}, [args, executeDeleteSession]); }, [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 { return {
copiedSessionId, copiedSessionId,
handleSessionSelect, handleSessionSelect,
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
handleCopySessionId, handleCopySessionId,
handleUnshareSession, handleUnshareSession,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
confirmDeleteSession, confirmDeleteSession,
}; };
}; };
@@ -18,6 +18,7 @@ type Args = {
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void; removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{ setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number; sessionCount: number;
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
removeSessionsFromFolders, removeSessionsFromFolders,
createFolderAndStartRename, createFolderAndStartRename,
archiveSessions, archiveSessions,
unarchiveSessions,
deleteSessions, deleteSessions,
setBulkDeleteConfirm, setBulkDeleteConfirm,
} = args; } = args;
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived }); setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]); }, [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 () => { const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null); setBulkDeleteConfirm(null);
await executeBulkDelete(); await executeBulkDelete();
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
handleBulkCreateFolderAndMove, handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder, handleBulkRemoveFromFolder,
handleBulkDelete, handleBulkDelete,
handleBulkRestore,
confirmBulkDelete, confirmBulkDelete,
}; };
}; };
@@ -65,6 +65,12 @@ export const HelpDialog: React.FC = () => {
icon: "layout-left", icon: "layout-left",
keys: '', keys: '',
}, },
{
id: 'add_selection_to_chat',
descriptionKey: "helpDialog.item.addSelectionToChat",
icon: "add",
keys: '',
},
{ {
id: 'cycle_agent', id: 'cycle_agent',
keys: '', keys: '',
+13
View File
@@ -32,6 +32,18 @@ const TINT_DESTRUCTIVE = [
"dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]", "dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]",
].join(" ") ].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( 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", "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, TINT_DESTRUCTIVE,
"focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", "focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
), ),
info: TINT_INFO,
neutral: neutral:
"bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active", "bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active",
outline: outline:
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { cn, formatDirectoryName } from '@/lib/utils'; import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
const setOpen = useUIStore((state) => state.setArchivePageOpen); const setOpen = useUIStore((state) => state.setArchivePageOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : [])); const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
setOpen(false); setOpen(false);
}, [setActiveMainTab, setCurrentSession, setOpen]); }, [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; if (!open) return null;
const renderDirectoryItem = ( const renderDirectoryItem = (
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
return ( return (
<div <div
key={session.id} 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)} onClick={() => openSession(session)}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75"> <span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())} {formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
</span> </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 <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
+13 -6
View File
@@ -70,7 +70,7 @@ import { Icon } from "@/components/icon/Icon";
import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useMessageTTS } from '@/hooks/useMessageTTS';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes'; 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 { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -361,6 +361,7 @@ interface FileRowProps {
isExpanded: boolean; isExpanded: boolean;
isActive: boolean; isActive: boolean;
isMobile: boolean; isMobile: boolean;
isBrowserClient: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
status?: FileStatus | null; status?: FileStatus | null;
badge?: { modified: number; added: number } | null; badge?: { modified: number; added: number } | null;
@@ -388,6 +389,7 @@ const FileRow: React.FC<FileRowProps> = ({
isExpanded, isExpanded,
isActive, isActive,
isMobile, isMobile,
isBrowserClient,
alwaysShowActions, alwaysShowActions,
status, status,
badge, badge,
@@ -405,14 +407,17 @@ const FileRow: React.FC<FileRowProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const isDir = node.type === 'directory'; const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; 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) => { const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) { if (!hasMenuActions) {
return; return;
} }
event?.preventDefault(); event?.preventDefault();
setRightClickMenuPath(node.path); setRightClickMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]); }, [hasMenuActions, node.path, setRightClickMenuPath]);
const handleInteraction = React.useCallback(() => { const handleInteraction = React.useCallback(() => {
if (isDir) { if (isDir) {
@@ -474,10 +479,10 @@ const FileRow: React.FC<FileRowProps> = ({
toast.error(t('sidebarFilesTree.toast.operationFailed')); 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> </Item>
)} )}
{canReveal && ( {canRevealPath && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}> <Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 size-4" /> {t(getRevealLabelKey())} <Icon name="folder-received" className="mr-2 size-4" /> {t(getRevealLabelKey())}
</Item> </Item>
@@ -546,7 +551,7 @@ const FileRow: React.FC<FileRowProps> = ({
</span> </span>
)} )}
</button> </button>
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( {hasMenuActions && (
<div className={cn( <div className={cn(
"absolute right-1 top-1/2 -translate-y-1/2", "absolute right-1 top-1/2 -translate-y-1/2",
alwaysShowActions ? "opacity-100" : "opacity-0 focus-within:opacity-100 group-hover:opacity-100" 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 { files, runtime } = useRuntimeAPIs();
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
const { isMobile, isTablet, screenWidth } = useDeviceInfo(); const { isMobile, isTablet, screenWidth } = useDeviceInfo();
const isBrowserClient = isBrowserClientRuntime(runtime.platform);
const alwaysShowActions = isMobile || isTablet; const alwaysShowActions = isMobile || isTablet;
const showHidden = useDirectoryShowHidden(); const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored(); const showGitignored = useFilesViewShowGitignored();
@@ -2302,6 +2308,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
isExpanded={isExpanded} isExpanded={isExpanded}
isActive={isActive} isActive={isActive}
isMobile={isMobile} isMobile={isMobile}
isBrowserClient={isBrowserClient}
alwaysShowActions={alwaysShowActions} alwaysShowActions={alwaysShowActions}
status={!isDir ? getFileStatus(node.path) : undefined} status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined}
@@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { addSelectionToChat } from '@/lib/addSelectionToChat';
import { hasOpenDropdown } from './keyboard-shortcut-dom'; import { hasOpenDropdown } from './keyboard-shortcut-dom';
export const useKeyboardShortcuts = () => { export const useKeyboardShortcuts = () => {
@@ -337,6 +338,12 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
if (eventMatchesShortcut(e, combo('add_selection_to_chat'))) {
e.preventDefault();
addSelectionToChat();
return;
}
if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { if (eventMatchesShortcut(e, combo('toggle_sidebar'))) {
e.preventDefault(); e.preventDefault();
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
+6
View File
@@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { showOpenCodeStatus } from '@/lib/openCodeStatus';
import { addSelectionToChat } from '@/lib/addSelectionToChat';
const getActiveElementSelectedText = (): string => { const getActiveElementSelectedText = (): string => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -77,6 +78,7 @@ type MenuAction =
| 'toggle-terminal' | 'toggle-terminal'
| 'toggle-terminal-expanded' | 'toggle-terminal-expanded'
| 'copy' | 'copy'
| 'add-selection-to-chat'
| 'theme-light' | 'theme-light'
| 'theme-dark' | 'theme-dark'
| 'theme-system' | 'theme-system'
@@ -278,6 +280,10 @@ export const useMenuActions = (
setThemeMode('system'); setThemeMode('system');
break; break;
case 'add-selection-to-chat':
addSelectionToChat();
break;
case 'toggle-sidebar': case 'toggle-sidebar':
toggleSidebar(); toggleSidebar();
break; break;
@@ -221,7 +221,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return; 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) { if (!payload) {
return; return;
} }
@@ -248,6 +250,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
} }
inFlightSessionsRef.current.add(targetKey); 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 { try {
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, { await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
@@ -271,6 +277,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
retryScheduler.schedule(nextAttemptAt); retryScheduler.schedule(nextAttemptAt);
} finally { } finally {
inFlightSessionsRef.current.delete(targetKey); 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);
});
});
+166
View File
@@ -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;
};
+5 -1
View File
@@ -441,7 +441,11 @@ export const debugUtils = {
const sources = { const sources = {
attachment, attachment,
worktreeMetadata, 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, selected,
remembered: remembered.runtime, remembered: remembered.runtime,
}; };
+15
View File
@@ -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);
});
});
+10 -1
View File
@@ -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 { getInjectedBootOutcome } from '@/lib/desktopBoot';
import type { DraftStarterRef } from '@/lib/draftStarters'; import type { DraftStarterRef } from '@/lib/draftStarters';
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
@@ -562,6 +562,15 @@ export const isWebRuntime = (): boolean => {
return !isVSCodeRuntime(); 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> => { export const getDesktopHomeDirectory = async (): Promise<string | null> => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const embedded = window.__OPENCHAMBER_HOME__; 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.open_settings.label': 'Einstellungen öffnen',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Terminal-Dock umschalten', '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.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_sidebar.label': 'Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte 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', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
+10
View File
@@ -426,6 +426,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert', 'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen', '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.none': 'Noch keine Ordner',
'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...', 'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...',
'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen', '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.delete.error': 'Fehler beim Löschen der Sitzung',
'sessions.sidebar.session.archive.success': 'Sitzung archiviert', 'sessions.sidebar.session.archive.success': 'Sitzung archiviert',
'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung', '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.checksPassed': '{success}/{total} Checks bestanden',
'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen', 'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen',
'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend', 'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend',
@@ -1085,6 +1092,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Umbenennen', 'sidebarFilesTree.menu.rename': 'Umbenennen',
'sidebarFilesTree.menu.copyPath': 'Pfad kopieren', 'sidebarFilesTree.menu.copyPath': 'Pfad kopieren',
'sidebarFilesTree.menu.save': 'Speichern', 'sidebarFilesTree.menu.save': 'Speichern',
'sidebarFilesTree.menu.download': 'Herunterladen',
'sidebarFilesTree.menu.newFile': 'Neue Datei', 'sidebarFilesTree.menu.newFile': 'Neue Datei',
'sidebarFilesTree.menu.newFolder': 'Neuer Ordner', 'sidebarFilesTree.menu.newFolder': 'Neuer Ordner',
'sidebarFilesTree.menu.delete': 'Löschen', 'sidebarFilesTree.menu.delete': 'Löschen',
@@ -1531,6 +1539,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen', 'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen',
'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)', 'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)',
'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten', 'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten',
'helpDialog.item.addSelectionToChat': 'Auswahl zum Chat hinzufügen',
'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)', 'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)',
'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen', 'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen',
'helpDialog.item.navigateModels': 'Modelle navigieren (in Auswahl)', '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.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen',
'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen', 'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen',
'sessions.archivePage.deleteSessionAria': '{title} löschen', 'sessions.archivePage.deleteSessionAria': '{title} löschen',
'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen',
'header.sessionActions.openAria': 'Sitzungsaktionen öffnen', 'header.sessionActions.openAria': 'Sitzungsaktionen öffnen',
'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren', 'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren',
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', '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.open_settings.label': 'Open settings',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Toggle terminal dock', '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.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_sidebar.label': 'Toggle sidebar',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
+10
View File
@@ -449,6 +449,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project', 'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}', 'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
'sessions.archivePage.deleteSessionAria': 'Delete {title}', 'sessions.archivePage.deleteSessionAria': 'Delete {title}',
'sessions.archivePage.restoreSessionAria': 'Restore {title}',
'sessions.switcher.openAria': 'Open session switcher', 'sessions.switcher.openAria': 'Open session switcher',
'sessions.switcher.empty': 'No recent sessions', 'sessions.switcher.empty': 'No recent sessions',
'sessions.switcher.draftTitle': 'New session', 'sessions.switcher.draftTitle': 'New session',
@@ -470,6 +471,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions', 'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Failed to archive {count} sessions', '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.none': 'No folders yet',
'sessions.sidebar.folders.newFolderEllipsis': 'New folder...', 'sessions.sidebar.folders.newFolderEllipsis': 'New folder...',
'sessions.sidebar.folders.removeFromFolder': 'Remove from 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.delete.error': 'Failed to delete session',
'sessions.sidebar.session.archive.success': 'Session archived', 'sessions.sidebar.session.archive.success': 'Session archived',
'sessions.sidebar.session.archive.error': 'Failed to archive session', '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.checksPassed': '{success}/{total} checks passed',
'sessions.sidebar.group.pr.failingCount': '{count} failing', 'sessions.sidebar.group.pr.failingCount': '{count} failing',
'sessions.sidebar.group.pr.pendingCount': '{count} pending', 'sessions.sidebar.group.pr.pendingCount': '{count} pending',
@@ -1225,6 +1233,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Rename', 'sidebarFilesTree.menu.rename': 'Rename',
'sidebarFilesTree.menu.copyPath': 'Copy Path', 'sidebarFilesTree.menu.copyPath': 'Copy Path',
'sidebarFilesTree.menu.save': 'Save', 'sidebarFilesTree.menu.save': 'Save',
'sidebarFilesTree.menu.download': 'Download',
'sidebarFilesTree.menu.newFile': 'New File', 'sidebarFilesTree.menu.newFile': 'New File',
'sidebarFilesTree.menu.newFolder': 'New Folder', 'sidebarFilesTree.menu.newFolder': 'New Folder',
'sidebarFilesTree.menu.delete': 'Delete', 'sidebarFilesTree.menu.delete': 'Delete',
@@ -1678,6 +1687,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Open Command Palette', 'helpDialog.item.openCommandPalette': 'Open Command Palette',
'helpDialog.item.showKeyboardShortcuts': 'Show Keyboard Shortcuts (this dialog)', 'helpDialog.item.showKeyboardShortcuts': 'Show Keyboard Shortcuts (this dialog)',
'helpDialog.item.toggleSessionSidebar': 'Toggle Session Sidebar', 'helpDialog.item.toggleSessionSidebar': 'Toggle Session Sidebar',
'helpDialog.item.addSelectionToChat': 'Add Selection to Chat',
'helpDialog.item.cycleAgent': 'Cycle Agent (chat input)', 'helpDialog.item.cycleAgent': 'Cycle Agent (chat input)',
'helpDialog.item.openModelSelector': 'Open Model Selector', 'helpDialog.item.openModelSelector': 'Open Model Selector',
'helpDialog.item.navigateModels': 'Navigate Models (in picker)', '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.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.label": "Mostrar u ocultar panel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer 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_sidebar.label": "Mostrar u ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto', "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', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto", "sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}", "sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Eliminar {title}", "sessions.archivePage.deleteSessionAria": "Eliminar {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir selector de sesiones", "sessions.switcher.openAria": "Abrir selector de sesiones",
"sessions.switcher.empty": "No hay sesiones recientes", "sessions.switcher.empty": "No hay sesiones recientes",
"sessions.switcher.draftTitle": "Nueva sesión", "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.archivedPlural": "Se archivaron {count} sesiones",
"sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión", "sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión",
"sessions.sidebar.bulkActions.failedArchivePlural": "No se pudo archivar {count} sesiones", "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.none": "No hay carpetas aún",
"sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...", "sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...",
"sessions.sidebar.folders.removeFromFolder": "Quitar de 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.delete.error": "No se pudo eliminar la sesión",
"sessions.sidebar.session.archive.success": "Sesión archivada", "sessions.sidebar.session.archive.success": "Sesión archivada",
"sessions.sidebar.session.archive.error": "No se pudo archivar la sesión", "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.checksPassed": "{success}/{total} comprobaciones aprobadas",
"sessions.sidebar.group.pr.failingCount": "{count} con fallos", "sessions.sidebar.group.pr.failingCount": "{count} con fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendientes", "sessions.sidebar.group.pr.pendingCount": "{count} pendientes",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Cambiar nombre", "sidebarFilesTree.menu.rename": "Cambiar nombre",
"sidebarFilesTree.menu.copyPath": "Copiar ruta", "sidebarFilesTree.menu.copyPath": "Copiar ruta",
"sidebarFilesTree.menu.save": "Guardar", "sidebarFilesTree.menu.save": "Guardar",
"sidebarFilesTree.menu.download": "Descargar",
"sidebarFilesTree.menu.newFile": "Nuevo archivo", "sidebarFilesTree.menu.newFile": "Nuevo archivo",
"sidebarFilesTree.menu.newFolder": "Nueva carpeta", "sidebarFilesTree.menu.newFolder": "Nueva carpeta",
"sidebarFilesTree.menu.delete": "Eliminar", "sidebarFilesTree.menu.delete": "Eliminar",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.openCommandPalette": "Abrir paleta de comandos",
"helpDialog.item.showKeyboardShortcuts": "Mostrar atajos de teclado (este diálogo)", "helpDialog.item.showKeyboardShortcuts": "Mostrar atajos de teclado (este diálogo)",
"helpDialog.item.toggleSessionSidebar": "Mostrar u ocultar barra lateral de sesión", "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.cycleAgent": "Cambiar agente (entrada de chat)",
"helpDialog.item.openModelSelector": "Abrir selector de modelos", "helpDialog.item.openModelSelector": "Abrir selector de modelos",
"helpDialog.item.navigateModels": "Navegar modelos (en selector)", "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.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.label': 'Basculer la station d\'accueil du terminal',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu', '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_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.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
+10
View File
@@ -285,6 +285,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet', '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.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}', 'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
'sessions.archivePage.restoreSessionAria': 'Restaurer {title}',
'sessions.switcher.openAria': 'Sélecteur de session ouvert', 'sessions.switcher.openAria': 'Sélecteur de session ouvert',
'sessions.switcher.empty': 'Aucune session récente', 'sessions.switcher.empty': 'Aucune session récente',
'sessions.switcher.draftTitle': 'Nouvelle session', 'sessions.switcher.draftTitle': 'Nouvelle session',
@@ -306,6 +307,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées', 'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Échec de l\'archivage de la session {count}', '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.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.none': 'Aucun dossier pour l\'instant',
'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...', 'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...',
'sessions.sidebar.folders.removeFromFolder': 'Supprimer du 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.delete.error': 'Échec de la suppression de la session',
'sessions.sidebar.session.archive.success': 'Session archivée', 'sessions.sidebar.session.archive.success': 'Session archivée',
'sessions.sidebar.session.archive.error': 'Échec de l\'archivage de la session', '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.checksPassed': 'Contrôles {success}/{total} réussis',
'sessions.sidebar.group.pr.failingCount': 'Échec de {count}', 'sessions.sidebar.group.pr.failingCount': 'Échec de {count}',
'sessions.sidebar.group.pr.pendingCount': '{count} en attente', 'sessions.sidebar.group.pr.pendingCount': '{count} en attente',
@@ -1047,6 +1055,7 @@ export const dict = {
'sidebarFilesTree.menu.rename': 'Rebaptiser', 'sidebarFilesTree.menu.rename': 'Rebaptiser',
'sidebarFilesTree.menu.copyPath': 'Copier le chemin', 'sidebarFilesTree.menu.copyPath': 'Copier le chemin',
'sidebarFilesTree.menu.save': 'Sauvegarder', 'sidebarFilesTree.menu.save': 'Sauvegarder',
'sidebarFilesTree.menu.download': 'Télécharger',
'sidebarFilesTree.menu.newFile': 'Nouveau fichier', 'sidebarFilesTree.menu.newFile': 'Nouveau fichier',
'sidebarFilesTree.menu.newFolder': 'Nouveau dossier', 'sidebarFilesTree.menu.newFolder': 'Nouveau dossier',
'sidebarFilesTree.menu.delete': 'Supprimer', 'sidebarFilesTree.menu.delete': 'Supprimer',
@@ -1491,6 +1500,7 @@ export const dict = {
'helpDialog.item.openCommandPalette': 'Ouvrir la palette de commandes', 'helpDialog.item.openCommandPalette': 'Ouvrir la palette de commandes',
'helpDialog.item.showKeyboardShortcuts': 'Afficher les raccourcis clavier (cette boîte de dialogue)', '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.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.cycleAgent': 'Agent de cycle (entrée de chat)',
'helpDialog.item.openModelSelector': 'Ouvrir le sélecteur de modèle', 'helpDialog.item.openModelSelector': 'Ouvrir le sélecteur de modèle',
'helpDialog.item.navigateModels': 'Naviguer dans les modèles (dans le sélecteur)', '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.open_settings.label': '設定を開く',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.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_sidebar.label': 'サイドバーの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteSessionAria': '{title} を削除', 'sessions.archivePage.deleteSessionAria': '{title} を削除',
'sessions.archivePage.restoreSessionAria': '{title} を復元',
'sessions.switcher.openAria': 'セッションスイッチャーを開く', 'sessions.switcher.openAria': 'セッションスイッチャーを開く',
'sessions.switcher.empty': '最近のセッションはありません', 'sessions.switcher.empty': '最近のセッションはありません',
'sessions.switcher.draftTitle': '新しいセッション', 'sessions.switcher.draftTitle': '新しいセッション',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました', 'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました',
'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました', 'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました',
'sessions.sidebar.bulkActions.failedArchivePlural': '{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.none': 'まだフォルダがありません',
'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...', 'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...',
'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除', 'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました', 'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました',
'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました', 'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました',
'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました', '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.checksPassed': '{success}/{total}のチェックに合格',
'sessions.sidebar.group.pr.failingCount': '{count}件失敗', 'sessions.sidebar.group.pr.failingCount': '{count}件失敗',
'sessions.sidebar.group.pr.pendingCount': '{count}件保留中', 'sessions.sidebar.group.pr.pendingCount': '{count}件保留中',
@@ -1221,6 +1229,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '名前の変更', 'sidebarFilesTree.menu.rename': '名前の変更',
'sidebarFilesTree.menu.copyPath': 'パスをコピー', 'sidebarFilesTree.menu.copyPath': 'パスをコピー',
'sidebarFilesTree.menu.save': '保存', 'sidebarFilesTree.menu.save': '保存',
'sidebarFilesTree.menu.download': 'ダウンロード',
'sidebarFilesTree.menu.newFile': '新しいファイル', 'sidebarFilesTree.menu.newFile': '新しいファイル',
'sidebarFilesTree.menu.newFolder': '新しいフォルダ', 'sidebarFilesTree.menu.newFolder': '新しいフォルダ',
'sidebarFilesTree.menu.delete': '削除', 'sidebarFilesTree.menu.delete': '削除',
@@ -1674,6 +1683,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': 'コマンドパレットを開く', 'helpDialog.item.openCommandPalette': 'コマンドパレットを開く',
'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)', 'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)',
'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え', 'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え',
'helpDialog.item.addSelectionToChat': '選択範囲をチャットに追加',
'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)', 'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)',
'helpDialog.item.openModelSelector': 'モデルセレクターを開く', 'helpDialog.item.openModelSelector': 'モデルセレクターを開く',
'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)', 'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)',
@@ -1083,6 +1083,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '설정 열기', 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '설정 열기',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.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_sidebar.label': '사이드바 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteSessionAria': '{title} 삭제', 'sessions.archivePage.deleteSessionAria': '{title} 삭제',
'sessions.archivePage.restoreSessionAria': '{title} 복원',
'sessions.switcher.openAria': '세션 전환기 열기', 'sessions.switcher.openAria': '세션 전환기 열기',
'sessions.switcher.empty': '최근 세션 없음', 'sessions.switcher.empty': '최근 세션 없음',
'sessions.switcher.draftTitle': '새 세션', 'sessions.switcher.draftTitle': '새 세션',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨', 'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨',
'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패', 'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패',
'sessions.sidebar.bulkActions.failedArchivePlural': '세션 {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.none': '아직 폴더 없음',
'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…', 'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…',
'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거', 'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '세션 삭제 실패', 'sessions.sidebar.session.delete.error': '세션 삭제 실패',
'sessions.sidebar.session.archive.success': '세션 보관됨', 'sessions.sidebar.session.archive.success': '세션 보관됨',
'sessions.sidebar.session.archive.error': '세션 보관 실패', '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.checksPassed': '검사 통과: {success}/{total}',
'sessions.sidebar.group.pr.failingCount': '실패 {count}개', 'sessions.sidebar.group.pr.failingCount': '실패 {count}개',
'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중', 'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중',
@@ -1228,6 +1236,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '이름 변경', 'sidebarFilesTree.menu.rename': '이름 변경',
'sidebarFilesTree.menu.copyPath': '경로 복사', 'sidebarFilesTree.menu.copyPath': '경로 복사',
'sidebarFilesTree.menu.save': '저장', 'sidebarFilesTree.menu.save': '저장',
'sidebarFilesTree.menu.download': '다운로드',
'sidebarFilesTree.menu.newFile': '새 파일', 'sidebarFilesTree.menu.newFile': '새 파일',
'sidebarFilesTree.menu.newFolder': '새 폴더', 'sidebarFilesTree.menu.newFolder': '새 폴더',
'sidebarFilesTree.menu.delete': '삭제', 'sidebarFilesTree.menu.delete': '삭제',
@@ -1680,6 +1689,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '명령 팔레트 열기', 'helpDialog.item.openCommandPalette': '명령 팔레트 열기',
'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)', 'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)',
'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바', 'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바',
'helpDialog.item.addSelectionToChat': '선택 내용을 채팅에 추가',
'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)', 'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)',
'helpDialog.item.openModelSelector': '모델 선택기 열기', 'helpDialog.item.openModelSelector': '모델 선택기 열기',
'helpDialog.item.navigateModels': '모델 이동(선택기)', '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_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_right_sidebar.label': 'Przełącz panel kontekstu',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług', '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_sidebar.label': 'Przełącz pasek boczny',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Przełącz rozszerzony terminal', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Przełącz rozszerzony terminal',
+10
View File
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu', 'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}', 'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
'sessions.archivePage.deleteSessionAria': 'Usuń {title}', 'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
'sessions.archivePage.restoreSessionAria': 'Przywróć {title}',
'sessions.switcher.openAria': 'Otwórz przełącznik sesji', 'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
'sessions.switcher.empty': 'Brak ostatnich sesji', 'sessions.switcher.empty': 'Brak ostatnich sesji',
'sessions.switcher.draftTitle': 'Nowa sesja', '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.archivedPlural': 'Zarchiwizowano {count} sesji',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Nie udało się zarchiwizować {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.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.title': 'Zaplanowane zadania',
'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.', 'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.',
'sessions.scheduledTasks.dialog.project.label': 'Projekt', '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.delete.error': 'Nie udało się usunąć sesji',
'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana', 'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana',
'sessions.sidebar.session.archive.error': 'Nie udało się zarchiwizować sesji', '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.checksPassed': '{success}/{total} testów przeszło',
'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń', 'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń',
'sessions.sidebar.group.pr.pendingCount': '{count} oczekujących', '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.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług', 'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji', 'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji',
'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu',
'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal', 'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal',
'helpDialog.item.toggleTerminalExpanded': 'Przełącz rozszerzenie terminala', 'helpDialog.item.toggleTerminalExpanded': 'Przełącz rozszerzenie terminala',
'helpDialog.keyCombiner.or': 'lub', 'helpDialog.keyCombiner.or': 'lub',
@@ -2708,6 +2717,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.newFolder': 'Nowy folder', 'sidebarFilesTree.menu.newFolder': 'Nowy folder',
'sidebarFilesTree.menu.rename': 'Zmień nazwę', 'sidebarFilesTree.menu.rename': 'Zmień nazwę',
'sidebarFilesTree.menu.save': 'Zapisz', 'sidebarFilesTree.menu.save': 'Zapisz',
'sidebarFilesTree.menu.download': 'Pobierz',
'sidebarFilesTree.search.clearAria': 'Wyczyść wyszukiwanie', 'sidebarFilesTree.search.clearAria': 'Wyczyść wyszukiwanie',
'sidebarFilesTree.search.placeholder': 'Szukaj plików...', 'sidebarFilesTree.search.placeholder': 'Szukaj plików...',
'sidebarFilesTree.state.loading': 'Ładowanie...', '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.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.label": "Mostrar ou ocultar painel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher 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_sidebar.label": "Mostrar ou ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto', "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', "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.deleteProject": "Excluir todas as sessões arquivadas deste projeto",
"sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}", "sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Excluir {title}", "sessions.archivePage.deleteSessionAria": "Excluir {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir seletor de sessões", "sessions.switcher.openAria": "Abrir seletor de sessões",
"sessions.switcher.empty": "Nenhuma sessão recente", "sessions.switcher.empty": "Nenhuma sessão recente",
"sessions.switcher.draftTitle": "Nova sessão", "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.archivedPlural": "{count} sessões arquivadas",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Não foi possível arquivar {count} sessão", "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.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.none": "Não há pastas ainda",
"sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...", "sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...",
"sessions.sidebar.folders.removeFromFolder": "Remover da 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.delete.error": "Não foi possível excluir a sessão",
"sessions.sidebar.session.archive.success": "Sessão archivada", "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.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.checksPassed": "{success}/{total} checks pasadas",
"sessions.sidebar.group.pr.failingCount": "{count} com fallos", "sessions.sidebar.group.pr.failingCount": "{count} com fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendentes", "sessions.sidebar.group.pr.pendingCount": "{count} pendentes",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Renomear", "sidebarFilesTree.menu.rename": "Renomear",
"sidebarFilesTree.menu.copyPath": "Copiar caminho", "sidebarFilesTree.menu.copyPath": "Copiar caminho",
"sidebarFilesTree.menu.save": "Salvar", "sidebarFilesTree.menu.save": "Salvar",
"sidebarFilesTree.menu.download": "Baixar",
"sidebarFilesTree.menu.newFile": "Novo arquivo", "sidebarFilesTree.menu.newFile": "Novo arquivo",
"sidebarFilesTree.menu.newFolder": "Nova pasta", "sidebarFilesTree.menu.newFolder": "Nova pasta",
"sidebarFilesTree.menu.delete": "Excluir", "sidebarFilesTree.menu.delete": "Excluir",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.openCommandPalette": "Abrir paleta de comandos",
"helpDialog.item.showKeyboardShortcuts": "Mostrar atalhos de teclado (este diálogo)", "helpDialog.item.showKeyboardShortcuts": "Mostrar atalhos de teclado (este diálogo)",
"helpDialog.item.toggleSessionSidebar": "Mostrar ou ocultar barra lateral de sessão", "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.cycleAgent": "Alternar agente (entrada do chat)",
"helpDialog.item.openModelSelector": "Abrir seletor de modelos", "helpDialog.item.openModelSelector": "Abrir seletor de modelos",
"helpDialog.item.navigateModels": "Navegar por modelos (no seletor)", "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.open_settings.label": "Відкрити налаштування",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.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_sidebar.label": "Перемкнути бічну панель",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель', "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
+10
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту", "sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}", "sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
"sessions.archivePage.deleteSessionAria": "Видалити {title}", "sessions.archivePage.deleteSessionAria": "Видалити {title}",
"sessions.archivePage.restoreSessionAria": "Відновити {title}",
"sessions.switcher.openAria": "Відкрити перемикач сесій", "sessions.switcher.openAria": "Відкрити перемикач сесій",
"sessions.switcher.empty": "Немає недавніх сесій", "sessions.switcher.empty": "Немає недавніх сесій",
"sessions.switcher.draftTitle": "Нова сесія", "sessions.switcher.draftTitle": "Нова сесія",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}", "sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}", "sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {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.none": "Папок ще немає",
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...", "sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
"sessions.sidebar.folders.removeFromFolder": "Видалити з папки", "sessions.sidebar.folders.removeFromFolder": "Видалити з папки",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "Не вдалося видалити сесію", "sessions.sidebar.session.delete.error": "Не вдалося видалити сесію",
"sessions.sidebar.session.archive.success": "Сесію заархівовано", "sessions.sidebar.session.archive.success": "Сесію заархівовано",
"sessions.sidebar.session.archive.error": "Не вдалося заархівувати сесію", "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.checksPassed": "Перевірки {success}/{total} пройдено",
"sessions.sidebar.group.pr.failingCount": "{count} з помилкою", "sessions.sidebar.group.pr.failingCount": "{count} з помилкою",
"sessions.sidebar.group.pr.pendingCount": "{count} очікує", "sessions.sidebar.group.pr.pendingCount": "{count} очікує",
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.menu.rename": "Перейменувати", "sidebarFilesTree.menu.rename": "Перейменувати",
"sidebarFilesTree.menu.copyPath": "Копіювати шлях", "sidebarFilesTree.menu.copyPath": "Копіювати шлях",
"sidebarFilesTree.menu.save": "Зберегти", "sidebarFilesTree.menu.save": "Зберегти",
"sidebarFilesTree.menu.download": "Завантажити",
"sidebarFilesTree.menu.newFile": "Новий файл", "sidebarFilesTree.menu.newFile": "Новий файл",
"sidebarFilesTree.menu.newFolder": "Нова папка", "sidebarFilesTree.menu.newFolder": "Нова папка",
"sidebarFilesTree.menu.delete": "Видалити", "sidebarFilesTree.menu.delete": "Видалити",
@@ -1656,6 +1665,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.openCommandPalette": "Відкрити палітру команд", "helpDialog.item.openCommandPalette": "Відкрити палітру команд",
"helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)", "helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)",
"helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій", "helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій",
"helpDialog.item.addSelectionToChat": "Додати виділення в чат",
"helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)", "helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)",
"helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі", "helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі",
"helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)", "helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)",
@@ -1083,6 +1083,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '打开设置', 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '打开设置',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.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_sidebar.label': '切换侧边栏',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面', '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.deleteProject': '删除此项目的所有已归档会话',
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话', 'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
'sessions.archivePage.deleteSessionAria': '删除 {title}', 'sessions.archivePage.deleteSessionAria': '删除 {title}',
'sessions.archivePage.restoreSessionAria': '还原 {title}',
'sessions.switcher.openAria': '打开会话切换器', 'sessions.switcher.openAria': '打开会话切换器',
'sessions.switcher.empty': '没有最近会话', 'sessions.switcher.empty': '没有最近会话',
'sessions.switcher.draftTitle': '新会话', 'sessions.switcher.draftTitle': '新会话',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话', 'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话',
'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败', 'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败',
'sessions.sidebar.bulkActions.failedArchivePlural': '归档 {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.none': '暂无文件夹',
'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...', 'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...',
'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除', 'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '删除会话失败', 'sessions.sidebar.session.delete.error': '删除会话失败',
'sessions.sidebar.session.archive.success': '会话已归档', 'sessions.sidebar.session.archive.success': '会话已归档',
'sessions.sidebar.session.archive.error': '归档会话失败', '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.checksPassed': '{success}/{total} 项检查已通过',
'sessions.sidebar.group.pr.failingCount': '{count} 项失败', 'sessions.sidebar.group.pr.failingCount': '{count} 项失败',
'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中', 'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中',
@@ -1191,6 +1199,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '重命名', 'sidebarFilesTree.menu.rename': '重命名',
'sidebarFilesTree.menu.copyPath': '复制路径', 'sidebarFilesTree.menu.copyPath': '复制路径',
'sidebarFilesTree.menu.save': '保存', 'sidebarFilesTree.menu.save': '保存',
'sidebarFilesTree.menu.download': '下载',
'sidebarFilesTree.menu.newFile': '新建文件', 'sidebarFilesTree.menu.newFile': '新建文件',
'sidebarFilesTree.menu.newFolder': '新建文件夹', 'sidebarFilesTree.menu.newFolder': '新建文件夹',
'sidebarFilesTree.menu.delete': '删除', 'sidebarFilesTree.menu.delete': '删除',
@@ -1644,6 +1653,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '打开命令面板', 'helpDialog.item.openCommandPalette': '打开命令面板',
'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)', 'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)',
'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏', 'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏',
'helpDialog.item.addSelectionToChat': '将选中内容添加到聊天',
'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)', 'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)',
'helpDialog.item.openModelSelector': '打开模型选择器', 'helpDialog.item.openModelSelector': '打开模型选择器',
'helpDialog.item.navigateModels': '导航模型(选择器中)', 'helpDialog.item.navigateModels': '导航模型(选择器中)',
@@ -990,6 +990,7 @@
'settings.openchamber.keyboardShortcuts.action.open_settings.label': '開啟設定', 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '開啟設定',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.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_sidebar.label': '切換側邊欄',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面', '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.deleteProject': '刪除此專案的所有已封存工作階段',
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段', 'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
'sessions.archivePage.deleteSessionAria': '刪除 {title}', 'sessions.archivePage.deleteSessionAria': '刪除 {title}',
'sessions.archivePage.restoreSessionAria': '還原 {title}',
'sessions.switcher.openAria': '開啟會話切換器', 'sessions.switcher.openAria': '開啟會話切換器',
'sessions.switcher.empty': '沒有最近會話', 'sessions.switcher.empty': '沒有最近會話',
'sessions.switcher.draftTitle': '新會話', 'sessions.switcher.draftTitle': '新會話',
@@ -484,6 +485,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話', 'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話',
'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗', 'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗',
'sessions.sidebar.bulkActions.failedArchivePlural': '封存 {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.none': '暫無資料夾',
'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...', 'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...',
'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除', 'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除',
@@ -587,6 +593,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '刪除會話失敗', 'sessions.sidebar.session.delete.error': '刪除會話失敗',
'sessions.sidebar.session.archive.success': '會話已封存', 'sessions.sidebar.session.archive.success': '會話已封存',
'sessions.sidebar.session.archive.error': '封存會話失敗', '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.checksPassed': '{success}/{total} 項檢查已通過',
'sessions.sidebar.group.pr.failingCount': '{count} 項失敗', 'sessions.sidebar.group.pr.failingCount': '{count} 項失敗',
'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中', 'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中',
@@ -1203,6 +1211,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.menu.rename': '重新命名', 'sidebarFilesTree.menu.rename': '重新命名',
'sidebarFilesTree.menu.copyPath': '複製路徑', 'sidebarFilesTree.menu.copyPath': '複製路徑',
'sidebarFilesTree.menu.save': '儲存', 'sidebarFilesTree.menu.save': '儲存',
'sidebarFilesTree.menu.download': '下載',
'sidebarFilesTree.menu.newFile': '新增檔案', 'sidebarFilesTree.menu.newFile': '新增檔案',
'sidebarFilesTree.menu.newFolder': '新增資料夾', 'sidebarFilesTree.menu.newFolder': '新增資料夾',
'sidebarFilesTree.menu.delete': '刪除', 'sidebarFilesTree.menu.delete': '刪除',
@@ -1648,6 +1657,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openCommandPalette': '開啟命令面板', 'helpDialog.item.openCommandPalette': '開啟命令面板',
'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)', 'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)',
'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄', 'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄',
'helpDialog.item.addSelectionToChat': '將選取內容加入聊天',
'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)', 'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)',
'helpDialog.item.openModelSelector': '開啟模型選擇器', 'helpDialog.item.openModelSelector': '開啟模型選擇器',
'helpDialog.item.navigateModels': '導覽模型(選擇器中)', 'helpDialog.item.navigateModels': '導覽模型(選擇器中)',
+8 -1
View File
@@ -12,6 +12,7 @@ import type {
TextPartInput, TextPartInput,
FilePartInput, FilePartInput,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
import type { PermissionRequest } from "@/types/permission"; import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question"; import type { QuestionRequest } from "@/types/question";
@@ -878,7 +879,13 @@ class OpencodeService {
// failure) — there is no HTTP response to report. Never fabricate a // failure) — there is no HTTP response to report. Never fabricate a
// status: surface it as a transport error so callers treat it like // status: surface it as a transport error so callers treat it like
// any other network failure instead of a server 500. // 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 }); response = new Response(JSON.stringify(result.error), { status });
} else { } else {
@@ -22,5 +22,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'wafer', name: 'Wafer.ai' }, { id: 'wafer', name: 'Wafer.ai' },
{ id: 'opencode-go', name: 'OpenCode Go' }, { id: 'opencode-go', name: 'OpenCode Go' },
{ id: 'crof', name: 'CrofAI' }, { id: 'crof', name: 'CrofAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'neuralwatt', name: 'NeuralWatt' }, { 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'; } from './crypto';
import { createHostHandshake } from './handshake'; import { createHostHandshake } from './handshake';
import { TunnelFrameType } from './protocol'; import { TunnelFrameType } from './protocol';
import { isAmbiguousTransportFailure } from './transport-error';
import { import {
createFragmentAssembler, createFragmentAssembler,
decodeFrameBatch, decodeFrameBatch,
@@ -339,6 +340,24 @@ describe('createRelayTunnelClient', () => {
await expect(reader.read()).rejects.toThrow(); 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 () => { test('opens, echoes, and closes a tunneled WebSocket', async () => {
const { client } = await setupClient(); const { client } = await setupClient();
track(client); track(client);
+17 -5
View File
@@ -35,6 +35,7 @@ import {
isWsClosePayload, isWsClosePayload,
normalizeTunnelRequest, normalizeTunnelRequest,
} from './tunnel-payloads'; } from './tunnel-payloads';
import { markAmbiguousTransportFailure } from './transport-error';
const EMPTY_PAYLOAD = new Uint8Array(0); const EMPTY_PAYLOAD = new Uint8Array(0);
const textEncoder = new TextEncoder(); 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 = () => { onAbort = () => {
sendAbort('aborted'); sendAbort('aborted');
finishError(abortError()); finishError(abortError());
@@ -735,7 +743,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
head = decodeJsonPayload(payload, isHttpResponsePayload); head = decodeJsonPayload(payload, isHttpResponsePayload);
} catch (error) { } catch (error) {
sendAbort('malformed response head'); sendAbort('malformed response head');
finishError(toError(error)); finishError(dispatchedFailure(toError(error).message));
return; return;
} }
const nullBody = head.status === 204 || head.status === 205 || head.status === 304; 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 (frameType === TunnelFrameType.StreamEnd) {
if (finished) return; if (finished) return;
if (!responseDelivered) { if (!responseDelivered) {
finishError(new Error('tunnel stream ended before response head')); finishError(dispatchedFailure('tunnel stream ended before response head'));
return; return;
} }
finished = true; finished = true;
@@ -792,11 +800,15 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
} catch { } catch {
// Keep the generic reason. // Keep the generic reason.
} }
finishError(new Error(reason)); finishError(dispatchedFailure(reason));
} }
}, },
fail(error) { 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) { } catch (error) {
sendAbort('request body failed'); 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');
});
});
+43 -2
View File
@@ -76,11 +76,52 @@ const sameOrigin = (left: string, right: string): boolean => {
}; };
export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl(); 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 => { export const getRuntimeKey = (): string => {
if (activeRuntimeKey) return activeRuntimeKey; 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(); const apiBaseUrl = getRuntimeApiBaseUrl();
if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local'; cachedRuntimeKey = sameOrigin(apiBaseUrl, readInjectedLocalOrigin())
return normalizeRuntimeUrlKey(apiBaseUrl); ? 'local'
: normalizeRuntimeUrlKey(apiBaseUrl);
cachedActiveApiBaseUrl = activeApiBaseUrl;
cachedRawApiBaseUrl = rawApiBaseUrl;
cachedRawLocalOrigin = rawLocalOrigin;
return cachedRuntimeKey;
}; };
export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => { export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => {
+8 -1
View File
@@ -159,8 +159,15 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Toggle the files panel', description: 'Toggle the files panel',
}, },
{ {
id: 'toggle_sidebar', id: 'add_selection_to_chat',
defaultCombo: 'mod+l', 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', label: 'Toggle sidebar',
description: 'Toggle the session sidebar', description: 'Toggle the session sidebar',
customizable: true, customizable: true,
@@ -24,12 +24,12 @@
"emphasis": "#fd9b66" "emphasis": "#fd9b66"
}, },
"surface": { "surface": {
"background": "#0c0b0a", "background": "#120f0e",
"foreground": "#dbd7ca", "foreground": "#c9c5ba",
"muted": "#131211", "muted": "#171615",
"mutedForeground": "#8f8b81", "mutedForeground": "#8f8b81",
"elevated": "#181715", "elevated": "#181715",
"elevatedForeground": "#dbd7ca", "elevatedForeground": "#c9c5ba",
"overlay": "#00000099", "overlay": "#00000099",
"subtle": "#171616" "subtle": "#171616"
}, },
@@ -37,11 +37,11 @@
"border": "#242323", "border": "#242323",
"borderHover": "#504e4c", "borderHover": "#504e4c",
"borderFocus": "#da7c47", "borderFocus": "#da7c47",
"selection": "#da7c472b", "selection": "#b9a5992b",
"selectionForeground": "#dbd7ca", "selectionForeground": "#c9c5ba",
"focus": "#da7c47", "focus": "#da7c47",
"focusRing": "#da7c4755", "focusRing": "#da7c4755",
"cursor": "#dbd7ca", "cursor": "#c9c5ba",
"hover": "#ffffff12", "hover": "#ffffff12",
"active": "#ffffff1f" "active": "#ffffff1f"
}, },
@@ -72,8 +72,8 @@
}, },
"syntax": { "syntax": {
"base": { "base": {
"background": "#131211", "background": "#120f0e",
"foreground": "#dbd7ca", "foreground": "#c9c5ba",
"comment": "#728772", "comment": "#728772",
"keyword": "#34983a", "keyword": "#34983a",
"string": "#d58373", "string": "#d58373",
@@ -127,14 +127,14 @@
"diffModified": "#5d99a9", "diffModified": "#5d99a9",
"diffModifiedBackground": "#5d99a920", "diffModifiedBackground": "#5d99a920",
"lineNumber": "#3c3a37", "lineNumber": "#3c3a37",
"lineNumberActive": "#dbd7ca" "lineNumberActive": "#c9c5ba"
} }
}, },
"markdown": { "markdown": {
"heading1": "#dbd7ca", "heading1": "#c9c5ba",
"heading2": "#dbd7ca", "heading2": "#c9c5ba",
"heading3": "#dbd7ca", "heading3": "#c9c5ba",
"heading4": "#dbd7ca", "heading4": "#c9c5ba",
"link": "#5d99a9", "link": "#5d99a9",
"linkHover": "#6ba7b8", "linkHover": "#6ba7b8",
"inlineCode": "#76ad4f", "inlineCode": "#76ad4f",
@@ -144,19 +144,19 @@
"listMarker": "#4d934e99" "listMarker": "#4d934e99"
}, },
"chat": { "chat": {
"userMessage": "#dbd7ca", "userMessage": "#c9c5ba",
"userMessageBackground": "#25170e", "userMessageBackground": "#25170e",
"assistantMessage": "#dbd7ca", "assistantMessage": "#c9c5ba",
"assistantMessageBackground": "#0c0b0a", "assistantMessageBackground": "#120f0e",
"timestamp": "#8f8b81", "timestamp": "#8f8b81",
"divider": "#302e2b" "divider": "#302e2b"
}, },
"tools": { "tools": {
"background": "#13121150", "background": "#120f0e50",
"border": "#302e2b99", "border": "#302e2b99",
"headerHover": "#ffffff0d", "headerHover": "#ffffff0d",
"icon": "#ada9a0", "icon": "#ada9a0",
"title": "#dbd7ca", "title": "#c9c5ba",
"description": "#aba9a3", "description": "#aba9a3",
"edit": { "edit": {
"added": "#4d934e", "added": "#4d934e",
+5 -2
View File
@@ -47,17 +47,20 @@ Examples:
- `useProjectsStore.ts` - `useProjectsStore.ts`
- `useGlobalSessionsStore.ts` - `useGlobalSessionsStore.ts`
- `useSessionFoldersStore.ts` - `useSessionFoldersStore.ts`
- `messageQueueStore.ts`
These stores coordinate persistent project/session metadata across multiple views. 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. `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`. 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: 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. - 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 is bounded to two requests across callers and prioritizes the current directory. - 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. - 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. - 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. - Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
+35 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages } from './globalSessions' import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('listGlobalSessionPages', () => { describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => { 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']) 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 () => { test('keeps paginating archived pages that are full of non-archived records', async () => {
const calls: Array<Record<string, unknown>> = [] const calls: Array<Record<string, unknown>> = []
const apiClient = { const apiClient = {
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_1']) 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'])
})
})
+31 -4
View File
@@ -84,11 +84,38 @@ const unwrapSessionList = (
*/ */
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived); 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( export async function listGlobalSessionPages(
apiClient: OpencodeClient, apiClient: OpencodeClient,
options: { options: {
directory?: string; directory?: string;
archived: boolean; 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; roots?: boolean;
pageSize: number; pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void; onPage?: (sessions: GlobalSessionRecord[]) => void;
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
const all: GlobalSessionRecord[] = []; const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>(); const seenIds = new Set<string>();
let cursor: number | undefined; let cursor: number | undefined;
const narrowToArchived = options.narrowToArchived !== false;
let operation: string; let operation: string;
if (!options.directory) { if (!options.directory) {
operation = `global-sessions.${options.archived ? "archived" : "active"}`; operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
} else if (options.roots === true) { } else if (options.roots === true) {
operation = "bootstrap.sessions.roots"; operation = "bootstrap.sessions.roots";
} else if (options.archived) { } else if (options.archived) {
operation = "bootstrap.sessions.archived"; operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
} else { } else {
operation = "bootstrap.sessions.all"; operation = "bootstrap.sessions.all";
} }
while (true) { while (true) {
let attempts = 0; let attempts = 0;
const finishPerformanceEvent = startSessionLoadPerformanceEvent({ const finishPerformanceEvent = startSessionLoadPerformanceEvent({
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
if (!session?.id || seenIds.has(session.id)) continue; if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id); seenIds.add(session.id);
appended += 1; appended += 1;
if (options.archived && !isArchivedSession(session)) continue; if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
all.push(session); all.push(session);
accepted.push(session); accepted.push(session);
} }
@@ -8,7 +8,7 @@ import {
} from "./messageQueueStore" } from "./messageQueueStore"
beforeEach(() => { beforeEach(() => {
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} }) useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
}) })
describe("message queue runtime ownership", () => { describe("message queue runtime ownership", () => {
@@ -49,3 +49,48 @@ describe("message queue runtime ownership", () => {
expect(queue[0]?.content).toBe("message-5") 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)
})
})
+59 -1
View File
@@ -85,6 +85,19 @@ interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
quarantinedLegacyMessages: Record<string, QueuedMessage[]>; quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
followUpBehavior: FollowUpBehavior; 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 { interface MessageQueueActions {
@@ -94,6 +107,9 @@ interface MessageQueueActions {
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null; popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
clearQueue: (target: MessageQueueTarget) => void; clearQueue: (target: MessageQueueTarget) => void;
clearAllQueues: () => void; clearAllQueues: () => void;
markSending: (target: MessageQueueTarget, messageId: string) => void;
clearSending: (target: MessageQueueTarget, messageId: string) => void;
getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[];
setFollowUpBehavior: (behavior: FollowUpBehavior) => void; setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
} }
@@ -127,6 +143,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
queuedMessages: {}, queuedMessages: {},
quarantinedLegacyMessages: {}, quarantinedLegacyMessages: {},
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
sendingIds: {},
addToQueue: (target, message) => { addToQueue: (target, message) => {
const key = getMessageQueueKey(target); const key = getMessageQueueKey(target);
@@ -237,6 +254,14 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
clearQueue: (target) => { clearQueue: (target) => {
const key = getMessageQueueKey(target); const key = getMessageQueueKey(target);
set((state) => { 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; const { [key]: _removed, ...rest } = state.queuedMessages;
void _removed; void _removed;
return { queuedMessages: rest }; return { queuedMessages: rest };
@@ -244,7 +269,40 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
}, },
clearAllQueues: () => { 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) => { setFollowUpBehavior: (behavior) => {
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
return { promise, resolve, reject } return { promise, resolve, reject }
} }
let activeRequest: Deferred<Session[]> let listRequest: Deferred<Session[]>
let archivedRequest: 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 = { const sdk = {
experimental: { experimental: {
session: { session: {
list: async (options: { archived?: boolean }) => ({ list: async () => ({
data: await (options.archived ? archivedRequest.promise : activeRequest.promise), data: await listRequest.promise,
response: { headers: new Headers() }, response: { headers: new Headers() },
}), }),
}, },
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
const session = (id: string, title = id, archived?: number): Session => ({ const session = (id: string, title = id, archived?: number): Session => ({
id, id,
title, title,
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) }, time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
} as Session) } as Session)
describe("global session mutation reconciliation", () => { describe("global session mutation reconciliation", () => {
beforeEach(() => { beforeEach(() => {
activeRequest = deferred<Session[]>() listRequest = deferred<Session[]>()
archivedRequest = deferred<Session[]>()
opencodeClient.getSdkClient = () => sdk opencodeClient.getSdkClient = () => sdk
useGlobalSessionsStore.getState().resetForRuntimeSwitch() useGlobalSessionsStore.getState().resetForRuntimeSwitch()
}) })
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("created")) useGlobalSessionsStore.getState().upsertSession(session("created"))
activeRequest.resolve([]) listRequest.resolve([])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"]) expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().removeSessions([stale.id]) useGlobalSessionsStore.getState().removeSessions([stale.id])
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10) useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("updated", "New")) useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New") 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 created = session("created")
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(created) useGlobalSessionsStore.getState().upsertSession(created)
activeRequest.reject(new Error("unavailable")) listRequest.reject(new Error("unavailable"))
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
expect(useGlobalSessionsStore.getState().status).toBe("error") 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 () => { test("does not undo a move while refreshing the source directory", async () => {
const source = { ...session("moved"), directory: "/source" } as Session const source = { ...session("moved"), directory: "/source" } as Session
const destination = { ...source, directory: "/destination" } as Session const destination = { ...source, directory: "/destination" } as Session
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"]) const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession(destination) useGlobalSessionsStore.getState().upsertSession(destination)
activeRequest.resolve([source]) listRequest.resolve([source])
archivedRequest.resolve([])
await refreshing await refreshing
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined) expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved") 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 { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client'; 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 { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization'; import { normalizePath } from '@/lib/pathNormalization';
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
const fetchDirectoryPages = async ( const fetchDirectoryPages = async (
sdk: OpencodeClient, sdk: OpencodeClient,
directories: Set<string>, directories: Set<string>,
archived: boolean,
): Promise<DirectoryPageResult> => { ): Promise<DirectoryPageResult> => {
const currentDirectory = normalizePath(opencodeClient.getDirectory()); const currentDirectory = normalizePath(opencodeClient.getDirectory());
const orderedDirectories = [...directories].sort((left, right) => { const orderedDirectories = [...directories].sort((left, right) => {
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
status: 'fulfilled' as const, status: 'fulfilled' as const,
value: { value: {
directory, 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(() => ( 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 () => { const loadPromise = (async () => {
try { try {
const sdk = opencodeClient.getSdkClient(); const sdk = opencodeClient.getSdkClient();
const [activeResult, archivedResult] = await Promise.allSettled([ // One inclusive fetch, split client-side. The server's
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), // `time_archived IS NULL` active filter would exclude restored
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), // sessions (`time.archived` falsy-but-present), so an
]); // `archived: false` request cannot produce a truthful active list.
const allSessions = await listGlobalSessionPages(sdk, {
if (activeResult.status === 'rejected') { archived: true,
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason); narrowToArchived: false,
} pageSize: PAGE_SIZE,
if (archivedResult.status === 'rejected') { });
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
if (generation !== loadGeneration) { if (generation !== loadGeneration) {
// Runtime switched mid-load: this snapshot belongs to the previous // Runtime switched mid-load: this snapshot belongs to the previous
// instance — drop it. // instance — drop it.
return { activeSessions: [], archivedSessions: [] }; return { activeSessions: [], archivedSessions: [] };
} }
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled' const { active, archived } = splitGlobalSessionsByArchived(allSessions);
? 'ready'
: 'error';
set((state) => { set((state) => {
const fetchedActive = activeResult.status === 'fulfilled' const reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
? activeResult.value return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
: 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 committed = get(); const committed = get();
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions }; return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const generation = loadGeneration; const generation = loadGeneration;
const baselineRevision = get().mutationRevision; const baselineRevision = get().mutationRevision;
const sdk = opencodeClient.getSdkClient(); const sdk = opencodeClient.getSdkClient();
const [active, archived] = await Promise.all([ const fetched = await fetchDirectoryPages(sdk, directorySet);
fetchDirectoryPages(sdk, directorySet, false),
fetchDirectoryPages(sdk, directorySet, true),
]);
if (generation !== loadGeneration) { if (generation !== loadGeneration) {
const state = get(); const state = get();
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
} }
if (active.errors.length > 0) { if (fetched.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]); console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
}
if (archived.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
} }
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
set((state) => { set((state) => {
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories); let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive); nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
if (sameSessionList(state.activeSessions, nextActiveSessions)) { if (sameSessionList(state.activeSessions, nextActiveSessions)) {
nextActiveSessions = state.activeSessions; 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)) { if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
nextArchivedSessions = state.archivedSessions; nextArchivedSessions = state.archivedSessions;
} }
+29 -6
View File
@@ -207,7 +207,7 @@ The discriminator is whether the server confirmed the path, not whether the valu
| Source | Meaning | | 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 | | `selected` | Server-confirmed directory captured at selection; a guessed one is never passed |
| `attachment` | Worktree attachment recorded by this client; the *requested* path | | `attachment` | Worktree attachment recorded by this client; the *requested* path |
| `worktree-metadata` | Worktree captured when the session was created in one; 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: 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. 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. 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. 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. 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. 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. 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`: 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)` - `updateSessionTitle()` -> `upsertSession(result.data)`
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session - `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 - `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 - `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 Archive and delete actions capture the active runtime key when they start and
recheck it before every store reconciliation, so a response recheck it before every store reconciliation, so a response
produced by the previous runtime is rejected instead of mutating the current 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 runtime's live or global session state. Restore follows the same guard: a
observed runtime change: sessions the server already confirmed remain archived stale completion returns `false` without touching any store. A guarded batch
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed stops at the first observed runtime change: sessions the server already
on the captured runtime is returned in `failedIds` so existing partial-failure 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. feedback stays truthful.
Callers whose confirmation can span a runtime switch may pass an Callers whose confirmation can span a runtime switch may pass an
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default.
+24 -2
View File
@@ -1,6 +1,6 @@
import { create, type StoreApi } from "zustand" import { create, type StoreApi } from "zustand"
import type { DirState, State } from "./types" 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 { pickDirectoriesToEvict, canDisposeDirectory, hasPendingBlockingRequests } from "./eviction"
import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessions } from "./persist-cache" import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessions } from "./persist-cache"
import { normalizePath } from "@/lib/pathNormalization" import { normalizePath } from "@/lib/pathNormalization"
@@ -250,6 +250,7 @@ export class ChildStoreManager {
readonly children = new Map<string, StoreApi<DirectoryStore>>() readonly children = new Map<string, StoreApi<DirectoryStore>>()
private readonly lifecycle = new Map<string, DirState>() private readonly lifecycle = new Map<string, DirState>()
private readonly pins = new Map<string, number>() private readonly pins = new Map<string, number>()
private evictionScheduled = false
private readonly disposers = new Map<string, () => void>() private readonly disposers = new Map<string, () => void>()
private readonly registrySubscribers = new Set<() => void>() private readonly registrySubscribers = new Set<() => void>()
private readonly bootstrapSubscribers = new Set<() => void>() private readonly bootstrapSubscribers = new Set<() => void>()
@@ -308,7 +309,25 @@ export class ChildStoreManager {
mark(directory: string) { mark(directory: string) {
if (!directory) return if (!directory) return
this.lifecycle.set(directory, { lastAccessAt: Date.now() }) 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) { pin(directory: string) {
@@ -327,6 +346,8 @@ export class ChildStoreManager {
return return
} }
this.pins.delete(normalizedDirectory) 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() this.runEviction()
} }
@@ -621,6 +642,7 @@ export class ChildStoreManager {
pins: new Set(stores.filter((d) => this.pinned(d))), pins: new Set(stores.filter((d) => this.pinned(d))),
max: MAX_DIR_STORES, max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS, ttl: DIR_IDLE_TTL_MS,
graceMs: EVICTION_GRACE_MS,
now: Date.now(), now: Date.now(),
hasPendingBlockingRequests: (dir) => this.hasPendingBlockingRequestsForDirectory(dir), hasPendingBlockingRequests: (dir) => this.hasPendingBlockingRequestsForDirectory(dir),
}).filter((d) => d !== skip) }).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])
})
})
+8 -1
View File
@@ -26,6 +26,7 @@ export function hasPendingBlockingRequests(state: State | undefined): boolean {
export function pickDirectoriesToEvict(input: EvictPlan) { export function pickDirectoriesToEvict(input: EvictPlan) {
const overflow = Math.max(0, input.stores.length - input.max) const overflow = Math.max(0, input.stores.length - input.max)
let pendingOverflow = overflow let pendingOverflow = overflow
const graceMs = input.graceMs ?? 0
const sorted = input.stores const sorted = input.stores
.filter((dir) => !input.pins.has(dir)) .filter((dir) => !input.pins.has(dir))
.filter((dir) => !input.hasPendingBlockingRequests?.(dir)) .filter((dir) => !input.hasPendingBlockingRequests?.(dir))
@@ -34,8 +35,14 @@ export function pickDirectoriesToEvict(input: EvictPlan) {
const output: string[] = [] const output: string[] = []
for (const dir of sorted) { for (const dir of sorted) {
const last = input.state.get(dir)?.lastAccessAt ?? 0 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 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) output.push(dir)
if (pendingOverflow > 0) pendingOverflow -= 1 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", () => { describe("fetchMessagesForSession startup race", () => {
test("does not reject before sync action refs are initialized", async () => { test("does not reject before sync action refs are initialized", async () => {
const { fetchMessagesForSession } = await import("./session-actions") const { fetchMessagesForSession } = await import("./session-actions")
@@ -1014,6 +1124,53 @@ describe("optimisticSend target directory", () => {
expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part") 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 () => { test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => {
const targetStore = createStore({}) const targetStore = createStore({})
const childStores = createChildStores([["/target/project", targetStore]]) const childStores = createChildStores([["/target/project", targetStore]])
+113 -3
View File
@@ -29,11 +29,21 @@ import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/l
import { getImperativeSessionMessageLoader } from "./session-message-loader" import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup" import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { getRuntimeKey } from "@/lib/runtime-switch" import { getRuntimeKey } from "@/lib/runtime-switch"
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
const MESSAGE_REFETCH_LIMIT = 100 const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30 const SEND_CONFIRMATION_REFETCH_LIMIT = 30
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2 // A relay-tunnel send fails when the tunnel drops, and the confirming refetch
const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150 // 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 MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3 const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150 const UNREVERT_REFETCH_RETRY_MS = 150
@@ -360,6 +370,13 @@ function getErrorStatus(error: unknown): number | null {
} }
function isAmbiguousSendFailure(error: unknown): boolean { 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) const status = getErrorStatus(error)
if (status === 503 || status === 504 || status === 408) return true if (status === 503 || status === 504 || status === 408) return true
if (error instanceof TypeError) return true if (error instanceof TypeError) return true
@@ -997,6 +1014,92 @@ export async function archiveSessions(
return { archivedIds, failedIds } 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> { export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId) const sessionDirectory = getSessionDirectory(sessionId)
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
@@ -1255,8 +1358,15 @@ async function fetchRecentSendConfirmationRecords(
messageID: string, messageID: string,
directory?: string | null, directory?: string | null,
): Promise<Array<{ info: Message; parts?: Part[] }> | 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) { 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 { try {
const result = await sdk().session.messages({ const result = await sdk().session.messages({
sessionID: sessionId, 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 * The ordering discriminator is **whether the server confirmed the path**, not
* whether the value is local or synced: * whether the value is local or synced:
* *
* 1. `authoritative` the child store that actually holds the session, then * 1. `authoritative` the session's own record, then a child store that holds
* the session's own record. Server-backed truth for an indexed session. * 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 * 2. `selected` the directory captured when the session was selected, but
* only when it came from a server response (the directory `createSession` * only when it came from a server response (the directory `createSession`
* returned, which may be a canonicalized form of what was requested). A * returned, which may be a canonicalized form of what was requested). A
@@ -42,7 +44,7 @@ export type SessionDirectorySource =
| 'none' | 'none'
export type SessionDirectorySources = { 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 authoritative?: string | null
/** Server-confirmed directory captured at selection. Never a guessed one. */ /** Server-confirmed directory captured at selection. Never a guessed one. */
selected?: string | null selected?: string | null
@@ -483,6 +483,15 @@ describe('archiveSessions option forwarding', () => {
expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] }); expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] });
expect(updateSessionCalls).toEqual([]); 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', () => { describe('deleteSessions option forwarding', () => {
+52 -6
View File
@@ -55,6 +55,8 @@ import {
deleteSessions as deleteSessionsAction, deleteSessions as deleteSessionsAction,
archiveSession as archiveSessionAction, archiveSession as archiveSessionAction,
archiveSessions as archiveSessionsAction, archiveSessions as archiveSessionsAction,
unarchiveSession as unarchiveSessionAction,
unarchiveSessions as unarchiveSessionsAction,
updateSessionTitle as updateSessionTitleAction, updateSessionTitle as updateSessionTitleAction,
shareSession as shareSessionAction, shareSession as shareSessionAction,
unshareSession as unshareSessionAction, unshareSession as unshareSessionAction,
@@ -67,6 +69,7 @@ import {
type ArchiveSessionsOptions, type ArchiveSessionsOptions,
type DeleteSessionOptions, type DeleteSessionOptions,
type DeleteSessionsOptions, type DeleteSessionsOptions,
type UnarchiveSessionsOptions,
} from "./session-actions" } from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store" import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore" import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
@@ -335,6 +338,8 @@ export type SessionUIState = {
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean> archiveSession: (id: string) => Promise<boolean>
archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }> 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> updateSessionTitle: (sessionId: string, title: string) => Promise<void>
shareSession: (sessionId: string) => Promise<Session | null> shareSession: (sessionId: string) => Promise<Session | null>
unshareSession: (sessionId: string) => Promise<Session | null> unshareSession: (sessionId: string) => Promise<Session | null>
@@ -352,6 +357,12 @@ export type SessionUIState = {
debugSessionMessages: (sessionId: string) => Promise<void> debugSessionMessages: (sessionId: string) => Promise<void>
pollForTokenUpdates: () => void pollForTokenUpdates: () => void
setSessionDirectory: (sessionId: string, directory: string | null) => 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 * The directory that owns a session, from the two server-backed signals.
* 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. * `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 getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
const owningDirectory = getSyncSessionDirectory(sessionId)
if (owningDirectory) return normalizePath(owningDirectory)
const target = getAllSyncSessions().find((s) => s.id === sessionId) 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), archiveSessions: (ids, options) => archiveSessionsAction(ids, options),
unarchiveSession: (id) => unarchiveSessionAction(id),
unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options),
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// updateSessionTitle — calls SDK, SSE event updates child store // 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 // 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) => { setSessionDirectory: (sessionId, directory) => {
const normalized = normalizePath(directory) const normalized = normalizePath(directory)
// Callers set this from a confirmed destination (a completed move, a // 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