mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f346024f0e | |||
| 56ea8a4a49 | |||
| efaeda00a1 | |||
| 08ea08e830 | |||
| 4c0feeebb4 | |||
| 4d314f6e04 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 | |||
| 339fd50cb9 | |||
| 828a4b3f15 | |||
| a43de3f86d | |||
| fe3b2e8f90 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 | |||
| 430750e2f8 | |||
| 7913c4a490 | |||
| 5414697bd1 | |||
| e22e3b8f2c | |||
| 8e657c7db5 | |||
| 02e2277057 | |||
| ac1ddc3f83 | |||
| 8f904c8e9a | |||
| ec07ee56f4 | |||
| de86d73d19 | |||
| 0fa9e5039e | |||
| c073387723 | |||
| 1956497f42 | |||
| 2a7e32c416 | |||
| 56a7c06a80 | |||
| 75e8fd4da2 | |||
| b9f39dd751 | |||
| 6eeeb4bfcf | |||
| 1ccaca826e | |||
| 04f9b15178 | |||
| 66b9cc7931 | |||
| e3f7637eb2 | |||
| 5945a8d429 | |||
| c7ceccf869 | |||
| 00ab94c44f | |||
| fe09a2e9b7 | |||
| 59202356fe | |||
| 637282f3b9 | |||
| d1550cb599 | |||
| ed6c117184 | |||
| 6ce62bd84a | |||
| a4a948316b | |||
| aca3b7813d | |||
| 0eaa75ec0f | |||
| 6b3c4f5839 | |||
| 8e76adb08f | |||
| 0bb24a46c1 | |||
| d3f4695af3 | |||
| 5b2715d24f | |||
| 20e37e7122 | |||
| b87bb4486f | |||
| feaec7a6be | |||
| 1c67004999 | |||
| 43ecf3ff1b | |||
| 9028c2d8f8 | |||
| be18f22842 | |||
| 6216dbae15 | |||
| 96a9731947 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
---
|
||||
|
||||
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: opencode-drive
|
||||
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
|
||||
---
|
||||
|
||||
# OpenCode Drive
|
||||
|
||||
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
|
||||
|
||||
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
|
||||
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
|
||||
on changes).
|
||||
|
||||
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
|
||||
stops all processes, and cleans up all artifacts.
|
||||
|
||||
# Prepare The Environment
|
||||
|
||||
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
|
||||
|
||||
```bash
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
|
||||
|
||||
# Scripted usage
|
||||
|
||||
You can write scripts that walk through entire flows, and gives you full access to controlling
|
||||
the backend too. See examples of the script API at the bottom of this file.
|
||||
|
||||
After creating or editing a script, always typecheck it before running. Never skip this step:
|
||||
|
||||
```bash
|
||||
opencode-drive check ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
Run it by passing `--script` to start:
|
||||
|
||||
```bash
|
||||
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
It will output information about the run, including paths to log files which you can read
|
||||
to inspect what happened. If you need to dig into failures that aren't clear, read those log
|
||||
files. If the script is unsuccessful, automatically fix the script and run it again.
|
||||
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
async setup({ fs, config }) {
|
||||
config.autoupdate = false
|
||||
await fs.writeFile("src/example.ts", "export const value = 1\n")
|
||||
},
|
||||
|
||||
async run({ ui, llm }) {
|
||||
await ui.submit("Open src/example.ts")
|
||||
await llm.send(llm.text("The file exports `value`."))
|
||||
await ui.waitFor("The file exports `value`.")
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` receives the current OpenCode config object, which starts from the
|
||||
default drive config unless the prepared instance already has one. When a script
|
||||
needs custom config, mutate this `config` parameter instead of generating and
|
||||
writing a new config object from scratch, so the script keeps the default
|
||||
provider/model settings unless it intentionally changes them.
|
||||
|
||||
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
|
||||
|
||||
Use `launch: "manual"` when the script needs to launch the server and every TUI
|
||||
itself (this is extremely rare, do not use this unless explicitly asked). In this
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
`await llm.send(...)` waits for the next request and resolves after OpenCode
|
||||
acknowledges its complete response. `llm.queue(...)` declares responses in
|
||||
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
|
||||
`finish`, and `disconnect`. A normal response receives `finish("stop")`
|
||||
automatically unless it yields or queues an explicit terminal event.
|
||||
|
||||
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
|
||||
15-character target varied by plus or minus 5 per chunk.
|
||||
|
||||
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
|
||||
delay between any two outputs.
|
||||
|
||||
Use `llm.serve` for an ongoing typed response generator:
|
||||
|
||||
```ts
|
||||
llm.serve(async function* (request, index) {
|
||||
yield llm.reasoning(`Handling request ${index + 1}`)
|
||||
yield llm.text(`Received ${request.id}`)
|
||||
yield llm.finish("stop")
|
||||
})
|
||||
```
|
||||
|
||||
The backend connection, response cleanup, cancellation, and recording
|
||||
completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
- `prune` removes artifact directories. These are always cleaned up after running a script
|
||||
successfully, but leftover on failed runs. Always call this if a script fails.
|
||||
|
||||
```bash
|
||||
opencode-drive prune --name demo
|
||||
|
||||
// --force cleans up all artifcat directories
|
||||
opencode-dirve prune --force
|
||||
```
|
||||
|
||||
# Live interaction usage
|
||||
|
||||
- Always give headless instances a unique `--name`. Visible instances may omit it.
|
||||
- A normal headless `start` detaches automatically and returns after the instance is ready.
|
||||
- Do not add `&`; the long-running owner already runs in the background.
|
||||
- Configure simulated model responses after startup when needed.
|
||||
- Send ordered UI commands with `send`.
|
||||
- Always stop the instance when finished.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Explain this project"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
## Send UI Commands
|
||||
|
||||
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
|
||||
- Combine typing and Enter in one command when submitting a prompt.
|
||||
- JSON-valued commands require one JSON argument.
|
||||
- Multiple command flags execute from left to right.
|
||||
|
||||
Commands:
|
||||
|
||||
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
|
||||
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
|
||||
- `--command.ui.enter` presses Enter. Arguments: none.
|
||||
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
|
||||
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
|
||||
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
|
||||
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
|
||||
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.arrow '{"direction":"down"}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.focus '{"target":12}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.click '{"target":12,"x":4,"y":1}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.matches '{"text":"OpenCode"}'
|
||||
```
|
||||
|
||||
To read the UI state and see information about interactable elements, use the `ui.state` command:
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo --command.ui.state
|
||||
```
|
||||
|
||||
## Configure LLM Responses
|
||||
|
||||
- `responses` controls what the LLM responds with
|
||||
- Only use this if you are wanting to reproduce an exact type of response
|
||||
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
|
||||
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
|
||||
- `--tools` limits generated tool calls to names offered by OpenCode.
|
||||
|
||||
```bash
|
||||
opencode-drive responses --name demo \
|
||||
--types text,reasoning,diff,tool \
|
||||
--tools write,apply_patch
|
||||
|
||||
opencode-drive responses --name demo \
|
||||
--types tool \
|
||||
--tools read,glob,grep
|
||||
```
|
||||
|
||||
## Inspect The UI
|
||||
|
||||
- `ui.state` prints focus and interactive element metadata as JSON.
|
||||
- `ui.matches` checks for literal, case-sensitive screen text.
|
||||
- `screenshot` prints the generated image path.
|
||||
|
||||
```bash
|
||||
opencode-drive screenshot --name demo
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `stop` waits for recording export and owner cleanup before returning.
|
||||
|
||||
```bash
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Record The UI
|
||||
|
||||
- Start with `--record` to capture a headless instance from its first rendered frame.
|
||||
- `stop` finishes the recording, exports an MP4, and prints its path.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo --record
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Show me the current architecture"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Artifacts dir
|
||||
|
||||
- `dir` prints the artifact directory for the instance.
|
||||
|
||||
```bash
|
||||
opencode-drive dir --name demo
|
||||
```
|
||||
|
||||
@@ -164,5 +164,5 @@ const table = sqliteTable("session", {
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- The durable `Instructions.Applied` record is what the model was last told, per instruction source. Reconciliation narrates drift through `session.instructions.updated` and never rewrites the baseline; only completed compaction rebaselines, while Session movement or committed revert resets the `InstructionCheckpoint`. Unavailable sources keep the model's prior belief, blocking only a Session's first instruction baseline.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
+31
-31
@@ -13,11 +13,11 @@ The opaque algebra of independently refreshable typed instruction sources that r
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction and **InstructionCheckpoint** baseline cutoffs.
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Instruction Source**:
|
||||
One independently observed typed value within **Instructions**, represented by a stable namespaced key, JSON codec, loader, pure baseline/update renderers, and an optional removal renderer.
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**InstructionEntry**:
|
||||
@@ -26,22 +26,25 @@ One API-managed, durable, per-Session instruction value. Its slash-free client k
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**InstructionCheckpoint**:
|
||||
The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps.
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Instruction Update**:
|
||||
A durable chronological System message published as `session.instructions.updated` that tells the model the newly effective state of one or more changed **Instruction Sources**.
|
||||
_Avoid_: System notification, raw text diff
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Instruction Baseline**:
|
||||
The exact joined instruction text stored by **InstructionCheckpoint** and sent as immutable provider-cache prefix state until completed compaction rebaselines it or Session movement or committed revert resets it.
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Applied Instructions**:
|
||||
The overwriteable model-hidden `Instructions.Applied` record in **InstructionCheckpoint**, containing what the model was last told per **Instruction Source**.
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to observe an **Instruction Source** value; the runtime retains its prior effective state and emits no update, while an unavailable source blocks creation of the first complete **Instruction Baseline**.
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
@@ -53,7 +56,7 @@ A durable user input accepted into the Session inbox but not yet included in **S
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
@@ -108,18 +111,16 @@ _Avoid_: Response envelope
|
||||
## Relationships
|
||||
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, the **Instruction Baseline**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** contains projected conversational messages and admitted **Instruction Updates**; the active **Instruction Baseline** remains separate provider-request state.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** loader returns one coherent typed value or explicitly reports unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its codec compares and stores the value, while pure renderers produce baseline, update, and optional removal text.
|
||||
- `Instructions.initialize(...)` observes composed **Instructions** once and produces a complete **Instruction Baseline** with **Applied Instructions**.
|
||||
- `Instructions.reconcile(...)` observes composed **Instructions** once and returns either unchanged or one combined chronological update. It never rewrites the baseline.
|
||||
- `Instructions.rebaseline(...)` renders a fresh baseline after completed compaction, recalling previously applied values for sources that are temporarily unavailable.
|
||||
- A changed **Instruction Source** may contribute text to one **Instruction Update** containing the newly effective state.
|
||||
- An **Instruction Update** persists the exact combined rendered text sent to the model through `session.instructions.updated`.
|
||||
- **Applied Instructions** advances atomically with the corresponding durable **Instruction Update**.
|
||||
- **Applied Instructions** stores one codec-encoded JSON value and, for removable sources, a pre-rendered removal message per stable **Instruction Source** key.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
@@ -130,28 +131,27 @@ _Avoid_: Response envelope
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** renders the latest complete **Instruction Baseline** and creates its **InstructionCheckpoint** without emitting a redundant **Instruction Update**; an unavailable initial source blocks the Step instead of persisting an incomplete baseline.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history.
|
||||
- A newly composed **Instruction Source** absent from **Applied Instructions** emits its baseline rendering once at the next **Safe Step Boundary**.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session resets its **InstructionCheckpoint**, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- An **Instruction Baseline** is stored durably and reused verbatim across process restarts until rebaseline or reset.
|
||||
- An **Instruction Baseline** durably preserves the exact joined text used for its part of the active provider-cache prefix.
|
||||
- A model/provider switch preserves the current **InstructionCheckpoint** and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
@@ -195,7 +195,7 @@ _Avoid_: Response envelope
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose system text, **Instruction Baseline**, tools, and step-local additions remain separate.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||
// ~250 GB as of 2026-07.
|
||||
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
|
||||
+3
-1
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.25 vCPU",
|
||||
memory: "0.5 GB",
|
||||
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||
memory: "2 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
@@ -102,6 +103,8 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
||||
@@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
@@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
@@ -102,9 +109,11 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
await filter.fill("nested")
|
||||
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||
await expect(result).toBeVisible()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||
const shellID = "prt_shell_outline"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||
reducedMotion: true,
|
||||
deviceScaleFactor,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
const output = part.locator('[data-component="bash-output"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(output).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||
if (!output) throw new Error("Shell output is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const outputRect = output.getBoundingClientRect()
|
||||
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||
element.style.transform = "translateY(0.25px)"
|
||||
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||
const style = getComputedStyle(output)
|
||||
return {
|
||||
outputWidth: outputRect.width,
|
||||
outputHeight: outputRect.height,
|
||||
borderColor: style.borderTopColor,
|
||||
boxShadow: style.boxShadow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
const clipped = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(clipped).toBeCloseTo(0.49, 1)
|
||||
|
||||
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||
const edges = await captureCardEdges(page, output)
|
||||
|
||||
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||
expect(geometry.boxShadow).toBe("none")
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||
const patchID = "prt_patch_outline"
|
||||
const file = {
|
||||
filePath: "src/outline.ts",
|
||||
relativePath: "src/outline.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "const outline = false\n",
|
||||
after: "const outline = true\n",
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
if (!card) throw new Error("Patch card is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||
const bottom = element.getBoundingClientRect().bottom
|
||||
return {
|
||||
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||
clipMargin,
|
||||
cardWidth: cardRect.width,
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||
expect(edges.luminance.top).toBeLessThan(245)
|
||||
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
})
|
||||
|
||||
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||
const secondUserID = "msg_outline_second_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_outline_second_assistant",
|
||||
parentID: secondUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
})),
|
||||
)
|
||||
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||
})
|
||||
|
||||
async function captureCardEdges(page: Page, card: Locator) {
|
||||
const box = await card.boundingBox()
|
||||
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||
const screenshot = await page.screenshot()
|
||||
return page.evaluate(
|
||||
async ({ source, box, viewport }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
const scale = {
|
||||
x: image.naturalWidth / viewport.width,
|
||||
y: image.naturalHeight / viewport.height,
|
||||
}
|
||||
const rows = (candidates: number[]) => {
|
||||
const left = Math.floor((box.x + 8) * scale.x)
|
||||
const width = Math.floor((box.width - 16) * scale.x)
|
||||
return candidates.map((row) => {
|
||||
const pixels = context.getImageData(left, row, width, 1).data
|
||||
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||
return {
|
||||
luminance:
|
||||
indexes
|
||||
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||
.reduce((sum, value) => sum + value, 0) / width,
|
||||
magenta:
|
||||
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||
.length / width,
|
||||
}
|
||||
})
|
||||
}
|
||||
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||
const columns = new Uint32Array(image.naturalWidth)
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||
}
|
||||
const top = box.y * scale.y
|
||||
const bottom = (box.y + box.height) * scale.y
|
||||
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||
return {
|
||||
box,
|
||||
luminance: {
|
||||
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||
},
|
||||
magenta: {
|
||||
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
viewport,
|
||||
box,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -53,9 +53,11 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
|
||||
@@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
},
|
||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||
load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||
load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
@@ -263,7 +263,7 @@ function createSessionEntries(props: {
|
||||
return props
|
||||
.load(directory)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
result.items
|
||||
.filter((session) => !!session?.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
@@ -176,6 +177,7 @@ function ProviderConnection(props: {
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const alive = { value: true }
|
||||
const connected = { value: false }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -188,7 +190,16 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
const integrationID = () => {
|
||||
const value = provider()
|
||||
if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
|
||||
return value.integrationID
|
||||
}
|
||||
const location = () => {
|
||||
const directory = props.directory?.()
|
||||
return directory ? { location: { directory } } : {}
|
||||
}
|
||||
const fallback = createMemo<AuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
@@ -197,19 +208,52 @@ function ProviderConnection(props: {
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v2") {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integration = await capability.get({ ...location(), integrationID: integrationID() })
|
||||
if (!alive.value) return fallback()
|
||||
return (
|
||||
integration?.methods.flatMap((method): AuthMethod[] => {
|
||||
if (method.type === "environment") return []
|
||||
if (method.type === "key") return [{ type: "api", label: method.label }]
|
||||
return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
|
||||
}) ?? fallback()
|
||||
)
|
||||
}
|
||||
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
const result = await capability.methods(location())
|
||||
if (!alive.value) return fallback()
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(result).map(([id, methods]) => [
|
||||
id,
|
||||
methods.map((method) => ({
|
||||
...method,
|
||||
prompts: method.prompts?.map((prompt) =>
|
||||
prompt.type === "select"
|
||||
? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
|
||||
: { ...prompt },
|
||||
),
|
||||
})),
|
||||
]),
|
||||
)
|
||||
serverSync().set("provider_auth", normalized)
|
||||
return normalized[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const methods = createMemo<AuthMethod[]>(() => [
|
||||
...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
|
||||
])
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
authorization: undefined as undefined | ProviderAuthorization,
|
||||
attemptID: undefined as string | undefined,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
@@ -221,7 +265,7 @@ function ProviderConnection(props: {
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
@@ -230,6 +274,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.attemptID = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
@@ -238,6 +283,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.attemptID = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
@@ -262,6 +308,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.attemptID = action.attemptID
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
@@ -273,6 +320,16 @@ function ProviderConnection(props: {
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
onCleanup(() => {
|
||||
if (!store.attemptID || connected.value) return
|
||||
void (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
await backend.capabilities.integrationsV2
|
||||
?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
|
||||
.catch(() => undefined)
|
||||
})()
|
||||
})
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||
@@ -322,15 +379,33 @@ function ProviderConnection(props: {
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const backend = await serverSDK().backend
|
||||
const request =
|
||||
backend.version === "v1"
|
||||
? (() => {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
|
||||
})()
|
||||
: (() => {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!method.id) throw new Error("Provider OAuth method is missing an ID")
|
||||
return capability
|
||||
.connectOauth({
|
||||
...location(),
|
||||
integrationID: integrationID(),
|
||||
methodID: method.id,
|
||||
values: inputs ?? {},
|
||||
})
|
||||
.then((attempt) => ({
|
||||
url: attempt.url,
|
||||
method: attempt.mode,
|
||||
instructions: attempt.instructions,
|
||||
attemptID: attempt.attemptID,
|
||||
}))
|
||||
})()
|
||||
await request
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
const elapsed = Date.now() - start
|
||||
@@ -341,11 +416,19 @@ function ProviderConnection(props: {
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({
|
||||
type: "auth.complete",
|
||||
authorization: x,
|
||||
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||
})
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({
|
||||
type: "auth.complete",
|
||||
authorization: x,
|
||||
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
@@ -445,7 +528,7 @@ function ProviderConnection(props: {
|
||||
<div>
|
||||
<List
|
||||
class="px-3"
|
||||
items={select()?.options ?? []}
|
||||
items={[...(select()?.options ?? [])]}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
@@ -498,7 +581,10 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK().client.global.dispose()
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
connected.value = true
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -570,14 +656,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
|
||||
} else {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
await capability.connectKey({
|
||||
...location(),
|
||||
integrationID: integrationID(),
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
await complete()
|
||||
}
|
||||
|
||||
@@ -642,13 +734,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
const result = await (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||
await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
|
||||
})()
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
@@ -695,12 +794,26 @@ function ProviderConnection(props: {
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
const result = await (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.callback({ providerID: props.provider, method: store.methodIndex! })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||
while (alive.value) {
|
||||
const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
|
||||
if (status.status === "complete") return
|
||||
if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
|
||||
if (status.status === "expired") throw new Error("Authorization expired")
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
})()
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
@@ -118,7 +118,7 @@ export function CustomProviderForm() {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||
disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
|
||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
@@ -131,23 +131,26 @@ export function CustomProviderForm() {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const disabledProviders = serverSync().data.config.disabledProviders ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
const backend = await serverSDK().backend
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
if (result.key && backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.setApiKey({ providerID: result.providerID, key: result.key })
|
||||
}
|
||||
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
disabledProviders: nextDisabled,
|
||||
})
|
||||
if (result.key && backend.version === "v2") {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
await capability.connectKey({ integrationID: result.providerID, key: result.key })
|
||||
await serverSync().refreshProviders()
|
||||
}
|
||||
return result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
|
||||
@@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverSDK().client.project.update({
|
||||
const editing = (await serverSDK().backend).capabilities.projectEditing
|
||||
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||
await editing.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
location: { directory: props.project.worktree },
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppPart } from "@/context/backend"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
const textPart = parts.find(
|
||||
(x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
)
|
||||
if (!textPart) continue
|
||||
|
||||
result.push({
|
||||
@@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.backend.then((client) => {
|
||||
const capability = client.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Session forking is not supported by this server")
|
||||
return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
|
||||
})
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() =>
|
||||
sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
sdk.backend
|
||||
.then((client) => client.capabilities.pathInfo?.get())
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
@@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
fallbackPath()?.home ||
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || typed === current) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
const files = await sdk.backend
|
||||
.then((client) =>
|
||||
client.common.files.find({
|
||||
location: { directory: root() },
|
||||
query: pickerFileSearchQuery(root(), value, home()),
|
||||
type: "file",
|
||||
limit: 20,
|
||||
}),
|
||||
)
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||
...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
return sdk.backend
|
||||
.then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
|
||||
.then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
|
||||
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
return sdk.backend
|
||||
.then((client) => client.capabilities.pathInfo?.get())
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
@@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
)
|
||||
|
||||
const directories = createDirectorySearch({
|
||||
sdk,
|
||||
backend: sdk.backend,
|
||||
home,
|
||||
base: start,
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
pickerRoot,
|
||||
pickerAbsoluteInput,
|
||||
} from "./directory-picker-domain"
|
||||
import { createAppClient } from "@/context/backend.test-fixture"
|
||||
|
||||
test("maps server directory entries into Pierre paths", () => {
|
||||
expect(
|
||||
@@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
return Promise.resolve({ data: [] })
|
||||
const backend = Promise.resolve(
|
||||
createAppClient({
|
||||
common: {
|
||||
files: {
|
||||
find: (input) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve([])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
}),
|
||||
)
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
|
||||
|
||||
await search("components")
|
||||
base = "/repo/src"
|
||||
|
||||
@@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
|
||||
}
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
import type { AppClient } from "@/context/backend"
|
||||
|
||||
export function cleanPickerInput(value: string) {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
@@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
||||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
export function createDirectorySearch(args: {
|
||||
backend: Promise<AppClient>
|
||||
base: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
@@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const key = trimPickerPath(directory)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
const request = args.backend
|
||||
.then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
.map((node) => ({
|
||||
name: node.name ?? getFilename(node.path),
|
||||
absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
|
||||
})),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
@@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
const results = await args.backend
|
||||
.then((client) =>
|
||||
client.common.files.find({
|
||||
location: { directory: input.directory },
|
||||
query,
|
||||
type: "directory",
|
||||
limit: 50,
|
||||
}),
|
||||
)
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
|
||||
}
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
@@ -72,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppReference as ReferenceInfo } from "@/context/backend"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -676,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
client: resource.server,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
@@ -1333,6 +1335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1704,22 +1707,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={IconButton}
|
||||
as={IconButtonV2}
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
style={buttons()}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||
{language.t("prompt.menu.imagesAndFiles")}
|
||||
</MenuV2.Item>
|
||||
|
||||
@@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
|
||||
).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
|
||||
expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppPart as Part, PromptPart } from "@/context/backend"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
type PromptRequestPart = PromptPart
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
@@ -33,6 +34,10 @@ const prompt = {
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
@@ -76,6 +81,34 @@ const clientFor = (directory: string) => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
const backend = Promise.resolve({
|
||||
common: {
|
||||
sessions: {
|
||||
create: async (input: { location?: { directory?: string } }) => {
|
||||
const directory = input.location?.directory ?? "/repo/main"
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
title: `New session ${createdSessions.length}`,
|
||||
cost: 0,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
},
|
||||
prompt: async () => undefined,
|
||||
command: async () => undefined,
|
||||
interrupt: async () => undefined,
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sessionExtrasV1: {
|
||||
shell: async (input: { location?: { directory?: string } }) => {
|
||||
sentShell.push(input.location?.directory ?? "/repo/main")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
@@ -84,13 +117,6 @@ beforeAll(async () => {
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
@@ -156,6 +182,7 @@ beforeAll(async () => {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
backend,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
@@ -277,7 +304,7 @@ describe("prompt submit worktree selection", () => {
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
@@ -378,6 +405,39 @@ describe("prompt submit worktree selection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
@@ -403,7 +463,13 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
title: "New session 1",
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
}),
|
||||
])
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
@@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -39,13 +39,14 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: DirectorySDK["client"]
|
||||
backend: Promise<AppClient>
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
before?: () => Promise<boolean> | boolean
|
||||
commitRevert?: boolean
|
||||
}
|
||||
|
||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
@@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const backend = await input.backend
|
||||
const location = { directory: input.draft.sessionDirectory }
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
if (input.commitRevert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||
const capability = backend.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Commands are not supported by this server")
|
||||
await capability.command({
|
||||
location,
|
||||
sessionID: input.draft.sessionID,
|
||||
id: input.messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
model: {
|
||||
providerID: input.draft.model.providerID,
|
||||
id: input.draft.model.modelID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
const message: Message = {
|
||||
const message: AppMessage = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
role: "user",
|
||||
@@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
if (input.commitRevert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||
await backend.common.sessions.prompt({
|
||||
location,
|
||||
sessionID: input.draft.sessionID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
id: messageID,
|
||||
text,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
selection: {
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
providerID: input.draft.model.providerID,
|
||||
id: input.draft.model.modelID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
@@ -191,6 +210,7 @@ type PromptSubmitInput = {
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -232,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.backend.then((client) =>
|
||||
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -261,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const seed = (dir: string, info: AppSession) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
setStore("session", (list: AppSession[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
@@ -296,9 +316,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = local.model.variant.current()
|
||||
const variant = modelSelection.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
@@ -311,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const backend = await sdk().backend
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
const createdWorktree = await backend.capabilities.worktreesV1
|
||||
?.create({ location: { directory: projectDirectory } })
|
||||
.catch((err: unknown) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
@@ -347,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
@@ -359,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
const created = await sdk()
|
||||
.backend.then((backend) =>
|
||||
backend.common.sessions.create({ location: { directory: sessionDirectory } }),
|
||||
)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -372,13 +389,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
@@ -438,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
sdk()
|
||||
.backend.then(async (backend) => {
|
||||
const location = { directory: sessionDirectory }
|
||||
if (session.revert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||
if (backend.capabilities.sessionExtrasV1)
|
||||
return backend.capabilities.sessionExtrasV1.shell({
|
||||
location,
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID },
|
||||
command: text,
|
||||
})
|
||||
if (backend.capabilities.sessionExtrasV2?.shell)
|
||||
return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
|
||||
throw new Error("Shell prompts are not supported by this server")
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -461,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
sdk()
|
||||
.backend.then(async (backend) => {
|
||||
const location = { directory: sessionDirectory }
|
||||
if (session.revert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||
const capability = backend.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Commands are not supported by this server")
|
||||
return capability.command({
|
||||
location,
|
||||
sessionID: session.id,
|
||||
id: Identifier.ascending("message"),
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -561,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
backend: sdk().backend,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
commitRevert: !!session.revert,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
@@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage, AppPart } from "@/context/backend"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
@@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||
|
||||
const charsFromUserPart = (part: Part) => {
|
||||
const charsFromUserPart = (part: AppPart) => {
|
||||
if (part.type === "text") return part.text.length
|
||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||
return 0
|
||||
}
|
||||
|
||||
const charsFromAssistantPart = (part: Part) => {
|
||||
const charsFromAssistantPart = (part: AppPart) => {
|
||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||
@@ -68,8 +68,8 @@ const build = (
|
||||
}
|
||||
|
||||
export function estimateSessionContextBreakdown(args: {
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[] | undefined>
|
||||
messages: AppMessage[]
|
||||
parts: Record<string, AppPart[] | undefined>
|
||||
input: number
|
||||
systemPrompt?: string
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import type { AppMessage as Message } from "@/context/backend"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -38,10 +38,10 @@ const user = (id: string) => {
|
||||
}
|
||||
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.total).toBe(500)
|
||||
expect(ctx?.input).toBe(300)
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
|
||||
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
|
||||
test("computes stored session token totals", () => {
|
||||
expect(
|
||||
getSessionTokenTotal({
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 30,
|
||||
cache: { read: 40, write: 50 },
|
||||
}),
|
||||
).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppAssistantMessage, AppMessage } from "@/context/backend"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
@@ -14,21 +14,22 @@ type Model = {
|
||||
}
|
||||
|
||||
type Context = {
|
||||
message: AssistantMessage
|
||||
message: AppAssistantMessage
|
||||
provider?: Provider
|
||||
model?: Model
|
||||
providerLabel: string
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
const tokenTotal = (msg: AppAssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
|
||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
const lastAssistantWithTokens = (messages: AppMessage[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
@@ -37,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return undefined
|
||||
|
||||
@@ -54,15 +55,11 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||
if (!tokens) return undefined
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -135,7 +135,6 @@ export function SessionContextTab() {
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -204,14 +203,15 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
|
||||
<MenuV2.Context.Trigger class="relative" as="div">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
onMouseDown={(e) => {
|
||||
// Switch on mousedown to shave the press-release delay off tab switches.
|
||||
if (e.button !== 0) return
|
||||
if (store.editing) return
|
||||
focus()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (e.detail > 0) return
|
||||
focus()
|
||||
}}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
|
||||
@@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||
if (!capability) return []
|
||||
return capability.list().catch(() => [] as ShellOption[])
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
import { credentialConnectionIDs } from "@/context/backend"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabledProviders ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabledProviders", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.updateConfig({ disabledProviders: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabledProviders", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
const disconnect = async (item: ProviderItem) => {
|
||||
const backend = await serverSDK().backend
|
||||
const remove = async () => {
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.remove({ providerID: item.id })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integrationID =
|
||||
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||
const integration = await capability.get({ integrationID })
|
||||
await Promise.all(
|
||||
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||
capability.removeCredential({ credentialID }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfigCustom(item.id)) {
|
||||
await remove().catch(() => undefined)
|
||||
await disableProvider(item.id, item.name)
|
||||
return
|
||||
}
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
await remove()
|
||||
.then(async () => {
|
||||
await serverSDK().client.global.dispose()
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
@@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||
if (!capability) return []
|
||||
return capability.list().catch(() => [] as ShellOption[])
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
import { credentialConnectionIDs } from "@/context/backend"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabledProviders ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabledProviders", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.updateConfig({ disabledProviders: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabledProviders", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
const disconnect = async (item: ProviderItem) => {
|
||||
const backend = await serverSdk().backend
|
||||
const remove = async () => {
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.remove({ providerID: item.id })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integrationID =
|
||||
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||
const integration = await capability.get({ integrationID })
|
||||
await Promise.all(
|
||||
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||
capability.removeCredential({ credentialID }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfigCustom(item.id)) {
|
||||
await remove().catch(() => undefined)
|
||||
await disableProvider(item.id, item.name)
|
||||
return
|
||||
}
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
await remove()
|
||||
.then(async () => {
|
||||
await serverSdk().client.global.dispose()
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
|
||||
{language.t("common.disconnect")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
|
||||
@@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
||||
<span class="settings-v2-servers-meta">
|
||||
<Show when={health()?.version}>v{health()?.version}</Show>
|
||||
<Show when={health()?.version && item.type === "http"}> • </Show>
|
||||
<Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
|
||||
<Show when={health()?.installationVersion && item.type === "http"}> • </Show>
|
||||
<Show
|
||||
when={item.type === "http" && item.http.username}
|
||||
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.settings-v2-panel::-webkit-scrollbar {
|
||||
@@ -181,6 +186,7 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer > span {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
|
||||
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
@@ -10,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
@@ -68,6 +67,19 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
|
||||
let current = tokens[key]
|
||||
for (let i = 0; i < 8 && current; i++) {
|
||||
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
|
||||
if (!match) {
|
||||
const hex = current.trim()
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
|
||||
return hex
|
||||
}
|
||||
current = tokens[match[1]]
|
||||
}
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -160,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||
const connection = useServerSDK()().server
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const url = sdk().url
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
const backend = sdk().backend
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
@@ -219,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return client.pty
|
||||
.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
return backend
|
||||
.then((client) =>
|
||||
client.common.pty.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
location: { directory },
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to sync terminal size", err)
|
||||
})
|
||||
@@ -238,7 +245,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (!variant?.seeds && !variant?.palette) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
const background = settings.general.newLayoutDesigns()
|
||||
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
|
||||
fallback.background)
|
||||
: (resolved["background-stronger"] ?? fallback.background)
|
||||
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||
const selectionBackground = withAlpha(base, alpha)
|
||||
@@ -473,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
backend
|
||||
.then((client) => {
|
||||
const transport = client.capabilities.ptyTransport
|
||||
if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
|
||||
return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
|
||||
})
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
|
||||
const connectToken = async () => {
|
||||
const result = await client.pty
|
||||
.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
const transport = (await backend).capabilities.ptyTransport
|
||||
if (!transport) return
|
||||
const result = await transport
|
||||
.connectToken({ ptyID: id, location: { directory } })
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
throw err
|
||||
})
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
if (result.status === 200 && result.ticket) return result.ticket
|
||||
if (result.status === 404 || result.status === 405) return
|
||||
if (result.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
throw new Error(`PTY connect ticket failed with ${result.status}`)
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
@@ -532,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const transport = (await backend).capabilities.ptyTransport
|
||||
if (!transport) {
|
||||
fail(new Error("PTY transport is not supported by this server"))
|
||||
return
|
||||
}
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken,
|
||||
}),
|
||||
transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { AppSession } from "@/context/backend"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
session: () => AppSession | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
@@ -120,8 +120,10 @@ export function TabNavItem(props: {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
const client = await ctx.sdk.backend
|
||||
const capability = client.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Session renaming is not supported by this server")
|
||||
await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
@@ -266,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
sdk.backend
|
||||
.then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
@@ -324,13 +324,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
const sessionTab = {
|
||||
type: "session" as const,
|
||||
server: route.server ?? server.key,
|
||||
sessionId: activeSession.id,
|
||||
}
|
||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
const activeTab = currentTab()
|
||||
if (activeTab?.type === "draft") {
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
import { backendIdentity, createBackendForServer } from "./backend-client"
|
||||
|
||||
const server = {
|
||||
type: "http" as const,
|
||||
http: {
|
||||
url: "http://localhost:4096",
|
||||
username: "user",
|
||||
password: "secret",
|
||||
},
|
||||
authToken: false,
|
||||
}
|
||||
|
||||
function setup(health: ServerHealth) {
|
||||
const requests: Request[] = []
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (new URL(request.url).pathname === "/project")
|
||||
return new Response("[]", { headers: { "content-type": "application/json" } })
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
return {
|
||||
requests,
|
||||
fetch,
|
||||
backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
|
||||
}
|
||||
}
|
||||
|
||||
describe("createBackendForServer", () => {
|
||||
test("changes backend identity when credentials for the same URL change", () => {
|
||||
expect(backendIdentity(server)).not.toBe(
|
||||
backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("selects v2 and configures fetch and authentication", async () => {
|
||||
const result = setup({ healthy: true, version: "v2" })
|
||||
const backend = await result.backend
|
||||
|
||||
expect(backend.version).toBe("v2")
|
||||
expect(result.requests).toHaveLength(0)
|
||||
await backend.common.health.get()
|
||||
expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
|
||||
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
|
||||
expect(backend.capabilities.projectList).toBeUndefined()
|
||||
expect(backend.capabilities.vcs).toBeUndefined()
|
||||
expect(backend.capabilities.mcp).toBeUndefined()
|
||||
expect(result.requests).toHaveLength(1)
|
||||
expect(backend.version).toBe("v2")
|
||||
})
|
||||
|
||||
test("selects v1 after fallback detection", async () => {
|
||||
const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
|
||||
const backend = await result.backend
|
||||
|
||||
expect(backend.version).toBe("v1")
|
||||
expect(result.requests).toHaveLength(0)
|
||||
await backend.common.health.get()
|
||||
expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
|
||||
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
expect(backend.capabilities.projectList).toBeDefined()
|
||||
expect(backend.capabilities.vcs).toBeDefined()
|
||||
expect(backend.capabilities.mcp).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
import { authTokenFromCredentials } from "@/utils/server"
|
||||
import type { ServerConnection } from "./server"
|
||||
import type { LocationRef } from "./backend"
|
||||
import { createV1Backend } from "./backend-v1"
|
||||
import { createV2Backend } from "./backend-v2"
|
||||
|
||||
function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return {
|
||||
baseUrl: server.url,
|
||||
fetch,
|
||||
headers: server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return createOpencodeClient(options(server, fetch))
|
||||
}
|
||||
|
||||
export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return OpenCode.make(options(server, fetch))
|
||||
}
|
||||
|
||||
export function backendIdentity(server: ServerConnection.Any) {
|
||||
return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
|
||||
server.type === "http" && server.authToken === true ? "token" : ""
|
||||
}`
|
||||
}
|
||||
|
||||
export async function createBackendForServer(input: {
|
||||
server: ServerConnection.Any
|
||||
browserUrl: string
|
||||
fetch: typeof globalThis.fetch
|
||||
eventFetch?: typeof globalThis.fetch
|
||||
health: Promise<ServerHealth>
|
||||
defaultLocation?: LocationRef
|
||||
}) {
|
||||
const health = await input.health
|
||||
const eventFetch = input.eventFetch ?? input.fetch
|
||||
const transport = {
|
||||
baseUrl: input.server.http.url,
|
||||
fetch: input.fetch,
|
||||
username: input.server.http.username,
|
||||
password: input.server.http.password,
|
||||
sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
|
||||
authToken: input.server.type === "http" && input.server.authToken === true,
|
||||
}
|
||||
if (health.version === "v2")
|
||||
return createV2Backend(
|
||||
createV2RawClient(input.server.http, input.fetch),
|
||||
transport,
|
||||
input.defaultLocation,
|
||||
createV2RawClient(input.server.http, eventFetch),
|
||||
)
|
||||
const legacy = createV1RawClient(input.server.http, input.fetch)
|
||||
const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
|
||||
return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||
import type { PtyTransportConfig } from "./backend"
|
||||
import { createV1Backend } from "./backend-v1"
|
||||
|
||||
function setup(
|
||||
respond: (request: Request) => Response | Promise<Response>,
|
||||
withDefault = false,
|
||||
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return respond(request)
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
|
||||
return {
|
||||
requests,
|
||||
backend: createV1Backend(
|
||||
client,
|
||||
withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
|
||||
client,
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
fetch,
|
||||
username: "user",
|
||||
password: "secret",
|
||||
sameOrigin: transport?.sameOrigin ?? false,
|
||||
authToken: transport?.authToken ?? false,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown, headers?: HeadersInit) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
|
||||
})
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
|
||||
describe("createV1Backend", () => {
|
||||
test("preserves location in OAuth callbacks", async () => {
|
||||
const setupResult = setup(() => json({}))
|
||||
|
||||
await setupResult.backend.capabilities.providerAuthV1?.callback({
|
||||
providerID: "provider",
|
||||
method: 1,
|
||||
code: "code",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.searchParams.get("directory")).toBe("/repo")
|
||||
expect(url.searchParams.get("workspace")).toBe("workspace")
|
||||
})
|
||||
|
||||
test("normalizes session pagination and location", async () => {
|
||||
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
roots: true,
|
||||
limit: 10,
|
||||
cursor: "123",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
version: "1",
|
||||
parentID: undefined,
|
||||
projectID: "project",
|
||||
location: { directory: "/repo", workspaceID: undefined },
|
||||
directory: "/repo",
|
||||
workspaceID: undefined,
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: undefined,
|
||||
time: { created: 1, updated: 2 },
|
||||
share: undefined,
|
||||
revert: undefined,
|
||||
},
|
||||
],
|
||||
older: "456",
|
||||
})
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/experimental/session")
|
||||
expect(url.searchParams.get("directory")).toBe("/repo")
|
||||
expect(url.searchParams.get("workspace")).toBe("workspace")
|
||||
expect(url.searchParams.get("roots")).toBe("true")
|
||||
expect(url.searchParams.get("cursor")).toBe("123")
|
||||
})
|
||||
|
||||
test("converts normalized prompts to legacy parts", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: {
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
},
|
||||
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }],
|
||||
agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }],
|
||||
})
|
||||
|
||||
const request = setupResult.requests[0]
|
||||
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||
expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
|
||||
expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
|
||||
expect(await request.json()).toEqual({
|
||||
messageID: "msg_1",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
agent: "build",
|
||||
variant: "high",
|
||||
parts: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" },
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves ordered prompt part IDs and metadata", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "visible",
|
||||
parts: [
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
],
|
||||
})
|
||||
|
||||
expect((await setupResult.requests[0].json()).parts).toEqual([
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("combines mixed file search and decodes binary content", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/find/file") {
|
||||
return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"])
|
||||
}
|
||||
return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" })
|
||||
})
|
||||
|
||||
const found = await setupResult.backend.common.files.find({ query: "a" })
|
||||
const content = await setupResult.backend.common.files.read({ path: "a.bin" })
|
||||
|
||||
expect(found).toEqual([
|
||||
{ path: "a.txt", type: "file" },
|
||||
{ path: "shared", type: "directory" },
|
||||
{ path: "dir", type: "directory" },
|
||||
])
|
||||
expect([...content.bytes]).toEqual([0, 1, 2])
|
||||
expect(content.kind).toBe("binary")
|
||||
expect(content.mimeType).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("preserves project, provider, and file metadata", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/project")
|
||||
return json([
|
||||
{ id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
|
||||
])
|
||||
if (path === "/provider")
|
||||
return json({
|
||||
all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
|
||||
connected: [],
|
||||
default: {},
|
||||
})
|
||||
return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||
})
|
||||
|
||||
const projectList = setupResult.backend.capabilities.projectList
|
||||
if (!projectList) throw new Error("Missing project list capability")
|
||||
const projects = await projectList.list()
|
||||
const providers = await setupResult.backend.common.catalog.providers()
|
||||
const files = await setupResult.backend.common.files.list({})
|
||||
|
||||
expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
|
||||
expect(providers.providers.get("provider")?.source).toBe("config")
|
||||
expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||
})
|
||||
|
||||
test("uses explicit session locations and preserves mutation confirmations", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
|
||||
if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
|
||||
return json(session)
|
||||
}, true)
|
||||
|
||||
const history = await setupResult.backend.common.sessions.history({
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
})
|
||||
const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
|
||||
const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
})
|
||||
const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
|
||||
const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
|
||||
const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
|
||||
|
||||
expect(history.older).toBe("older")
|
||||
expect(removed).toBe(true)
|
||||
expect(reverted?.id).toBe("ses_1")
|
||||
expect(cleared?.id).toBe("ses_1")
|
||||
expect(worktreeRemoved).toBe(true)
|
||||
expect(worktreeReset).toBe(true)
|
||||
const urls = setupResult.requests.map((request) => new URL(request.url))
|
||||
expect(urls[0].searchParams.get("directory")).toBe("/explicit")
|
||||
expect(urls[1].searchParams.get("directory")).toBe("/default")
|
||||
})
|
||||
|
||||
test("normalizes idle and compatibility events", async () => {
|
||||
const events = [
|
||||
{ type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
|
||||
{
|
||||
type: "todo.updated",
|
||||
properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||
},
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
|
||||
},
|
||||
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||
{ type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
|
||||
{ type: "lsp.updated", properties: {} },
|
||||
{ type: "reference.updated", properties: {} },
|
||||
{ type: "mcp.tools.changed", properties: { server: "docs" } },
|
||||
{ type: "server.instance.disposed", properties: { directory: "/repo" } },
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
const result = await Promise.all(events.map(() => iterator.next()))
|
||||
|
||||
expect(result.map((item) => item.value?.event.type)).toEqual([
|
||||
"session.activity",
|
||||
"todo.updated",
|
||||
"timeline.delta",
|
||||
"timeline.part.removed",
|
||||
"worktree.ready",
|
||||
"lsp.updated",
|
||||
"reference.updated",
|
||||
"mcp.updated",
|
||||
"instance.disposed",
|
||||
])
|
||||
expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
|
||||
expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
|
||||
})
|
||||
|
||||
test("updates the V1 projection cache for deltas and removals", async () => {
|
||||
const info = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "p", modelID: "m" },
|
||||
}
|
||||
const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
|
||||
const events = [
|
||||
{ type: "message.updated", properties: { info } },
|
||||
{ type: "message.part.updated", properties: { sessionID: "ses_1", part } },
|
||||
{ type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
|
||||
{ type: "message.updated", properties: { info } },
|
||||
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||
{ type: "message.updated", properties: { info } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
const afterDelta = await iterator.next()
|
||||
await iterator.next()
|
||||
const afterRemoval = await iterator.next()
|
||||
|
||||
expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
|
||||
expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("merges global config updates with untouched fields", async () => {
|
||||
const bodies: unknown[] = []
|
||||
const setupResult = setup(async (request) => {
|
||||
if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] })
|
||||
bodies.push(await request.json())
|
||||
return json({})
|
||||
})
|
||||
|
||||
await setupResult.backend.capabilities.configuration?.updateGlobal({
|
||||
model: "new",
|
||||
disabledProviders: ["two"],
|
||||
})
|
||||
|
||||
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
|
||||
})
|
||||
|
||||
test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
|
||||
if (request.method === "GET") return new Response(null, { status: 404 })
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
|
||||
await setupResult.backend.common.permissions.reply({
|
||||
sessionID: "ses_1",
|
||||
requestID: "per_1",
|
||||
reply: "once",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
await setupResult.backend.common.questions.reject({
|
||||
sessionID: "ses_1",
|
||||
requestID: "que_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const transport = setupResult.backend.capabilities.ptyTransport
|
||||
const ticket = await transport?.connectToken({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const exists = await transport?.exists({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
expect(ticket).toEqual({ status: 405, ticket: undefined })
|
||||
expect(exists).toBe(false)
|
||||
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
])
|
||||
expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
|
||||
|
||||
const fallback = transport?.connectURL({
|
||||
ptyID: "pty/1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
cursor: 12,
|
||||
})
|
||||
expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
|
||||
expect(fallback?.protocol).toBe("ws:")
|
||||
expect(fallback?.searchParams.get("directory")).toBe("/explicit")
|
||||
expect(fallback?.searchParams.get("workspace")).toBe("workspace")
|
||||
expect(fallback?.searchParams.get("cursor")).toBe("12")
|
||||
expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||
|
||||
const ticketURL = transport?.connectURL({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit" },
|
||||
cursor: -1,
|
||||
ticket: "ticket value",
|
||||
})
|
||||
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves same-origin auth-token policy for PTY URLs", () => {
|
||||
const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
|
||||
const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
|
||||
.ptyTransport
|
||||
const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
|
||||
|
||||
expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
|
||||
expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,745 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
|
||||
import { createV2Backend } from "./backend-v2"
|
||||
|
||||
function setup(
|
||||
respond: (request: Request) => Response | Promise<Response>,
|
||||
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return respond(request)
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
|
||||
return {
|
||||
requests,
|
||||
backend: createV2Backend(
|
||||
client,
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
fetch,
|
||||
username: "user",
|
||||
password: transport && "password" in transport ? transport.password : "secret",
|
||||
sameOrigin: transport?.sameOrigin ?? false,
|
||||
authToken: transport?.authToken ?? false,
|
||||
},
|
||||
{ directory: "/default", workspaceID: "default-workspace" },
|
||||
client,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown) {
|
||||
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_1",
|
||||
projectID: "project",
|
||||
cost: 1.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
time: { created: 10, updated: 20 },
|
||||
title: "Session",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
}
|
||||
|
||||
describe("createV2Backend", () => {
|
||||
test("uses no legacy endpoints for bootstrap and common reads", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/health") return json({ healthy: true, version: "v2" })
|
||||
if (path === "/api/location")
|
||||
return json({
|
||||
directory: "/default",
|
||||
workspaceID: "default-workspace",
|
||||
project: { id: "project", directory: "/default" },
|
||||
})
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
setupResult.backend.common.health.get(),
|
||||
setupResult.backend.common.projects.current(),
|
||||
setupResult.backend.common.catalog.providers(),
|
||||
setupResult.backend.common.catalog.agents(),
|
||||
setupResult.backend.common.commands.list(),
|
||||
setupResult.backend.common.references.list(),
|
||||
setupResult.backend.common.files.list({}),
|
||||
setupResult.backend.common.files.find({ query: "src" }),
|
||||
setupResult.backend.common.permissions.pending(),
|
||||
setupResult.backend.common.questions.pending(),
|
||||
setupResult.backend.common.pty.list(),
|
||||
])
|
||||
|
||||
const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
|
||||
expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
|
||||
expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes session pages and preserves location precedence", async () => {
|
||||
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
limit: 10,
|
||||
cursor: "cursor",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "ses_1",
|
||||
slug: "ses_1",
|
||||
version: "",
|
||||
parentID: undefined,
|
||||
projectID: "project",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
directory: "/repo",
|
||||
workspaceID: "workspace",
|
||||
title: "Session",
|
||||
cost: 1.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
time: { created: 10, updated: 20 },
|
||||
revert: undefined,
|
||||
},
|
||||
],
|
||||
newer: "before",
|
||||
older: "after",
|
||||
})
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/api/session")
|
||||
expect(url.searchParams.get("directory")).toBe("/explicit")
|
||||
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
|
||||
expect(url.searchParams.has("parentID")).toBe(false)
|
||||
expect(url.searchParams.get("cursor")).toBe("cursor")
|
||||
})
|
||||
|
||||
test("paginates native sessions until roots:true contains only the requested roots", async () => {
|
||||
const child = { ...session, id: "child", parentID: "root_1" }
|
||||
const root1 = { ...session, id: "root_1" }
|
||||
const root2 = { ...session, id: "root_2" }
|
||||
const setupResult = setup((request) => {
|
||||
const cursor = new URL(request.url).searchParams.get("cursor")
|
||||
if (!cursor) return json({ data: [child], cursor: { next: "one" } })
|
||||
if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
|
||||
return json({ data: [root2], cursor: { next: "three" } })
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
|
||||
|
||||
expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
|
||||
expect(result.older).toBe("three")
|
||||
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
|
||||
})
|
||||
|
||||
test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
|
||||
return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
await setupResult.backend.common.files.list({ path: "dir" })
|
||||
const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
|
||||
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/api/fs/list")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/default")
|
||||
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||
expect(content).toEqual({
|
||||
bytes: Uint8Array.from([0, 1, 255]),
|
||||
kind: "binary",
|
||||
mimeType: "application/octet-stream",
|
||||
})
|
||||
const readURL = new URL(setupResult.requests[1].url)
|
||||
expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
|
||||
expect(readURL.searchParams.get("location[directory]")).toBe("/default")
|
||||
expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||
expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
|
||||
expect(setupResult.backend.version).toBe("v2")
|
||||
expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
|
||||
"integrationsV2",
|
||||
"projectCopiesV2",
|
||||
"ptyTransport",
|
||||
"savedPermissionsV2",
|
||||
"sessionExtrasV2",
|
||||
])
|
||||
expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.projectList).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.vcs).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.mcp).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
|
||||
)
|
||||
const transport = setupResult.backend.capabilities.ptyTransport
|
||||
|
||||
const ticket = await transport?.connectToken({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const exists = await transport?.exists({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
expect(ticket).toEqual({ status: 403, ticket: undefined })
|
||||
expect(exists).toBe(false)
|
||||
expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
|
||||
const tokenURL = new URL(setupResult.requests[0].url)
|
||||
const existsURL = new URL(setupResult.requests[1].url)
|
||||
expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
|
||||
expect(existsURL.pathname).toBe("/api/pty/pty_1")
|
||||
expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
|
||||
expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
|
||||
expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
|
||||
expect(() =>
|
||||
transport?.connectURL({
|
||||
ptyID: "pty/1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
cursor: 8,
|
||||
}),
|
||||
).toThrow("require a ticket")
|
||||
|
||||
const ticketURL = transport?.connectURL({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit" },
|
||||
cursor: 0,
|
||||
ticket: "ticket value",
|
||||
})
|
||||
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
|
||||
test("allows ticketless same-origin native PTY URLs without credential queries", () => {
|
||||
const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
|
||||
.ptyTransport
|
||||
const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
|
||||
|
||||
expect(url?.searchParams.has("auth_token")).toBe(false)
|
||||
expect(url?.pathname).toBe("/api/pty/pty_1/connect")
|
||||
})
|
||||
|
||||
test("preserves native provider integration IDs", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/provider")
|
||||
return json({
|
||||
location: { directory: "/default" },
|
||||
data: [
|
||||
{
|
||||
id: "provider",
|
||||
integrationID: "integration",
|
||||
name: "Provider",
|
||||
api: { type: "native", settings: {} },
|
||||
request: { headers: {}, body: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.catalog.providers()
|
||||
|
||||
expect(result.providers.get("provider")?.integrationID).toBe("integration")
|
||||
})
|
||||
|
||||
test("preserves integration connection kinds", async () => {
|
||||
const setupResult = setup(() =>
|
||||
json({
|
||||
location: { directory: "/default" },
|
||||
data: [
|
||||
{
|
||||
id: "integration",
|
||||
name: "Integration",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "credential", label: "Saved" },
|
||||
{ type: "environment", name: "TOKEN" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
|
||||
expect(integrations).toEqual([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Integration",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ id: "credential", label: "Saved", kind: "credential" },
|
||||
{ id: "TOKEN", label: "TOKEN", kind: "environment" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
|
||||
})
|
||||
|
||||
test("switches prompt selection before admission", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.url.endsWith("/prompt")
|
||||
? json({
|
||||
data: {
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
: new Response(null, { status: 204 }),
|
||||
)
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
|
||||
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/model",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
expect(await setupResult.requests[2].json()).toEqual({
|
||||
id: "msg_1",
|
||||
prompt: {
|
||||
text: "hello",
|
||||
files: [
|
||||
{
|
||||
uri: "data:text/plain;base64,aGk=",
|
||||
mime: "application/octet-stream",
|
||||
name: "hi.txt",
|
||||
source: { start: 0, end: 2, text: "hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes selection and prompt admission per session", async () => {
|
||||
const firstPrompt = Promise.withResolvers<void>()
|
||||
const firstPromptStarted = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
|
||||
firstPromptStarted.resolve()
|
||||
await firstPrompt.promise
|
||||
}
|
||||
return path.endsWith("/prompt")
|
||||
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||
: new Response(null, { status: 204 })
|
||||
})
|
||||
|
||||
const first = setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "first",
|
||||
selection: { agent: "build" },
|
||||
})
|
||||
await firstPromptStarted.promise
|
||||
const second = setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_2",
|
||||
text: "second",
|
||||
selection: { agent: "plan" },
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
firstPrompt.resolve()
|
||||
await Promise.all([first, second])
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not switch a selection already present in session state", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.url.endsWith("/prompt")
|
||||
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||
: json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
|
||||
)
|
||||
await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: { agent: "build", model: { id: "model", providerID: "provider" } },
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
})
|
||||
|
||||
test("requests file staging when selected revert files are present", async () => {
|
||||
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
|
||||
|
||||
await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
files: ["a.txt"],
|
||||
})
|
||||
|
||||
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
|
||||
})
|
||||
|
||||
test("maps ordered app prompt parts to the native prompt shape", async () => {
|
||||
const setupResult = setup(() =>
|
||||
json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
|
||||
)
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "visible",
|
||||
parts: [
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
],
|
||||
})
|
||||
|
||||
expect(await setupResult.requests[0].json()).toEqual({
|
||||
id: "msg_1",
|
||||
prompt: {
|
||||
text: "visiblenote",
|
||||
files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
|
||||
agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("commits a staged revert through the V2 capability", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
|
||||
|
||||
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
|
||||
})
|
||||
|
||||
test("normalizes current session activity events without projection refresh", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_started",
|
||||
type: "session.next.step.started",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toMatchObject({
|
||||
location: { directory: "/repo" },
|
||||
event: {
|
||||
type: "session.activity",
|
||||
sessionID: "ses_1",
|
||||
activity: { type: "running" },
|
||||
},
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("maps completed native steps to idle activity", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_ended",
|
||||
type: "session.next.step.ended",
|
||||
data: {
|
||||
timestamp: 2,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
finish: "stop",
|
||||
cost: 1,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value?.event).toEqual({
|
||||
type: "session.activity",
|
||||
sessionID: "ses_1",
|
||||
activity: { type: "idle" },
|
||||
})
|
||||
})
|
||||
|
||||
test("projects failed steps as completed assistant errors and clears running", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "start",
|
||||
type: "session.next.step.started",
|
||||
data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
|
||||
},
|
||||
{ id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
|
||||
{ id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
|
||||
{ id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
|
||||
expect((await iterator.next()).value?.event).toMatchObject({
|
||||
type: "session.activity",
|
||||
activity: { type: "idle" },
|
||||
item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not infer assistant parents across history pages or direct fetches", async () => {
|
||||
const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
|
||||
const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
|
||||
const setupResult = setup((request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
|
||||
if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
|
||||
return json({ data: [assistant], cursor: { next: "older" } })
|
||||
})
|
||||
|
||||
const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
|
||||
await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
|
||||
const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
|
||||
|
||||
expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
|
||||
expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
|
||||
})
|
||||
|
||||
test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
|
||||
const events = [
|
||||
{ id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
|
||||
{ id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
|
||||
{ id: "integration", type: "integration.updated", data: {} },
|
||||
{ id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
|
||||
expect((await iterator.next()).value?.event.type).toBe("unknown")
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("normalizes native session create, update, and delete lifecycle events", async () => {
|
||||
const info = {
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
version: "2",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const events = [
|
||||
{ id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
|
||||
{ id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
|
||||
{ id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
|
||||
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
|
||||
})
|
||||
|
||||
test("normalizes durable session log events through the event mapper", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_started",
|
||||
type: "session.next.step.started",
|
||||
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
const capability = setupResult.backend.capabilities.sessionExtrasV2
|
||||
if (!capability) throw new Error("Missing V2 session capability")
|
||||
|
||||
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toMatchObject({
|
||||
sequence: 7,
|
||||
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
|
||||
})
|
||||
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
|
||||
})
|
||||
|
||||
test("normalizes native todo and part removal events", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "evt_todo",
|
||||
type: "todo.updated",
|
||||
data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||
},
|
||||
{
|
||||
id: "evt_removed",
|
||||
type: "message.part.removed",
|
||||
data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
|
||||
},
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "todo.updated",
|
||||
sessionID: "ses_1",
|
||||
todos: [{ content: "Ship", status: "pending", priority: "high" }],
|
||||
})
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "timeline.part.removed",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
contentID: "part_1",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves streamed V2 timeline deltas without projection refresh", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
if (new URL(request.url).pathname === "/api/event") {
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "session.next.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
textID: "text_1",
|
||||
delta: "hi",
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return json({
|
||||
data: {
|
||||
id: "msg_1",
|
||||
type: "assistant",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", id: "text_1", text: "hello" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toEqual({
|
||||
location: { directory: "/repo" },
|
||||
event: {
|
||||
type: "timeline.delta",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
contentID: "text_1",
|
||||
field: "text",
|
||||
delta: "hi",
|
||||
},
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("projects native fragment starts without blocking the event stream on HTTP", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "evt_step",
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_text",
|
||||
type: "session.next.text.started",
|
||||
data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
|
||||
},
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
await iterator.next()
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "timeline.content.updated",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
content: { type: "text", id: "text_1", text: "" },
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import type { AppClient, Capabilities, CommonClient } from "./backend"
|
||||
|
||||
type PartialApi<T> = {
|
||||
[K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
|
||||
}
|
||||
|
||||
export function createAppClient(input: {
|
||||
version?: AppClient["version"]
|
||||
common?: PartialApi<CommonClient>
|
||||
capabilities?: Capabilities
|
||||
} = {}): AppClient {
|
||||
const unsupported = async () => {
|
||||
throw new Error("Backend fixture method is not configured")
|
||||
}
|
||||
const defaults: CommonClient = {
|
||||
health: { get: unsupported },
|
||||
projects: { current: unsupported },
|
||||
catalog: { providers: unsupported, agents: unsupported },
|
||||
commands: { list: unsupported },
|
||||
references: { list: unsupported },
|
||||
sessions: {
|
||||
list: unsupported,
|
||||
create: unsupported,
|
||||
get: unsupported,
|
||||
interrupt: unsupported,
|
||||
activity: unsupported,
|
||||
history: unsupported,
|
||||
message: unsupported,
|
||||
prompt: unsupported,
|
||||
},
|
||||
files: { list: unsupported, find: unsupported, read: unsupported },
|
||||
permissions: { pending: unsupported, reply: unsupported },
|
||||
questions: { pending: unsupported, reply: unsupported, reject: unsupported },
|
||||
pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
|
||||
events: {
|
||||
async *subscribe() {},
|
||||
},
|
||||
}
|
||||
return {
|
||||
version: input.version ?? "v1",
|
||||
capabilities: input.capabilities ?? {},
|
||||
common: {
|
||||
health: { ...defaults.health, ...input.common?.health },
|
||||
projects: { ...defaults.projects, ...input.common?.projects },
|
||||
catalog: { ...defaults.catalog, ...input.common?.catalog },
|
||||
commands: { ...defaults.commands, ...input.common?.commands },
|
||||
references: { ...defaults.references, ...input.common?.references },
|
||||
sessions: { ...defaults.sessions, ...input.common?.sessions },
|
||||
files: { ...defaults.files, ...input.common?.files },
|
||||
permissions: { ...defaults.permissions, ...input.common?.permissions },
|
||||
questions: { ...defaults.questions, ...input.common?.questions },
|
||||
pty: { ...defaults.pty, ...input.common?.pty },
|
||||
events: { ...defaults.events, ...input.common?.events },
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage, AppPart, AppSession } from "./backend"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
@@ -23,8 +23,8 @@ export const createDirSyncContext = (
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
@@ -72,7 +72,7 @@ export const createDirSyncContext = (
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
},
|
||||
session: {
|
||||
remember(session: Session) {
|
||||
remember(session: AppSession) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
@@ -81,7 +81,7 @@ export const createDirSyncContext = (
|
||||
if (session?.directory === directory) return session
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
|
||||
serverSync.session.optimistic.add(input)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
@@ -91,7 +91,7 @@ export const createDirSyncContext = (
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
parts: AppPart[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
@@ -110,7 +110,7 @@ export const createDirSyncContext = (
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
await serverSync.session.sync(sessionID, { ...options, location: { directory } })
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
@@ -121,17 +121,25 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
const backend = await serverSDK.backend
|
||||
const response = await backend.common.sessions.list({
|
||||
location: { directory },
|
||||
roots: true,
|
||||
limit: count,
|
||||
cursor: sessionPage.cursor,
|
||||
})
|
||||
const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
setSessionPage({ cursor: response.older, complete: !response.older })
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
more: createMemo(() => !sessionPage.complete),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
const backend = await serverSDK.backend
|
||||
const capability = backend.capabilities.sessionExtrasV1
|
||||
if (!capability) throw new Error("Server does not support session archiving")
|
||||
await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const tree = createFileTreeStore({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
list: async (dir) =>
|
||||
[...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
|
||||
(node) => ({
|
||||
name: node.name ?? getFilename(node.path),
|
||||
path: node.path,
|
||||
absolute: node.absolute ?? node.path,
|
||||
type: node.type,
|
||||
ignored: node.ignored,
|
||||
}),
|
||||
),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
.backend.then(async (client) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
const content: FileState["content"] = client.capabilities.decoratedFiles
|
||||
? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
|
||||
: await client.common.files.read({ location: { directory }, path: file }).then((value) => {
|
||||
if (value.kind !== "text") return
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: new TextDecoder().decode(value.bytes),
|
||||
mimeType: value.mimeType,
|
||||
}
|
||||
})
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
@@ -205,9 +219,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||
.backend.then((client) =>
|
||||
client.common.files.find(
|
||||
{
|
||||
location: { directory: scope() },
|
||||
query,
|
||||
type: dirs === "true" ? undefined : "file",
|
||||
limit: options?.limit,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
(items) => items.map((item) => path.normalize(item.path)),
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||
|
||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "../backend"
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "../backend"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
properties: unknown
|
||||
path?: string
|
||||
change?: string
|
||||
properties?: unknown
|
||||
}
|
||||
|
||||
type WatcherOps = {
|
||||
@@ -16,11 +18,11 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "filesystem.changed") return
|
||||
if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||
const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
|
||||
const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
|
||||
if (!rawPath) return
|
||||
if (!kind) return
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
|
||||
import { createAppClient } from "../backend.test-fixture"
|
||||
import type { ProviderStore } from "./types"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
@@ -30,6 +31,7 @@ function directoryState() {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
@@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => {
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
backend: {
|
||||
version: "v1",
|
||||
capabilities: {
|
||||
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
|
||||
vcsInfo: { get: async () => ({}) },
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
common: {
|
||||
catalog: {
|
||||
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
|
||||
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
|
||||
},
|
||||
sessions: { activity: async () => ({}) },
|
||||
projects: { current: async () => ({ id: "project", directory: "/project" }) },
|
||||
commands: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return []
|
||||
},
|
||||
},
|
||||
permissions: { pending: async () => [] },
|
||||
questions: { pending: async () => [] },
|
||||
references: { list: async () => [] },
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
} as unknown as AppClient,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => {
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
const backend = createAppClient({
|
||||
version: "v1",
|
||||
capabilities: {
|
||||
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
|
||||
vcsInfo: { get: async () => ({}) },
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
common: {
|
||||
catalog: {
|
||||
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
|
||||
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
|
||||
},
|
||||
sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
|
||||
projects: { current: async () => ({ id: "project", directory: "/project" }) },
|
||||
commands: { list: async () => [] },
|
||||
permissions: { pending: async () => [] },
|
||||
questions: { pending: async () => [] },
|
||||
references: { list: async () => [] },
|
||||
},
|
||||
})
|
||||
const session = createServerSession(backend)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
@@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => {
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
backend,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => {
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as OpencodeClient
|
||||
const backend = Promise.resolve({} as AppClient)
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
null,
|
||||
"providers",
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
AppClient,
|
||||
AppConfig,
|
||||
AppPathInfo,
|
||||
AppPermissionRequest,
|
||||
AppProject,
|
||||
AppProviderAuthResponse,
|
||||
AppQuestionRequest,
|
||||
AppReference,
|
||||
AppSession,
|
||||
ProviderCatalog,
|
||||
} from "../backend"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ProviderStore, State, StoreConfig, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { cmp } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
path: AppPathInfo
|
||||
project: AppProject[]
|
||||
provider: ProviderStore
|
||||
provider_auth: AppProviderAuthResponse
|
||||
config: StoreConfig
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
@@ -82,29 +82,31 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}),
|
||||
})
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadProjectsQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
sdk.project.list().then((x) => {
|
||||
return (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
backend
|
||||
.then((client) => client.capabilities.projectList?.list() ?? [])
|
||||
.then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
backend: Promise<AppClient>
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.backend))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
@@ -140,11 +142,11 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
function projectID(directory: string, projects: readonly AppProject[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
@@ -162,44 +164,54 @@ function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
backend: AppClient
|
||||
location: { directory: string }
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
|
||||
if (!x?.id) return
|
||||
mergeSession(input.setStore, x)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
||||
queryFn: () =>
|
||||
retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))),
|
||||
})
|
||||
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])),
|
||||
),
|
||||
})
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
queryOptions<Path>({
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
|
||||
queryOptions<AppPathInfo>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||
queryFn: async () => {
|
||||
const client = await backend
|
||||
return retry(
|
||||
() => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||
queryOptions<readonly AppReference[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||
queryFn: () =>
|
||||
retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
backend: AppClient
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
config: StoreConfig
|
||||
path: AppPathInfo
|
||||
project: readonly AppProject[]
|
||||
provider: ProviderStore
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
@@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
retry(
|
||||
() =>
|
||||
input.backend.capabilities.configuration
|
||||
?.get(location(input.directory))
|
||||
.then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
input.setStore("session_status", mapActivity(statuses))
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
const mapped = mapActivity(statuses)
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (mapped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
for (const [sessionID, status] of Object.entries(mapped)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
(() =>
|
||||
retry(() => input.backend.common.projects.current(location(input.directory))).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
(input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
|
||||
(data) => {
|
||||
const next = data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
},
|
||||
),
|
||||
),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) =>
|
||||
input.setStore("command", reconcile(commands)),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
input.backend.common.permissions.pending(location(input.directory)).then((data) => {
|
||||
const permissions = data.map(
|
||||
(perm): AppPermissionRequest => perm,
|
||||
)
|
||||
const ids = permissions.map((perm) => perm.sessionID)
|
||||
const grouped = groupBySession(permissions)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({
|
||||
ids,
|
||||
store: input.store,
|
||||
setStore: input.setStore,
|
||||
backend: input.backend,
|
||||
location: { directory: input.directory },
|
||||
})
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
@@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
input.backend.common.questions.pending(location(input.directory)).then((data) => {
|
||||
const questions = data.map(
|
||||
(question): AppQuestionRequest => question,
|
||||
)
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(questions)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({
|
||||
ids,
|
||||
store: input.store,
|
||||
setStore: input.setStore,
|
||||
backend: input.backend,
|
||||
location: { directory: input.directory },
|
||||
})
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
@@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
@@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: {
|
||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
})()
|
||||
}
|
||||
|
||||
function location(directory: string | null) {
|
||||
return directory === null ? undefined : { location: { directory } }
|
||||
}
|
||||
|
||||
function emptyPath(directory: string | null): AppPathInfo {
|
||||
return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" }
|
||||
}
|
||||
|
||||
function toProviderStore(input: ProviderCatalog): ProviderStore {
|
||||
return {
|
||||
all: input.providers,
|
||||
connected: [...input.connected],
|
||||
default: { ...input.defaults },
|
||||
}
|
||||
}
|
||||
|
||||
function mapActivity(input: Awaited<ReturnType<AppClient["common"]["sessions"]["activity"]>>) {
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { State } from "./types"
|
||||
import type { ProviderStore, State } from "./types"
|
||||
import type { QueryOptionsApi } from "../server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -16,7 +15,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
||||
]
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
|
||||
|
||||
const queryOptionsApi = {
|
||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppVcsInfo } from "../backend"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type IconCache,
|
||||
type MetaCache,
|
||||
type ProjectMeta,
|
||||
type ProviderStore,
|
||||
type State,
|
||||
type VcsCache,
|
||||
} from "./types"
|
||||
@@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { QueryOptionsApi } from "../server-sync"
|
||||
import { directoryKey, type DirectoryKey } from "./utils"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
@@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
global: {
|
||||
provider: NormalizedProviderListResponse
|
||||
provider: ProviderStore
|
||||
}
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
@@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
|
||||
const vcs = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
createStore({ value: undefined as AppVcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||
@@ -223,6 +223,7 @@ export function createChildStoreManager(input: {
|
||||
return (type ?? "idle") !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AppMessage as Message,
|
||||
AppPart as Part,
|
||||
AppPermissionRequest as PermissionRequest,
|
||||
AppProject as Project,
|
||||
AppQuestionRequest as QuestionRequest,
|
||||
AppSession as Session,
|
||||
} from "../backend"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
@@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
id,
|
||||
sessionID,
|
||||
permission: title,
|
||||
action: title,
|
||||
patterns: ["*"],
|
||||
resources: ["*"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
}) as PermissionRequest
|
||||
@@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
value: { branch: "main", defaultBranch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
@@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
AppEvent,
|
||||
AppFileDiff,
|
||||
AppMessage,
|
||||
AppPart,
|
||||
AppPermissionRequest,
|
||||
AppProject,
|
||||
AppQuestionRequest,
|
||||
AppSession,
|
||||
AppTodo,
|
||||
SessionActivity,
|
||||
} from "../backend"
|
||||
import { timelineMessage } from "../backend"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([
|
||||
"question.rejected",
|
||||
])
|
||||
|
||||
type LegacyEvent = { type: string; properties?: unknown }
|
||||
type DirectoryLegacyEvent =
|
||||
| { type: string; properties: unknown }
|
||||
| { type: "server.instance.disposed"; properties?: undefined }
|
||||
|
||||
function legacyEvent(event: AppEvent): LegacyEvent | undefined {
|
||||
if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event }
|
||||
if (event.type === "session.created" || event.type === "session.updated")
|
||||
return { type: event.type, properties: { info: event.session } }
|
||||
if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } }
|
||||
if (event.type === "session.activity")
|
||||
return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }
|
||||
if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event }
|
||||
if (event.type === "timeline.updated") {
|
||||
const message = timelineMessage(event.item)
|
||||
return message ? { type: "message.updated", properties: { info: message } } : undefined
|
||||
}
|
||||
if (event.type === "timeline.content.updated") return undefined
|
||||
if (event.type === "timeline.removed")
|
||||
return {
|
||||
type: "message.removed",
|
||||
properties: { sessionID: event.sessionID, messageID: event.itemID },
|
||||
}
|
||||
if (event.type === "timeline.part.removed")
|
||||
return {
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID },
|
||||
}
|
||||
if (event.type === "timeline.delta")
|
||||
return {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: event.sessionID,
|
||||
messageID: event.itemID,
|
||||
partID: event.contentID,
|
||||
field: event.field,
|
||||
delta: event.delta,
|
||||
},
|
||||
}
|
||||
if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request }
|
||||
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected")
|
||||
return { type: event.type, properties: event }
|
||||
if (event.type === "question.requested") return { type: "question.asked", properties: event.request }
|
||||
return { type: event.type, properties: event }
|
||||
}
|
||||
|
||||
function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) {
|
||||
if ("properties" in event) return event
|
||||
if (event.type === "server.instance.disposed") return event
|
||||
return legacyEvent(event)
|
||||
}
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||
event: AppEvent | LegacyEvent
|
||||
project: AppProject[]
|
||||
setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||
if (
|
||||
input.event.type === "server.disposed" ||
|
||||
input.event.type === "global.disposed" ||
|
||||
input.event.type === "server.connected" ||
|
||||
input.event.type === "provider.updated"
|
||||
) {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject)
|
||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setGlobalProject(
|
||||
@@ -60,7 +120,11 @@ export function applyGlobalEvent(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
|
||||
function cleanupSessionCaches(
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -69,7 +133,12 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: AppSession[],
|
||||
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
@@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
|
||||
}
|
||||
|
||||
export function applyDirectoryEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
event: AppEvent | DirectoryLegacyEvent
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
push: (directory: string) => void
|
||||
@@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: {
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
sessionContent?: boolean
|
||||
permission?: State["permission"]
|
||||
}) {
|
||||
const event = input.event
|
||||
if (input.event.type === "server.instance.disposed") {
|
||||
input.push(input.directory)
|
||||
return
|
||||
}
|
||||
const event = normalizeDirectoryEvent(input.event)
|
||||
if (!event) return
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
@@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: {
|
||||
return
|
||||
}
|
||||
case "session.created": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const info = (event.properties as { info: AppSession }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
@@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const info = (event.properties as { info: AppSession }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||
@@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
const properties = event.properties as { sessionID?: string; info?: { id: string } }
|
||||
const sessionID = properties.sessionID ?? properties.info?.id ?? ""
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
const info = result.found ? input.store.session[result.index] : undefined
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
@@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id)
|
||||
if (info.parentID) break
|
||||
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||
if (info?.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: AppTodo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
const props = event.properties as { sessionID: string; status: SessionActivity }
|
||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = clean((event.properties as { info: Message }).info)
|
||||
const info = clean((event.properties as { info: AppMessage }).info)
|
||||
const messages = input.store.message[info.sessionID]
|
||||
if (!messages) {
|
||||
input.setStore("message", info.sessionID, [info])
|
||||
@@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
const part = (event.properties as { part: AppPart }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
@@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permission = event.properties as AppPermissionRequest
|
||||
const permissions = input.store.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
input.setStore("permission", permission.sessionID, [permission])
|
||||
@@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const question = event.properties as AppQuestionRequest
|
||||
const questions = input.store.question[question.sessionID]
|
||||
if (!questions) {
|
||||
input.setStore("question", question.sessionID, [question])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMcpStatus } from "../backend"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
status: AppMcpStatus["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
@@ -9,6 +9,7 @@ export async function toggleMcp(input: {
|
||||
}) {
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
pending: async () => {},
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
AppMessage as Message,
|
||||
AppPart as Part,
|
||||
AppPermissionRequest as PermissionRequest,
|
||||
AppQuestionRequest as QuestionRequest,
|
||||
AppFileDiff as SnapshotFileDiff,
|
||||
SessionActivity as SessionStatus,
|
||||
} from "../backend"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -32,7 +32,8 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, unknown>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -41,6 +42,7 @@ describe("app session cache", () => {
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: {},
|
||||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
@@ -63,7 +65,8 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, unknown>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -72,6 +75,7 @@ describe("app session cache", () => {
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
@@ -85,6 +89,21 @@ describe("app session cache", () => {
|
||||
expect(store.part[m.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dropSessionCaches accepts V2 stores without todo caches", () => {
|
||||
const store = {
|
||||
session_status: { ses_1: { type: "running" } },
|
||||
session_diff: { ses_1: [] },
|
||||
message: { ses_1: [] },
|
||||
part: {},
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
session_status: Record<string, unknown>
|
||||
session_diff: Record<string, unknown>
|
||||
todo?: Record<string, unknown>
|
||||
message: Record<string, readonly { id: string }[] | undefined>
|
||||
part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
|
||||
permission: Record<string, unknown>
|
||||
question: Record<string, unknown>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
@@ -35,6 +27,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.todo?.[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[sessionID]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppSession as Session } from "../backend"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||
@@ -43,7 +43,7 @@ describe("trimSessions", () => {
|
||||
const result = trimSessions(list, {
|
||||
limit: 2,
|
||||
permission: {
|
||||
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
|
||||
"child-kept-by-permission": [{ id: "perm-1" }],
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppSession } from "../backend"
|
||||
import { cmp } from "./utils"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export function sessionUpdatedAt(session: Session) {
|
||||
export function sessionUpdatedAt(session: AppSession) {
|
||||
return session.time.updated ?? session.time.created
|
||||
}
|
||||
|
||||
export function compareSessionRecent(a: Session, b: Session) {
|
||||
export function compareSessionRecent(a: AppSession, b: AppSession) {
|
||||
const aUpdated = sessionUpdatedAt(a)
|
||||
const bUpdated = sessionUpdatedAt(b)
|
||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||
return cmp(a.id, b.id)
|
||||
}
|
||||
|
||||
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
||||
if (limit <= 0) return [] as Session[]
|
||||
const selected: Session[] = []
|
||||
export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
|
||||
if (limit <= 0) return [] as AppSession[]
|
||||
const selected: AppSession[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
if (!session?.id) continue
|
||||
@@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
|
||||
}
|
||||
|
||||
export function trimSessions(
|
||||
input: Session[],
|
||||
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
||||
input: AppSession[],
|
||||
options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
|
||||
) {
|
||||
const limit = Math.max(0, options.limit)
|
||||
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
AppMessage,
|
||||
AppPart,
|
||||
AppPermissionRequest,
|
||||
AppQuestionRequest,
|
||||
AppSession,
|
||||
AppFileDiff,
|
||||
AppTodo,
|
||||
AppCommand,
|
||||
AppConfig,
|
||||
AppAgent,
|
||||
AppLspStatus,
|
||||
AppMcpResource,
|
||||
AppMcpStatus,
|
||||
AppPathInfo,
|
||||
AppProvider,
|
||||
AppReference,
|
||||
AppVcsInfo,
|
||||
SessionActivity,
|
||||
} from "../backend"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -31,49 +32,62 @@ export type ProjectMeta = {
|
||||
}
|
||||
}
|
||||
|
||||
export type StoreConfig = {
|
||||
-readonly [Key in keyof AppConfig]: AppConfig[Key]
|
||||
}
|
||||
|
||||
export type ProviderStore = {
|
||||
all: ReadonlyMap<string, AppProvider>
|
||||
connected: readonly string[]
|
||||
default: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
reference: ReferenceInfo[]
|
||||
agent: AppAgent[]
|
||||
command: readonly AppCommand[]
|
||||
reference: readonly AppReference[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: NormalizedProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
provider: ProviderStore
|
||||
config: StoreConfig
|
||||
path: AppPathInfo
|
||||
session: AppSession[]
|
||||
sessionTotal: number
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
[sessionID: string]: SessionActivity
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: AppFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: AppTodo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
[sessionID: string]: AppPermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
[sessionID: string]: AppQuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
[name: string]: AppMcpStatus
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
[key: string]: AppMcpResource
|
||||
}
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
lsp: readonly AppLspStatus[]
|
||||
vcs: AppVcsInfo | undefined
|
||||
limit: number
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
[sessionID: string]: AppMessage[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
[messageID: string]: AppPart[]
|
||||
}
|
||||
part_text_accum_delta: {
|
||||
[partID: string]: string
|
||||
@@ -81,8 +95,8 @@ export type State = {
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
store: Store<{ value: VcsInfo | undefined }>
|
||||
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
||||
store: Store<{ value: AppVcsInfo | undefined }>
|
||||
setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
@@ -127,11 +141,11 @@ export type DisposeCheck = {
|
||||
export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }>
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
data?: Session[]
|
||||
data?: AppSession[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { directoryKey, normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
import { directoryKey } from "./utils"
|
||||
|
||||
describe("directoryKey", () => {
|
||||
test("normalizes slashes", () => {
|
||||
|
||||
@@ -1,43 +1,8 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { AppProject as Project } from "../backend"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeProject(project: Project) {
|
||||
if (!project.icon?.url && !project.icon?.override) return project
|
||||
return {
|
||||
|
||||
@@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth, useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { usePlatform } from "./platform"
|
||||
import { backendIdentity, createBackendForServer } from "./backend-client"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const serverHealth = useServerHealth(
|
||||
() => server.list,
|
||||
() => true,
|
||||
checkServerHealth,
|
||||
)
|
||||
const [store, setStore] = createStore({
|
||||
settings: {
|
||||
@@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
|
||||
const serverCtxs = new Map<
|
||||
ServerConnection.Key,
|
||||
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
|
||||
{ dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
|
||||
>()
|
||||
|
||||
const owner = getOwner()
|
||||
@@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
const existing = serverCtxs.get(key)
|
||||
if (existing) return existing.serverCtx
|
||||
const identity = backendIdentity(conn)
|
||||
if (existing?.identity === identity) return existing.serverCtx
|
||||
if (existing) {
|
||||
existing.dispose()
|
||||
serverCtxs.delete(key)
|
||||
}
|
||||
const root = createRoot((dispose) => {
|
||||
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
|
||||
return { dispose, serverCtx }
|
||||
const serverCtx = createServerCtx(
|
||||
conn,
|
||||
server.scope(key),
|
||||
server.projects.forServer(key),
|
||||
createBackendForServer({
|
||||
server: conn,
|
||||
browserUrl: location.href,
|
||||
fetch: platform.fetch ?? globalThis.fetch,
|
||||
eventFetch: eventStreamFetch(conn.http.url, platform.fetch),
|
||||
health: checkServerHealth(conn.http),
|
||||
}),
|
||||
)
|
||||
return { dispose, identity, serverCtx }
|
||||
}, owner as any)
|
||||
serverCtxs.set(key, root)
|
||||
return root.serverCtx
|
||||
@@ -97,6 +118,7 @@ function createServerCtx(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
backend: ReturnType<typeof createBackendForServer>,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -107,7 +129,7 @@ function createServerCtx(
|
||||
},
|
||||
},
|
||||
})
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const sdk = createServerSdkContext(conn, scope, backend)
|
||||
const sync = createServerSyncContext(sdk)
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
@@ -141,6 +163,7 @@ function createServerCtx(
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
return {
|
||||
backend,
|
||||
queryClient,
|
||||
sdk,
|
||||
sync,
|
||||
@@ -153,6 +176,18 @@ function createServerCtx(
|
||||
}
|
||||
}
|
||||
|
||||
function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) {
|
||||
if (!fetch) return globalThis.fetch
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"
|
||||
if (parsed.protocol === "http:" && !loopback) return fetch
|
||||
} catch {
|
||||
return globalThis.fetch
|
||||
}
|
||||
return globalThis.fetch
|
||||
}
|
||||
|
||||
export type ServerCtx = ReturnType<typeof createServerCtx>
|
||||
|
||||
function isLocalHost(url: string) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import type { AppProject } from "./backend"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -72,7 +72,7 @@ type TabHandoff = {
|
||||
at: number
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
export type LocalProject = Partial<AppProject> & { worktree: string; expanded: boolean }
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
@@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setColors(worktree, color)
|
||||
}
|
||||
if (!project.id) continue
|
||||
const projectID = project.id
|
||||
|
||||
const requested = colorRequested.get(worktree)
|
||||
if (requested === color) continue
|
||||
@@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
void serverSdk()
|
||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.backend.then((client) => {
|
||||
const editing = client.capabilities.projectEditing
|
||||
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||
return editing.update({ projectID, location: { directory: worktree }, icon: { color } })
|
||||
})
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
|
||||
@@ -64,10 +64,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const models = useModels()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const list = createMemo(() =>
|
||||
sync()
|
||||
.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
model: item.model && { providerID: item.model.providerID, modelID: item.model.id },
|
||||
variant: item.model?.variant,
|
||||
})),
|
||||
)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
const [saved, setSaved, , savedReady] = persisted(
|
||||
{
|
||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
@@ -375,11 +383,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
ready: savedReady,
|
||||
reset() {
|
||||
setStore({ draft: undefined, promoting: undefined })
|
||||
},
|
||||
promote(dir: string, session: string) {
|
||||
const next = clone(snapshot())
|
||||
promote(dir: string, session: string, state?: State) {
|
||||
const next = clone(state ?? snapshot())
|
||||
if (!next) return
|
||||
const key = handoffKey(serverSDK().scope, dir, session)
|
||||
handoff.set(key, next)
|
||||
@@ -409,3 +418,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
() =>
|
||||
new Map(
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
const parsed = DateTime.fromISO(model.releaseDate ?? "")
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
),
|
||||
@@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
values(),
|
||||
(groups) =>
|
||||
groups.flatMap((g) => {
|
||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||
const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"])
|
||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
@@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: EventSessionError["properties"]["error"]
|
||||
error: unknown
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
@@ -325,8 +324,7 @@ function createServerNotificationState(input: {
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
@@ -353,10 +351,10 @@ function createServerNotificationState(input: {
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
event: { sessionID?: string; error?: unknown },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const sessionID = event.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
@@ -365,7 +363,7 @@ function createServerNotificationState(input: {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
const error = event.error
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
@@ -386,12 +384,13 @@ function createServerNotificationState(input: {
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
if (event.type !== "session.activity" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
if (event.type === "session.activity") {
|
||||
if (event.activity.type !== "idle") return
|
||||
handleSessionIdle(directory, event.sessionID, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
@@ -21,6 +20,8 @@ type PermissionRespondFn = (input: {
|
||||
directory?: string
|
||||
}) => void
|
||||
|
||||
type PermissionRequest = { id: string; sessionID: string }
|
||||
|
||||
function isNonAllowRule(rule: unknown) {
|
||||
if (!rule) return false
|
||||
if (typeof rule === "string") return rule !== "allow"
|
||||
@@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.backend.then((client) =>
|
||||
client.common.permissions.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.permissionID,
|
||||
reply: input.response,
|
||||
location: { directory },
|
||||
}),
|
||||
)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
@@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
if (event?.type !== "permission.requested") return
|
||||
|
||||
const perm = event.properties
|
||||
const perm = event.request
|
||||
if (!shouldAutoRespond(perm, e.name)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
@@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
|
||||
.then((items) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
for (const perm of items) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
@@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
|
||||
.then((items) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
for (const perm of items) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
createRoot((dispose) => {
|
||||
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||
const prompt = createPromptState({ prompt: "hello", model })
|
||||
|
||||
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||
expect(prompt.cursor()).toBe(5)
|
||||
expect(prompt.model.current()).toEqual(model)
|
||||
expect(prompt.model.current()).not.toBe(model)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default prompt without initial values", () => {
|
||||
createRoot((dispose) => {
|
||||
const prompt = createPromptState()
|
||||
|
||||
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||
expect(prompt.cursor()).toBeUndefined()
|
||||
expect(prompt.model.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import type { AppFilePartSource as FilePartSource } from "./backend"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
const text = initial?.prompt
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text === undefined ? undefined : text.length,
|
||||
model: initial?.model ? { ...initial.model } : undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
model: {
|
||||
current: () => store.model,
|
||||
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||
},
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
export function createPromptState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
@@ -1,186 +1,49 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import { ServerConnection } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
type ContextItem,
|
||||
type FileContextItem,
|
||||
type Prompt,
|
||||
type PromptModel,
|
||||
type PromptScope,
|
||||
type PromptSession,
|
||||
} from "./prompt-state"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) {
|
||||
return `${key}:c=${item.commentID}`
|
||||
}
|
||||
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(
|
||||
setStore: SetStoreFunction<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
DEFAULT_PROMPT,
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
export type {
|
||||
AgentPart,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
} from "./prompt-state"
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
||||
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
|
||||
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||
if (!scope.id) return
|
||||
return (
|
||||
@@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
|
||||
)
|
||||
}
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
function scopeKey(scope: PromptScope) {
|
||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
@@ -199,91 +62,6 @@ type PromptCacheEntry = {
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
)
|
||||
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
function promptStore(): PromptStore {
|
||||
return {
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
|
||||
const value = {
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPromptState() {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
|
||||
export const createTabPromptState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
@@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
for (const entry of cache.values()) entry.dispose()
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const owner = getOwner()
|
||||
const serverKey = () =>
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = () =>
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: Scope) => {
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) {
|
||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
}
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
@@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: Scope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
model: {
|
||||
current: () => session().model.current(),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
},
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
@@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: Scope) => pick(scope).reset(),
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: PromptScope) => pick(scope).reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppEvent } from "./backend"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -18,34 +18,36 @@ describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID, field, delta: value },
|
||||
} as Event,
|
||||
type: "timeline.delta",
|
||||
sessionID: "ses",
|
||||
itemID: "msg",
|
||||
contentID: partID,
|
||||
field,
|
||||
delta: value,
|
||||
} as AppEvent,
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
const first = delta("hello ")
|
||||
const second = delta("world")
|
||||
first.payload.id = "first"
|
||||
second.payload.id = "second"
|
||||
const result = coalesceServerEvents([first, second])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
expect(result[0]?.payload).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
|
||||
payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent,
|
||||
}
|
||||
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
||||
|
||||
expect(result.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.delta",
|
||||
"message.part.delta",
|
||||
"session.status",
|
||||
"message.part.delta",
|
||||
"timeline.delta",
|
||||
"timeline.delta",
|
||||
"session.activity",
|
||||
"timeline.delta",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => {
|
||||
const first = delta("a")
|
||||
const other = delta("b", "text", "other")
|
||||
const last = delta("c")
|
||||
first.payload.id = "1"
|
||||
other.payload.id = "2"
|
||||
last.payload.id = "3"
|
||||
|
||||
const result = coalesceServerEvents([first, other, last])
|
||||
|
||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("enqueueServerEvent", () => {
|
||||
const partUpdated = (text: string) =>
|
||||
({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
type: "timeline.updated",
|
||||
item: {
|
||||
type: "user",
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||
created: 1,
|
||||
content: [{ id: "part", type: "text", text }],
|
||||
},
|
||||
}) as Event
|
||||
}) as AppEvent
|
||||
|
||||
test("preserves part updates across message remove and re-add barriers", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||
enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
|
||||
enqueue({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
type: "timeline.updated",
|
||||
item: {
|
||||
type: "user",
|
||||
created: 1,
|
||||
content: [],
|
||||
sessionID: "session",
|
||||
info: {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
},
|
||||
id: "message",
|
||||
},
|
||||
} as Event)
|
||||
})
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"message.removed",
|
||||
"message.updated",
|
||||
"message.part.updated",
|
||||
"timeline.updated",
|
||||
"timeline.removed",
|
||||
"timeline.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves deltas after a replacement snapshot", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("a"))
|
||||
enqueue(partUpdated("ab"))
|
||||
enqueue({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||
} as Event)
|
||||
type: "timeline.delta",
|
||||
sessionID: "session",
|
||||
itemID: "message",
|
||||
contentID: "part",
|
||||
field: "text",
|
||||
delta: "c",
|
||||
})
|
||||
|
||||
const result = coalesceServerEvents(events)
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
|
||||
expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
|
||||
expect(result[1]?.payload).toMatchObject({ delta: "c" })
|
||||
})
|
||||
|
||||
test("preserves updates after session deletion", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({
|
||||
id: "event-delete",
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session" },
|
||||
} as Event)
|
||||
sessionID: "session",
|
||||
})
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"timeline.updated",
|
||||
"session.deleted",
|
||||
"message.part.updated",
|
||||
"timeline.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not coalesce edge-triggered session statuses", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||
const enqueue = (status: "retry" | "busy") =>
|
||||
enqueueServerEvent(events, {
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||
},
|
||||
} as Event,
|
||||
type: "session.activity",
|
||||
sessionID: "session",
|
||||
activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
|
||||
},
|
||||
})
|
||||
|
||||
enqueue("retry")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppClient, AppEvent } from "./backend"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
@@ -15,14 +14,15 @@ const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
type QueuedServerEvent = { directory: string; payload: AppEvent }
|
||||
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||
if (event.payload.type === "timeline.updated") {
|
||||
return `timeline.updated:${event.directory}:${event.payload.item.id}`
|
||||
}
|
||||
if (event.payload.type === "timeline.content.updated")
|
||||
return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
if (event.payload.type !== "timeline.delta") {
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
const props = event.payload.properties
|
||||
const previous = output[output.length - 1]
|
||||
if (
|
||||
!previous ||
|
||||
previous.payload.type !== "message.part.delta" ||
|
||||
previous.payload.type !== "timeline.delta" ||
|
||||
previous.directory !== event.directory ||
|
||||
previous.payload.properties.messageID !== props.messageID ||
|
||||
previous.payload.properties.partID !== props.partID ||
|
||||
previous.payload.properties.field !== props.field
|
||||
previous.payload.itemID !== event.payload.itemID ||
|
||||
previous.payload.contentID !== event.payload.contentID ||
|
||||
previous.payload.field !== event.payload.field
|
||||
) {
|
||||
output.push({
|
||||
directory: event.directory,
|
||||
payload: { ...event.payload, properties: { ...props } },
|
||||
payload: { ...event.payload },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||
delta: previous.payload.delta + event.payload.delta,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -76,7 +75,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||
start()
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -91,13 +90,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
})()
|
||||
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
[key: string]: AppEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
@@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (isStreamClosed(error, attempt?.signal)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
const directory = envelope.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
@@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
type ServerSDKWithBackend = ServerSDKBase & { backend: Promise<AppClient> }
|
||||
export type ServerSDK = ServerSDKWithBackend & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||
const sdk = createServerSdkContextBase(server, scope)
|
||||
export function createServerSdkContext(
|
||||
server: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
backend: Promise<AppClient>,
|
||||
): ServerSDK {
|
||||
const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
|
||||
return Object.assign(sdk, {
|
||||
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
})
|
||||
@@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
[key in AppEvent["type"]]: AppEvent
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
@@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
return {
|
||||
scope: serverSDK.scope,
|
||||
directory,
|
||||
client,
|
||||
backend: serverSDK.backend,
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
||||
return serverSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type {
|
||||
AppFileDiff,
|
||||
AppMessage as Message,
|
||||
AppPart as Part,
|
||||
AppSession as Session,
|
||||
AppTodo,
|
||||
TimelineContent,
|
||||
TimelineItem,
|
||||
} from "./backend"
|
||||
import { createAppClient } from "./backend.test-fixture"
|
||||
import { createServerSession as createAppServerSession } from "./server-session"
|
||||
|
||||
type FixtureClient = {
|
||||
session: {
|
||||
get(input: unknown): Promise<{ data: Session }>
|
||||
messages(input: unknown): MessageResponse | Promise<MessageResponse>
|
||||
message?(input: unknown): SingleMessageResponse | Promise<SingleMessageResponse>
|
||||
diff?(input: unknown): Promise<{ data: AppFileDiff[] }>
|
||||
todo?(input: unknown): Promise<{ data: AppTodo[] }>
|
||||
}
|
||||
}
|
||||
|
||||
const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) =>
|
||||
createAppServerSession(
|
||||
createAppClient({
|
||||
version: "v1",
|
||||
common: {
|
||||
sessions: {
|
||||
get: async (input) => (await client.session.get(input)).data,
|
||||
history: async (input) => {
|
||||
const result = await client.session.messages(input)
|
||||
return {
|
||||
items: result.data.map((item) => timelineItem(item.info, item.parts)),
|
||||
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||
}
|
||||
},
|
||||
message: async (input) => {
|
||||
if (!client.session.message) throw new Error("Message fixture is not configured")
|
||||
const result = await client.session.message(input)
|
||||
return timelineItem(result.data.info, result.data.parts)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
const session = (id: string, parentID?: string): Session => ({
|
||||
id,
|
||||
@@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function timelineItem(info: Message, parts: Part[]): TimelineItem {
|
||||
const content = parts.map(toTimelineContent)
|
||||
if (info.role === "user")
|
||||
return {
|
||||
type: "user",
|
||||
id: info.id,
|
||||
sessionID: info.sessionID,
|
||||
created: info.time.created,
|
||||
content,
|
||||
agent: info.agent,
|
||||
model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant },
|
||||
format: info.format,
|
||||
summary: info.summary,
|
||||
system: info.system,
|
||||
tools: info.tools,
|
||||
}
|
||||
return {
|
||||
type: "assistant",
|
||||
id: info.id,
|
||||
sessionID: info.sessionID,
|
||||
parentID: info.parentID,
|
||||
created: info.time.created,
|
||||
completed: info.time.completed,
|
||||
content,
|
||||
agent: info.agent,
|
||||
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
|
||||
tokens: info.tokens,
|
||||
error: info.error,
|
||||
mode: info.mode,
|
||||
path: info.path,
|
||||
cost: info.cost,
|
||||
structured: info.structured,
|
||||
finish: info.finish,
|
||||
summary: info.summary,
|
||||
}
|
||||
}
|
||||
|
||||
function toTimelineContent(part: Part): TimelineContent {
|
||||
if (part.type === "agent")
|
||||
return {
|
||||
type: part.type,
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
|
||||
}
|
||||
if (part.type === "subtask")
|
||||
return {
|
||||
...part,
|
||||
model: part.model && { id: part.model.modelID, providerID: part.model.providerID },
|
||||
}
|
||||
if (part.type !== "file") return { ...part }
|
||||
return {
|
||||
type: "file",
|
||||
id: part.id,
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mime: part.mime,
|
||||
source:
|
||||
part.source?.type === "resource"
|
||||
? {
|
||||
type: part.source.type,
|
||||
clientName: part.source.clientName,
|
||||
uri: part.source.uri,
|
||||
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
|
||||
}
|
||||
: part.source && {
|
||||
type: part.source.type,
|
||||
path: part.source.path,
|
||||
name: part.source.type === "symbol" ? part.source.name : undefined,
|
||||
kind: part.source.type === "symbol" ? part.source.kind : undefined,
|
||||
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
let index = 0
|
||||
const requests: unknown[] = []
|
||||
@@ -81,7 +200,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
||||
return responses[index++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
} as FixtureClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
requested(count: number) {
|
||||
@@ -114,7 +233,7 @@ function rootMessageClient(
|
||||
return roots[rootIndex++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
} as FixtureClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
rootRequests,
|
||||
@@ -152,7 +271,7 @@ function setup(sessions: Record<string, Session>) {
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
} as FixtureClient
|
||||
return { get, messages, store: createServerSession(client) }
|
||||
}
|
||||
|
||||
@@ -163,7 +282,10 @@ describe("server session", () => {
|
||||
const result = await ctx.store.lineage.resolve("child")
|
||||
|
||||
expect(result.root.id).toBe("root")
|
||||
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
|
||||
expect(ctx.get).toEqual([
|
||||
{ sessionID: "child", location: undefined },
|
||||
{ sessionID: "root", location: undefined },
|
||||
])
|
||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
@@ -194,7 +316,9 @@ describe("server session", () => {
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(client.rootRequests).toEqual([
|
||||
{ sessionID: "child", messageID: user.id, location: { directory: "/repo" } },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
@@ -278,7 +402,9 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(client.rootRequests).toEqual([
|
||||
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
@@ -300,7 +426,9 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(client.rootRequests).toEqual([
|
||||
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
@@ -636,6 +764,21 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "hello" })
|
||||
const fetched = textPart(message.id, { id: "message:text", text: "hello" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||
})
|
||||
|
||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
@@ -1207,6 +1350,7 @@ describe("server session", () => {
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
guard.active = false
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
})
|
||||
|
||||
@@ -1429,4 +1573,28 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
|
||||
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("force-resyncs pinned sessions after a stream reconnect", async () => {
|
||||
const first = userMessage("first")
|
||||
const second = userMessage("second", { time: { created: 2 } })
|
||||
let messages = response([{ info: first, parts: [] }])
|
||||
let requests = 0
|
||||
const store = createServerSession({
|
||||
session: {
|
||||
get: async () => ({ data: session("child") }),
|
||||
messages: async () => {
|
||||
requests++
|
||||
return messages
|
||||
},
|
||||
},
|
||||
})
|
||||
store.pin("child")
|
||||
await store.sync("child")
|
||||
messages = response([{ info: first, parts: [] }, { info: second, parts: [] }])
|
||||
|
||||
await store.resync()
|
||||
|
||||
expect(requests).toBe(2)
|
||||
expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type {
|
||||
Message,
|
||||
OpencodeClient,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
AppClient,
|
||||
AppEvent,
|
||||
AppFileDiff,
|
||||
AppMessage,
|
||||
AppPart,
|
||||
AppPermissionRequest,
|
||||
AppQuestionRequest,
|
||||
AppSession,
|
||||
AppTodo,
|
||||
LocationRef,
|
||||
SessionActivity,
|
||||
} from "./backend"
|
||||
import { timelineMessage, timelineParts } from "./backend"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
@@ -18,7 +22,7 @@ import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
@@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
message: AppMessage
|
||||
parts: AppPart[]
|
||||
confirmedParts?: AppPart[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
session: AppMessage[]
|
||||
part: { id: string; part: AppPart[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
@@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick<
|
||||
>
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
const observed: { messageID: string; parts: AppPart[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
if (!result.found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
const confirmed = result.found
|
||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||
const current = part.get(item.message.id) ?? []
|
||||
const matched = result.found
|
||||
? item.parts.flatMap((optimistic) => {
|
||||
const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic))
|
||||
return fetched ? [{ optimistic, fetched }] : []
|
||||
})
|
||||
: []
|
||||
const confirmed = matched.map((value) => value.optimistic)
|
||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
result.found
|
||||
? current.map((value) => {
|
||||
const match = matched.find((item) => item.fetched.id === value.id)
|
||||
return match ? { ...value, id: match.optimistic.id } : value
|
||||
})
|
||||
: merge(item.confirmedParts ?? [], current),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
@@ -87,6 +100,14 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function samePromptPart(a: AppPart, b: AppPart) {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === "text" && b.type === "text") return a.text === b.text
|
||||
if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime
|
||||
if (a.type === "agent" && b.type === "agent") return a.name === b.name
|
||||
return false
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
@@ -134,21 +155,22 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
info: {} as Record<string, AppSession | undefined>,
|
||||
session_status: {} as Record<string, SessionActivity>,
|
||||
session_diff: {} as Record<string, AppFileDiff[]>,
|
||||
todo: {} as Record<string, AppTodo[]>,
|
||||
permission: {} as Record<string, AppPermissionRequest[]>,
|
||||
question: {} as Record<string, AppQuestionRequest[]>,
|
||||
message: {} as Record<string, AppMessage[]>,
|
||||
part: {} as Record<string, AppPart[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
return (this.session_status[id]?.type ?? "idle") !== "idle"
|
||||
},
|
||||
})
|
||||
const requests = new Map<string, Promise<Session>>()
|
||||
const requests = new Map<string, Promise<AppSession>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
@@ -158,7 +180,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
cache: { part: Record<string, AppPart[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
) => {
|
||||
for (const part of cache.part[messageID] ?? []) {
|
||||
@@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const remember = (session: Session) => {
|
||||
const locations = new Map<string, LocationRef>()
|
||||
const location = (sessionID: string) => {
|
||||
const session = data.info[sessionID]
|
||||
return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined)
|
||||
}
|
||||
|
||||
const remember = (session: AppSession) => {
|
||||
if (session.location) locations.set(session.id, session.location)
|
||||
if (session.parentID && session.location) locations.set(session.parentID, session.location)
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
infoSeen.add(session.id)
|
||||
@@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
const request = Promise.resolve(backend)
|
||||
.then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
|
||||
.then((result) => {
|
||||
if (!result) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
@@ -297,7 +329,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
@@ -314,7 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
@@ -461,35 +493,38 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
const response = await (options?.retry ?? retry)(async () => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) })
|
||||
})
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
const items = response.items
|
||||
.map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) }))
|
||||
.filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: items.map((item) => ({
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
id: item.message.id,
|
||||
part: item.parts.sort((a, b) => cmp(a.id, b.id)),
|
||||
})),
|
||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.headers.get("x-next-cursor"),
|
||||
cursor: response.older,
|
||||
complete: !response.older,
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
const response = await (options?.retry ?? retry)(async () => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) })
|
||||
})
|
||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||
const message = timelineMessage(response)
|
||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||
return {
|
||||
message: cleanMessage(response.data.info),
|
||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
message: cleanMessage(message),
|
||||
parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const replaceMessages = (sessionID: string, messages: AppMessage[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
@@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
sessionID: string,
|
||||
page: MessagePage,
|
||||
load: MessageLoadState | undefined,
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
preserveUnfetched: boolean | ((message: AppMessage) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
@@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
let applied = false
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
const first = page.session.reduce<AppMessage | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
@@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const parentIDs = [
|
||||
...new Set(
|
||||
page.session.flatMap((message) =>
|
||||
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
|
||||
message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -659,7 +694,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
),
|
||||
}
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
result,
|
||||
@@ -682,7 +717,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
const sync = (
|
||||
sessionID: string,
|
||||
options?: { force?: boolean; messageLimit?: number; location?: LocationRef },
|
||||
) => {
|
||||
if (options?.location) locations.set(sessionID, options.location)
|
||||
touch(sessionID)
|
||||
return runInflight(inflight, sessionID, async () => {
|
||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||
@@ -729,7 +768,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return properties.part.sessionID
|
||||
}
|
||||
|
||||
const apply = (event: { type: string; properties?: unknown }) => {
|
||||
const applyLegacy = (event: { type: string; properties?: unknown }) => {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
touch(eventID)
|
||||
@@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
remember((event.properties as { info: Session }).info)
|
||||
remember((event.properties as { info: AppSession }).info)
|
||||
return
|
||||
case "session.updated": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const info = (event.properties as { info: AppSession }).info
|
||||
remember(info)
|
||||
if (info.time.archived) evict([info.id])
|
||||
return
|
||||
}
|
||||
case "session.deleted": {
|
||||
const sessionID = (event.properties as { info: Session }).info.id
|
||||
const sessionID = (event.properties as { sessionID: string }).sessionID
|
||||
infoSeen.delete(sessionID)
|
||||
setData(
|
||||
"info",
|
||||
@@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: AppTodo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
return
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
const props = event.properties as { sessionID: string; status: SessionActivity }
|
||||
setData("session_status", props.sessionID, reconcile(props.status))
|
||||
return
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
const info = cleanMessage((event.properties as { info: AppMessage }).info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
@@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
const part = (event.properties as { part: AppPart }).part
|
||||
if (SKIP_PARTS.has(part.type)) return
|
||||
const messages = data.message[part.sessionID]
|
||||
const load = messageLoads.get(part.sessionID)
|
||||
@@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permission = event.properties as AppPermissionRequest
|
||||
const permissions = data.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
setData("permission", permission.sessionID, [permission])
|
||||
@@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const question = event.properties as AppQuestionRequest
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
@@ -1033,6 +1077,118 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
}
|
||||
|
||||
const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => {
|
||||
if ("properties" in event) {
|
||||
applyLegacy(event)
|
||||
return
|
||||
}
|
||||
const sessionID =
|
||||
event.type === "session.created" || event.type === "session.updated"
|
||||
? event.session.id
|
||||
: "sessionID" in event && typeof event.sessionID === "string"
|
||||
? event.sessionID
|
||||
: event.type === "timeline.updated"
|
||||
? event.item.sessionID
|
||||
: undefined
|
||||
if (sessionID && eventLocation) locations.set(sessionID, eventLocation)
|
||||
if (event.type === "session.created" || event.type === "session.updated") {
|
||||
applyLegacy({ type: event.type, properties: { info: event.session } })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.deleted") {
|
||||
applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.moved") {
|
||||
const current = data.info[event.sessionID]
|
||||
if (!current) return
|
||||
remember({
|
||||
...current,
|
||||
location: event.location,
|
||||
directory: event.location.directory,
|
||||
workspaceID: event.location.workspaceID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.revert") {
|
||||
const current = data.info[event.sessionID]
|
||||
if (current) remember({ ...current, revert: event.revert })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.activity") {
|
||||
applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } })
|
||||
if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.diff" || event.type === "todo.updated") {
|
||||
applyLegacy({ type: event.type, properties: event })
|
||||
return
|
||||
}
|
||||
if (event.type === "timeline.updated") {
|
||||
const message = timelineMessage(event.item)
|
||||
if (!message) return
|
||||
applyLegacy({ type: "message.updated", properties: { info: message } })
|
||||
timelineParts(event.item).forEach((part) =>
|
||||
applyLegacy({ type: "message.part.updated", properties: { part } }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === "timeline.content.updated") {
|
||||
const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID)
|
||||
if (!message) return
|
||||
const part = timelineParts({
|
||||
type: message.role,
|
||||
id: message.id,
|
||||
sessionID: message.sessionID,
|
||||
created: message.time.created,
|
||||
completed: message.role === "assistant" ? message.time.completed : undefined,
|
||||
content: [event.content],
|
||||
})[0]
|
||||
if (part) applyLegacy({ type: "message.part.updated", properties: { part } })
|
||||
return
|
||||
}
|
||||
if (event.type === "timeline.removed") {
|
||||
applyLegacy({
|
||||
type: "message.removed",
|
||||
properties: { sessionID: event.sessionID, messageID: event.itemID },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "timeline.part.removed") {
|
||||
applyLegacy({
|
||||
type: "message.part.removed",
|
||||
properties: {
|
||||
sessionID: event.sessionID,
|
||||
messageID: event.itemID,
|
||||
partID: event.contentID,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "timeline.delta") {
|
||||
applyLegacy({
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: event.sessionID,
|
||||
messageID: event.itemID,
|
||||
partID: event.contentID,
|
||||
field: event.field,
|
||||
delta: event.delta,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "permission.requested") {
|
||||
applyLegacy({ type: "permission.asked", properties: event.request })
|
||||
return
|
||||
}
|
||||
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") {
|
||||
applyLegacy({ type: event.type, properties: event })
|
||||
return
|
||||
}
|
||||
if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request })
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
set: setData,
|
||||
@@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
@@ -1122,9 +1278,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
return retry(async () => {
|
||||
const client = await backend
|
||||
const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2
|
||||
if (!capability?.diff) return []
|
||||
return capability.diff({ sessionID, location: location(sessionID) })
|
||||
}).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -1155,6 +1316,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (!count || count === 1) pinned.delete(sessionID)
|
||||
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||
},
|
||||
async resync() {
|
||||
const sessionIDs = new Set([
|
||||
...pinned.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]),
|
||||
])
|
||||
await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true })))
|
||||
},
|
||||
apply,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type {
|
||||
Config,
|
||||
McpResource,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
AppClient,
|
||||
AppConfig,
|
||||
AppMcpResource,
|
||||
AppPathInfo,
|
||||
AppProject,
|
||||
AppProviderAuthResponse,
|
||||
} from "./backend"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
@@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -37,7 +37,6 @@ import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
@@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
path: AppPathInfo
|
||||
project: AppProject[]
|
||||
provider: ProviderStore
|
||||
provider_auth: AppProviderAuthResponse
|
||||
config: StoreConfig
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
queryFn: () =>
|
||||
backend
|
||||
.then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? [])
|
||||
.then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))),
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<Record<string, McpResource>>({
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||
queryOptions<Record<string, AppMcpResource>>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||
queryFn: () =>
|
||||
backend
|
||||
.then(
|
||||
(client) =>
|
||||
client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] },
|
||||
)
|
||||
.then((result) =>
|
||||
Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
),
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
queryFn: async () => {
|
||||
const client = await backend
|
||||
return client.capabilities.lsp?.status({ location: { directory } }) ?? []
|
||||
},
|
||||
})
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
) {
|
||||
function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, backend),
|
||||
projects: () => loadProjectsQuery(scope, backend),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
}
|
||||
@@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const sdkCache = new Map<string, OpencodeClient>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const cached = sdkCache.get(key)
|
||||
if (cached) return cached
|
||||
const sdk = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(key, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
@@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||
})
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
setProjects(input[1] as AppProject[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
@@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
backend: serverSDK.backend,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
@@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||
setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[]))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
@@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const session = createServerSession(serverSDK.client)
|
||||
const session = createServerSession(serverSDK.backend)
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
@@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void retry(() =>
|
||||
sdkFor(directory)
|
||||
.command.list()
|
||||
.then((x) => setStore("command", x.data ?? [])),
|
||||
serverSDK.backend
|
||||
.then((client) => client.common.commands.list({ location: { directory } }))
|
||||
.then((commands) => setStore("command", reconcile(commands))),
|
||||
).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
sdkCache.delete(key)
|
||||
clearProviderRev(serverSDK.scope, key)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => serverSDK.client.session.list(query),
|
||||
list: (query) =>
|
||||
serverSDK.backend.then((client) =>
|
||||
client.common.sessions
|
||||
.list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit })
|
||||
.then((page) => ({ data: [...page.items] })),
|
||||
),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
@@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const child = children.ensureChild(directory)
|
||||
const cache = children.vcsCache.get(key)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
const backend = await serverSDK.backend
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
scope: serverSDK.scope,
|
||||
@@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
backend,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
@@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
session.apply(event)
|
||||
session.apply(event, directory === "global" ? undefined : { directory })
|
||||
|
||||
if (event.type === "server.connected") void session.resync()
|
||||
if (event.type === "provider.updated") {
|
||||
if (!recent) bootstrap.refetch()
|
||||
if (directory !== "global") queue.push(directory)
|
||||
return
|
||||
}
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
@@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (event.type === "server.connected" || event.type === "server.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
@@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
||||
mutationFn: async (config: GlobalConfigUpdate) => {
|
||||
const backend = await serverSDK.backend
|
||||
const capability = backend.capabilities.configuration
|
||||
if (!capability) throw new Error("Server does not support configuration updates")
|
||||
await capability.updateGlobal({
|
||||
shell: config.shell,
|
||||
provider: config.provider,
|
||||
disabledProviders: config.disabledProviders,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
@@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryOptions: queryOptionsApi,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
refreshProviders: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||
})
|
||||
await bootstrap.refetch()
|
||||
},
|
||||
project: projectApi,
|
||||
session,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const backend = await serverSDK.backend
|
||||
const capability = backend.capabilities.mcpControl
|
||||
if (!capability) throw new Error("Server does not support MCP controls")
|
||||
const location = { location: { directory: key } }
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
await capability.connect({ ...location, name })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
await capability.disconnect({ ...location, name })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
await capability.authenticate({ ...location, name })
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage as Message, AppPart as Part } from "./backend"
|
||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
||||
|
||||
type Text = Extract<Part, { type: "text" }>
|
||||
|
||||
@@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage, AppPart } from "./backend"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
function sortParts(parts: AppPart[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
message: Record<string, AppMessage[] | undefined>
|
||||
part: Record<string, AppPart[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
message: AppMessage
|
||||
parts: AppPart[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
@@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
message: AppMessage
|
||||
parts: AppPart[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
session: AppMessage[]
|
||||
part: { id: string; part: AppPart[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
|
||||
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
|
||||
}
|
||||
|
||||
return {
|
||||
get<T>(key: string, name: string) {
|
||||
return entries.get(key)?.get(name)?.value as T | undefined
|
||||
},
|
||||
ensure<T>(key: string, name: string, init: () => T) {
|
||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||
if (!entries.has(key)) entries.set(key, state)
|
||||
|
||||
@@ -22,6 +22,8 @@ describe("tab memory", () => {
|
||||
})
|
||||
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
|
||||
expect(memory.get("missing", "prompt")).toBeUndefined()
|
||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||
|
||||
memory.remove("tab")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppSession } from "./backend"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -44,7 +45,7 @@ export const tabHref = (tab: Tab) =>
|
||||
|
||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) {
|
||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||
}
|
||||
|
||||
@@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
const draftID = uuid()
|
||||
const tab = { type: "draft" as const, draftID, ...draft }
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
tabs.push(tab)
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
navigate(draftHref(draftID))
|
||||
})
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
@@ -345,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of removed) memory.remove(key)
|
||||
for (const key of removed) removeInfo(key)
|
||||
},
|
||||
rememberSessionInfo(tab: SessionTab, session: Session) {
|
||||
rememberSessionInfo(tab: SessionTab, session: AppSession) {
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.directory }
|
||||
const current = info[key]
|
||||
@@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
state<T>(tab: Tab, name: string, init: () => T) {
|
||||
return memory.ensure(tabKey(tab), name, init)
|
||||
},
|
||||
stateValue<T>(tab: Tab, name: string) {
|
||||
return memory.get<T>(tabKey(tab), name)
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, recentReady }
|
||||
|
||||
@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
|
||||
scope: ServerScopeValue,
|
||||
legacySessionID?: string,
|
||||
) {
|
||||
const location = { directory: sdk.directory }
|
||||
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
@@ -198,23 +199,27 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
||||
removeExited(event.properties.id)
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
if (event.type !== "pty.exited") return
|
||||
removeExited(event.ptyID)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const update = (pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
client.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
sdk.backend
|
||||
.then((client) =>
|
||||
client.common.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
location,
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (previous) {
|
||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||
@@ -224,26 +229,24 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
||||
const clone = async (id: string) => {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const next = await client.pty
|
||||
.create({
|
||||
title: pty.title,
|
||||
})
|
||||
const next = await sdk.backend
|
||||
.then((client) => client.common.pty.create({ title: pty.title, location }))
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
if (!next?.data) return
|
||||
if (!next) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
id: next.data.id,
|
||||
title: next.data.title ?? pty.title,
|
||||
id: next.id,
|
||||
title: next.title ?? pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
buffer: undefined,
|
||||
cursor: undefined,
|
||||
@@ -252,7 +255,7 @@ function createWorkspaceTerminalSession(
|
||||
cols: undefined,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", next.data.id)
|
||||
setStore("active", next.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -270,14 +273,14 @@ function createWorkspaceTerminalSession(
|
||||
new() {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
sdk.backend
|
||||
.then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
|
||||
.then((pty) => {
|
||||
const id = pty.id
|
||||
if (!id) return
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
title: pty.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
@@ -288,7 +291,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
update(sdk.client, pty)
|
||||
update(pty)
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -303,10 +306,9 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
await clone(sdk.client, id)
|
||||
await clone(id)
|
||||
},
|
||||
bind() {
|
||||
const client = sdk.client
|
||||
return {
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -314,10 +316,10 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
update(client, pty)
|
||||
update(pty)
|
||||
},
|
||||
async clone(id: string) {
|
||||
await clone(client, id)
|
||||
await clone(id)
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -353,9 +355,11 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
await sdk.backend
|
||||
.then((client) => client.common.pty.remove({ ptyID: id, location }))
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderStore } from "@/context/global-sync/types"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
|
||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||
const catalog = (id: string): ProviderStore => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
|
||||
connected: [id],
|
||||
default: { [id]: `${id}-model` },
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderStore } from "@/context/global-sync/types"
|
||||
|
||||
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} }
|
||||
|
||||
type DirectoryCatalog = {
|
||||
ready: boolean
|
||||
providers: NormalizedProviderListResponse
|
||||
providers: ProviderStore
|
||||
}
|
||||
|
||||
type ProviderCatalogInput =
|
||||
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
|
||||
explicit: false
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
global: NormalizedProviderListResponse
|
||||
global: ProviderStore
|
||||
}
|
||||
|
||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.message.next.description": "Go to the next user message",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.choose.description": "Select a different model",
|
||||
"command.mcp.toggle": "Toggle MCPs",
|
||||
"command.mcp.toggle.description": "Toggle MCPs",
|
||||
"command.mcp.toggle": "Manage MCP servers",
|
||||
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.agent.cycle.description": "Switch to the next agent",
|
||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||
@@ -307,9 +307,9 @@ export const dict = {
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.title": "MCP servers",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
"dialog.mcp.empty": "No MCP servers configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppSession as Session } from "@/context/backend"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
@@ -508,7 +508,15 @@ export function NewHome() {
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
update: (value) => ctx.sdk.client.session.update(value),
|
||||
update: async (value) => {
|
||||
const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1
|
||||
if (!capability) throw new Error("Session archiving is not supported by this server")
|
||||
await capability.archive({
|
||||
location: { directory: value.directory },
|
||||
sessionID: value.sessionID,
|
||||
archivedAt: value.time.archived,
|
||||
})
|
||||
},
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { AppSession as Session } from "@/context/backend"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
errorMessage,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
workspaceCopyCreateInput,
|
||||
} from "./layout/helpers"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
@@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
export default function LegacyLayout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
const [backend] = createResource(
|
||||
() => serverSDK().backend,
|
||||
(value) => value,
|
||||
)
|
||||
const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||
createStore({
|
||||
@@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
WorktreeState.failed(
|
||||
serverSDK().scope,
|
||||
e.name,
|
||||
e.details.properties?.message ?? language.t("common.requestFailed"),
|
||||
e.details.message ?? language.t("common.requestFailed"),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const props = e.details
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
e.details.type === "permission.requested"
|
||||
? language.t("notification.permission.title")
|
||||
: language.t("notification.question.title")
|
||||
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
|
||||
const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const)
|
||||
const directory = e.name
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
const props = e.details.request
|
||||
if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
|
||||
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const session = store.session.find((s) => s.id === props.sessionID)
|
||||
@@ -431,7 +437,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const sessionTitle = session?.title ?? language.t("command.session.new")
|
||||
const projectName = getFilename(directory)
|
||||
const description =
|
||||
e.details.type === "permission.asked"
|
||||
e.details.type === "permission.requested"
|
||||
? language.t("notification.permission.description", { sessionTitle, projectName })
|
||||
: language.t("notification.question.description", { sessionTitle, projectName })
|
||||
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
||||
@@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
if (now - lastAlerted < cooldownMs) return
|
||||
alertedAtBySession.set(sessionKey, now)
|
||||
|
||||
if (e.details.type === "permission.asked") {
|
||||
if (e.details.type === "permission.requested") {
|
||||
if (settings.sounds.permissionsEnabled()) {
|
||||
void playSoundById(settings.sounds.permissions())
|
||||
}
|
||||
@@ -450,7 +456,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (e.details.type === "question.asked") {
|
||||
if (e.details.type === "question.requested") {
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(title, description, href)
|
||||
}
|
||||
@@ -874,10 +880,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK().client.session.update({
|
||||
directory: session.directory,
|
||||
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
|
||||
if (!capability) return
|
||||
await capability.archive({
|
||||
location: { directory: session.directory },
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
archivedAt: Date.now(),
|
||||
})
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
title: language.t("command.session.archive"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+backspace",
|
||||
disabled: !params.dir || !params.id,
|
||||
disabled: !params.dir || !params.id || !canArchive(),
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) void archiveSession(session)
|
||||
@@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await serverSDK()
|
||||
.client.worktree.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.backend.then((client) => {
|
||||
const location = { directory: root }
|
||||
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location })
|
||||
if (project?.id && client.capabilities.projectCopiesV2?.directories)
|
||||
return client.capabilities.projectCopiesV2
|
||||
.directories({ projectID: project.id, location })
|
||||
.then((items) => items.map((item) => item.directory))
|
||||
return []
|
||||
})
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
return canOpen(target)
|
||||
@@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await serverSDK()
|
||||
.client.session.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.backend.then((client) =>
|
||||
client.common.sessions
|
||||
.list({ location: { directory: item } })
|
||||
.then((page) => [...page.items]),
|
||||
)
|
||||
.catch(() => []),
|
||||
})),
|
||||
),
|
||||
@@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
const editing = (await serverSDK().backend).capabilities.projectEditing
|
||||
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||
await editing.update({ projectID: project.id, location: { directory: project.worktree }, name })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await serverSDK()
|
||||
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.backend.then(async (client) => {
|
||||
const location = { directory: root }
|
||||
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory })
|
||||
const copies = client.capabilities.projectCopiesV2
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server")
|
||||
await copies.remove({ projectID: project.id, location, directory, force: true })
|
||||
return true
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
@@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await serverSDK()
|
||||
.client.session.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
const sessions = await serverSDK()
|
||||
.backend.then((client) =>
|
||||
client.common.sessions
|
||||
.list({ location: { directory } })
|
||||
.then((page) => [...page.items]),
|
||||
)
|
||||
.catch(() => [])
|
||||
|
||||
clearWorkspaceTerminals(
|
||||
@@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
serverSDK().scope,
|
||||
)
|
||||
await serverSDK()
|
||||
.client.instance.dispose({ directory })
|
||||
.backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
|
||||
.catch(() => undefined)
|
||||
|
||||
const result = await serverSDK()
|
||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.backend.then(async (client) => {
|
||||
const location = { directory: root }
|
||||
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory })
|
||||
const copies = client.capabilities.projectCopiesV2
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server")
|
||||
await copies.remove({ projectID: project.id, location, directory, force: true })
|
||||
await copies.create({
|
||||
projectID: project.id,
|
||||
location,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(directory),
|
||||
name: getFilename(directory),
|
||||
})
|
||||
return true
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.reset.failed.title"),
|
||||
@@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: archivedAt },
|
||||
.backend.then(async (client) => {
|
||||
const capability = client.capabilities.sessionExtrasV1
|
||||
if (!capability) return
|
||||
await capability.archive({
|
||||
location: { directory: session.directory },
|
||||
sessionID: session.id,
|
||||
archivedAt,
|
||||
})
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
@@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
|
||||
.then((files) => {
|
||||
const dirty = files.length > 0
|
||||
setData({ status: "ready", dirty })
|
||||
})
|
||||
@@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await serverSDK()
|
||||
.client.session.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.backend.then((client) =>
|
||||
client.common.sessions
|
||||
.list({ location: { directory: props.directory } })
|
||||
.then((page) => [...page.items]),
|
||||
)
|
||||
.catch(() => [])
|
||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||
setState({ sessions: active })
|
||||
@@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
|
||||
.then((files) => {
|
||||
const dirty = files.length > 0
|
||||
setState({ status: "ready", dirty })
|
||||
void refresh()
|
||||
@@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
const created = await serverSDK()
|
||||
.client.worktree.create({ directory: project.worktree })
|
||||
.then((x) => x.data)
|
||||
.backend.then((client) => {
|
||||
const location = { directory: project.worktree }
|
||||
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location })
|
||||
const copies = client.capabilities.projectCopiesV2
|
||||
const input = workspaceCopyCreateInput(project)
|
||||
if (!copies || !input) throw new Error("Project copy creation is not supported by this server")
|
||||
return copies.create({
|
||||
...input,
|
||||
location,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.create.failed.title"),
|
||||
@@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
if (!created?.directory) return
|
||||
|
||||
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
|
||||
const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined
|
||||
setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch)
|
||||
|
||||
const local = project.worktree
|
||||
const key = pathKey(created.directory)
|
||||
@@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
clearHoverProjectSoon,
|
||||
prefetchSession,
|
||||
archiveSession,
|
||||
canArchive,
|
||||
workspaceName,
|
||||
renameWorkspace,
|
||||
editorOpen,
|
||||
@@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
clearHoverProjectSoon,
|
||||
prefetchSession,
|
||||
archiveSession,
|
||||
canArchive,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppSession as Session } from "@/context/backend"
|
||||
import {
|
||||
childSessionOnPath,
|
||||
closeHomeProject,
|
||||
@@ -19,12 +19,22 @@ import {
|
||||
homeSessionServerStatus,
|
||||
latestRootSession,
|
||||
toggleHomeProjectSelection,
|
||||
workspaceCopyCreateInput,
|
||||
} from "./helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
const serverKey = ServerConnection.Key.make
|
||||
|
||||
test("builds a v2 git worktree copy request from the project root", () => {
|
||||
expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({
|
||||
projectID: "project",
|
||||
strategy: "git_worktree",
|
||||
directory: "/repos/",
|
||||
})
|
||||
expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined()
|
||||
})
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
title: "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user