Merge origin/main into deferred OpenCode restart branch.
Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API key hiding, always-load auth methods) while keeping deferred Apply & Restart for provider mutations. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,34 @@ Do not optimize against a toy fixture when the report provides production scale.
|
|||||||
|
|
||||||
## Workflow
|
## 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -68,4 +68,5 @@ workspaces/
|
|||||||
*.pid
|
*.pid
|
||||||
.worktrees/
|
.worktrees/
|
||||||
test-results/
|
test-results/
|
||||||
artifacts/browser-profile-*/
|
artifacts/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
mode: subagent
|
mode: all
|
||||||
description: Simplifies recently modified OpenChamber code for clarity and maintainability while preserving exact behavior. Use after implementation with a concrete scope or a request to simplify current worktree changes.
|
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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||||
|
import {
|
||||||
|
formatCodeSelectionMarkdown,
|
||||||
|
rangeToMarkdown,
|
||||||
|
trimSelectionValue,
|
||||||
|
wrapMarkdownSelectionForChat,
|
||||||
|
} from '@/components/chat/message/selectionMarkdown';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
|
||||||
|
|
||||||
|
const isInsideChatComposer = (node: Node | null): boolean => {
|
||||||
|
if (!node) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const asElement = node as Element;
|
||||||
|
const element = typeof asElement.closest === 'function'
|
||||||
|
? asElement
|
||||||
|
: (node as Node).parentElement;
|
||||||
|
return Boolean(element?.closest(CHAT_INPUT_HOST_SELECTOR));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTextControlSelection = (element: Element): string | null => {
|
||||||
|
if (isInsideChatComposer(element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = element.tagName?.toLowerCase();
|
||||||
|
if (tag === 'textarea') {
|
||||||
|
const control = element as HTMLTextAreaElement;
|
||||||
|
const start = control.selectionStart ?? 0;
|
||||||
|
const end = control.selectionEnd ?? 0;
|
||||||
|
const text = trimSelectionValue(control.value.slice(start, end));
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Collapse so a duplicate menu delivery cannot append the same range twice.
|
||||||
|
control.selectionStart = end;
|
||||||
|
control.selectionEnd = end;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'input') {
|
||||||
|
const control = element as HTMLInputElement;
|
||||||
|
const type = control.type?.toLowerCase() ?? 'text';
|
||||||
|
if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const start = control.selectionStart ?? 0;
|
||||||
|
const end = control.selectionEnd ?? 0;
|
||||||
|
const text = trimSelectionValue(control.value.slice(start, end));
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
control.selectionStart = end;
|
||||||
|
control.selectionEnd = end;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureActiveElementSelection = (): string | null => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement || typeof (activeElement as Element).tagName !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = readTextControlSelection(activeElement as Element);
|
||||||
|
return text ? wrapMarkdownSelectionForChat(text) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureCodeMirrorSelection = (): string | null => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusedEditor = document.querySelector<HTMLElement>('.cm-editor.cm-focused');
|
||||||
|
if (!focusedEditor || isInsideChatComposer(focusedEditor)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = EditorView.findFromDOM(focusedEditor);
|
||||||
|
if (!view) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
if (from === to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = trimSelectionValue(view.state.sliceDoc(from, to));
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: to },
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatCodeSelectionMarkdown(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureDomSelection = (): string | null => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (isInsideChatComposer(range.commonAncestorContainer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainText = trimSelectionValue(selection.toString());
|
||||||
|
if (!plainText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = rangeToMarkdown(range, plainText);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
return wrapMarkdownSelectionForChat(markdown);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the current non-composer selection as chat-ready markdown.
|
||||||
|
* Returns null when nothing usable is selected.
|
||||||
|
*/
|
||||||
|
export const captureSelectionMarkdownForChat = (): string | null => {
|
||||||
|
return captureCodeMirrorSelection()
|
||||||
|
?? captureActiveElementSelection()
|
||||||
|
?? captureDomSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the current selection to the chat composer.
|
||||||
|
* When nothing is selected, focuses the chat input (Cursor-style Ctrl/Cmd+L).
|
||||||
|
* Returns true when selected text was appended.
|
||||||
|
*/
|
||||||
|
export const addSelectionToChat = (): boolean => {
|
||||||
|
const markdown = captureSelectionMarkdownForChat();
|
||||||
|
|
||||||
|
useUIStore.getState().setActiveMainTab('chat');
|
||||||
|
useUIStore.getState().setSessionSwitcherOpen(false);
|
||||||
|
|
||||||
|
if (markdown) {
|
||||||
|
useInputStore.getState().setPendingInputText(markdown, 'append');
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
focusChatInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown !== null;
|
||||||
|
};
|
||||||
@@ -441,7 +441,11 @@ export const debugUtils = {
|
|||||||
const sources = {
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { isBrowserClientRuntime } from './desktop';
|
||||||
|
|
||||||
|
describe('browser client runtime', () => {
|
||||||
|
test('uses browser file behavior only outside the Electron shell', () => {
|
||||||
|
expect(isBrowserClientRuntime('web', false)).toBe(true);
|
||||||
|
expect(isBrowserClientRuntime('web', true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps desktop and VS Code runtime behavior out of browser-only flows', () => {
|
||||||
|
expect(isBrowserClientRuntime('desktop', false)).toBe(false);
|
||||||
|
expect(isBrowserClientRuntime('vscode', false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ProjectEntry, TerminalShell } from '@/lib/api/types';
|
import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types';
|
||||||
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
|
import { 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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 サーフェスを開く',
|
||||||
|
|||||||
@@ -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 서피스 열기',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '導覽模型(選擇器中)',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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]])
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user